feat: add personal productivity dashboard with stats API

Introduce a dedicated My productivity page at /dashboard/productivity
with streaks, focus metrics, project breakdown, a 12-week heatmap, and
Chart.js charts backed by ProductivityService (user-timezone-aware).
Expose GET /api/productivity/stats with a 5-minute cache when no active
timer is running. Document the feature and related session JSON routes.
This commit is contained in:
Dries Peeters
2026-05-15 08:57:14 +02:00
parent 9e05f26fb9
commit 6de7035b4a
9 changed files with 1799 additions and 4 deletions
+3
View File
@@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- **Personal productivity dashboard** — New **My productivity** page at `/dashboard/productivity` (sidebar link) with today/week summary, streaks, 14-day hours chart, project doughnut, focus stats, 12-week activity heatmap, and insight cards. Backed by `ProductivityService` (user-timezone-aware) and `GET /api/productivity/stats` (`period` 190 days, 5-minute cache when no active timer). See [docs/features/PRODUCTIVITY_DASHBOARD.md](docs/features/PRODUCTIVITY_DASHBOARD.md).
- **AI time entry suggestions** — `GET /api/ai/suggest` returns deterministic (and optional LLM-rich) project/task/notes suggestions. Wired into the Start Timer modal (`components/ai_suggestions.html`) and manual entry **Autofill** (`js/ai_autocomplete.js`) when the AI helper is enabled.
- **Project forecast panel** — `ForecastService` and `GET /api/projects/<id>/forecast` (deterministic metrics plus optional `?ai=true` narrative; 10-minute in-process cache). Self-contained card on active projects with estimated hours or budget. Documented in [docs/BUDGET_ALERTS_AND_FORECASTING.md](docs/BUDGET_ALERTS_AND_FORECASTING.md) and [docs/features/PROJECT_DASHBOARD.md](docs/features/PROJECT_DASHBOARD.md).
- **Smart reminders: break, end-of-day, and idle toasts** — Extends smart in-app notifications with optional **break reminder** (Pomodoro-style nudge every N minutes while a timer runs, 15240 min) and **end-of-day wrap-up** (hours logged today in a configurable hour window). New kinds `break_reminder` and `end_of_day_reminder` in `NotificationService`; user prefs under **Settings → Notifications**; migration `154_add_smart_notify_break_and_eod`. [`app/static/idle.js`](app/static/idle.js) shows blue/purple/green toasts for no-tracking, break, and end-of-day (alongside existing idle stop-timer prompt). APScheduler job `smart_reminder_push` (every 15 min) sends browser push for eligible users when VAPID and push subscriptions are available. Env default `SMART_NOTIFY_END_OF_DAY_AT` (`17:00`). See [docs/features/SMART_NOTIFICATIONS.md](docs/features/SMART_NOTIFICATIONS.md).
## [5.5.7] - 2026-05-14
+69 -3
View File
@@ -827,7 +827,7 @@ def _forecast_cache_bust(project_id: int) -> None:
return
def _forecast_truthy_param(value) -> bool:
def _truthy_param(value) -> bool:
if value is None:
return False
return str(value).strip().lower() in ("1", "true", "yes", "on")
@@ -846,8 +846,8 @@ def project_forecast(project_id):
if not user_can_access_project(current_user, project_id):
return jsonify({"ok": False, "error": "Access denied"}), 403
include_ai = _forecast_truthy_param(request.args.get("ai"))
refresh = _forecast_truthy_param(request.args.get("refresh"))
include_ai = _truthy_param(request.args.get("ai"))
refresh = _truthy_param(request.args.get("refresh"))
if refresh:
_forecast_cache_bust(project_id)
@@ -860,6 +860,7 @@ def project_forecast(project_id):
from app.services.forecast_service import ForecastService
# Cheap probe so the component can hide the AI button when disabled.
try:
ai_enabled = LLMService().is_enabled()
except Exception:
@@ -2352,6 +2353,71 @@ def get_activity_stats():
)
@api_bp.route("/api/productivity/stats")
@login_required
def productivity_stats():
"""Aggregated personal productivity stats for the current user.
Query params:
period (int): days for focus/project breakdowns (default 30, max 90)
"""
try:
try:
period = int(request.args.get("period", 30))
except (TypeError, ValueError):
period = 30
period = max(1, min(period, 90))
from app.services.productivity_service import ProductivityService
cache_key = None
cache_obj = None
try:
from app.utils.cache import get_cache as _get_app_cache
cache_obj = _get_app_cache()
cache_key = f"productivity:stats:{current_user.id}:{period}"
cached = cache_obj.get(cache_key) if cache_obj is not None else None
if cached is not None:
return jsonify(cached)
except Exception:
cache_obj = None
summary = ProductivityService.get_summary(current_user)
daily_breakdown = ProductivityService.get_daily_breakdown(current_user, days=14)
streak = ProductivityService.get_streak(current_user)
focus = ProductivityService.get_focus_stats(current_user, days=period)
projects = ProductivityService.get_project_breakdown(current_user, days=period)
heatmap = ProductivityService.get_weekly_heatmap(current_user, weeks=12)
insights = ProductivityService.get_insights(
current_user, summary, daily_breakdown, streak, focus, projects
)
payload = {
"ok": True,
"period": period,
"summary": summary,
"daily_breakdown": daily_breakdown,
"streak": streak,
"focus": focus,
"projects": projects,
"heatmap": heatmap,
"insights": insights,
}
# Don't cache when an active timer is running so the UI stays fresh.
if cache_obj is not None and cache_key and not (summary or {}).get("active_timer"):
try:
cache_obj.set(cache_key, payload, ttl=300)
except Exception:
pass
return jsonify(payload)
except Exception as exc:
current_app.logger.exception("productivity_stats failed")
return jsonify({"ok": False, "error": str(exc) or "internal_error"}), 500
# WebSocket event handlers
@socketio.on("connect")
def handle_connect():
+34
View File
@@ -286,6 +286,40 @@ def dashboard():
return render_template("main/dashboard.html", **template_data)
@main_bp.route("/dashboard/productivity")
@login_required
def productivity_dashboard():
"""Personal productivity dashboard: streaks, focus, project mix, heatmap."""
track_page_view("productivity_dashboard")
from app.services.productivity_service import ProductivityService
summary = ProductivityService.get_summary(current_user)
daily_breakdown = ProductivityService.get_daily_breakdown(current_user, days=14)
streak = ProductivityService.get_streak(current_user)
focus = ProductivityService.get_focus_stats(current_user, days=30)
projects = ProductivityService.get_project_breakdown(current_user, days=30)
heatmap = ProductivityService.get_weekly_heatmap(current_user, weeks=12)
insights = ProductivityService.get_insights(
current_user, summary, daily_breakdown, streak, focus, projects
)
standard_hours_per_day = float(getattr(current_user, "standard_hours_per_day", 8.0) or 8.0)
return render_template(
"main/productivity_dashboard.html",
summary=summary,
daily_breakdown=daily_breakdown,
streak=streak,
focus=focus,
projects=projects,
heatmap=heatmap,
insights=insights,
standard_hours_per_day=standard_hours_per_day,
period_days=30,
)
@main_bp.route("/_health")
def health_check():
"""Liveness probe: shallow checks only, no DB access"""
+736
View File
@@ -0,0 +1,736 @@
"""Personal productivity stats for the per-user productivity dashboard.
All methods are read-only, take ``user`` as the first argument, and never
raise — they return safe defaults on any error so the dashboard always
renders. Time bucketing is performed in the user's local timezone.
"""
from __future__ import annotations
import logging
from collections import defaultdict
from datetime import date, datetime, time, timedelta
from typing import Any, Dict, List, Optional
from sqlalchemy import func
from app import db
from app.models import Project, TimeEntry
from app.utils.timezone import (
get_timezone_for_user,
get_timezone_obj,
now_in_user_timezone,
)
logger = logging.getLogger(__name__)
# Deterministic palette indexed by project_id % 10 (matches template legend).
_PROJECT_PALETTE = [
"#3b82f6",
"#10b981",
"#f59e0b",
"#ef4444",
"#8b5cf6",
"#ec4899",
"#14b8a6",
"#f97316",
"#6366f1",
"#84cc16",
]
_DOW_NAMES = (
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
"Sunday",
)
def _safe_user_id(user) -> Optional[int]:
try:
uid = int(getattr(user, "id", 0) or 0)
return uid if uid > 0 else None
except (TypeError, ValueError):
return None
def _user_today(user) -> date:
try:
return now_in_user_timezone(user).date()
except Exception:
return datetime.utcnow().date()
def _week_start_for(user, today: date) -> date:
"""Return the Monday on or before ``today`` (always Monday per spec)."""
return today - timedelta(days=today.weekday())
def _user_day_bounds_app_naive(user, day: date):
"""Return (start_naive, end_naive_exclusive) in app TZ for the given user-local calendar day.
TimeEntry.start_time is stored as naive datetime in the application timezone, so
we project the user's local day boundaries into the app TZ (and strip tzinfo) to
keep filters compatible with the rest of the codebase.
"""
try:
user_tz = get_timezone_for_user(user)
app_tz = get_timezone_obj()
start_local = datetime.combine(day, time.min).replace(tzinfo=user_tz)
end_local = start_local + timedelta(days=1)
start_app = start_local.astimezone(app_tz).replace(tzinfo=None)
end_app = end_local.astimezone(app_tz).replace(tzinfo=None)
return start_app, end_app
except Exception:
# Fallback: treat the user date as naive app-local
start = datetime.combine(day, time.min)
return start, start + timedelta(days=1)
def _user_period_bounds_app_naive(user, start_day: date, end_day_inclusive: date):
"""Return (start_naive, end_naive_exclusive) in app TZ for [start_day, end_day_inclusive]."""
start_app, _ = _user_day_bounds_app_naive(user, start_day)
_, end_app = _user_day_bounds_app_naive(user, end_day_inclusive)
return start_app, end_app
def _to_user_local(dt, user_tz, app_tz):
"""Convert a (naive app-local) datetime into the user's local timezone."""
if dt is None:
return None
try:
if dt.tzinfo is None:
aware = dt.replace(tzinfo=app_tz)
else:
aware = dt
return aware.astimezone(user_tz)
except Exception:
return None
def _format_active_timer(timer) -> Optional[Dict[str, Any]]:
if timer is None:
return None
try:
return {
"id": int(timer.id),
"project_name": timer.project.name if getattr(timer, "project", None) else None,
"task_name": timer.task.name if getattr(timer, "task", None) else None,
"start_time": timer.start_time.isoformat() if timer.start_time else None,
"duration_seconds": int(timer.current_duration_seconds or 0),
}
except Exception:
return None
class ProductivityService:
"""Aggregate personal productivity stats for the authenticated user."""
# --------------------------------------------------------------- summary
@classmethod
def get_summary(cls, user) -> Dict[str, Any]:
"""Stats for today and the current week."""
empty: Dict[str, Any] = {
"today_hours": 0.0,
"week_hours": 0.0,
"week_goal_hours": 40.0,
"week_goal_percent": 0,
"active_timer": None,
"billable_percent_week": 0,
"top_project_week": None,
}
uid = _safe_user_id(user)
if uid is None:
return empty
try:
today = _user_today(user)
week_start = _week_start_for(user, today)
today_start, today_end = _user_day_bounds_app_naive(user, today)
week_start_dt, week_end_dt = _user_period_bounds_app_naive(user, week_start, today)
today_seconds = (
db.session.query(func.coalesce(func.sum(TimeEntry.duration_seconds), 0))
.filter(
TimeEntry.user_id == uid,
TimeEntry.end_time.isnot(None),
TimeEntry.start_time >= today_start,
TimeEntry.start_time < today_end,
)
.scalar()
or 0
)
week_rows = (
db.session.query(
func.coalesce(func.sum(TimeEntry.duration_seconds), 0).label("total_sec"),
func.coalesce(
func.sum(
db.case(
(TimeEntry.billable == True, TimeEntry.duration_seconds), # noqa: E712
else_=0,
)
),
0,
).label("billable_sec"),
)
.filter(
TimeEntry.user_id == uid,
TimeEntry.end_time.isnot(None),
TimeEntry.start_time >= week_start_dt,
TimeEntry.start_time < week_end_dt,
)
.one()
)
today_hours = round(int(today_seconds) / 3600.0, 2)
week_total_sec = int(week_rows.total_sec or 0)
week_billable_sec = int(week_rows.billable_sec or 0)
week_hours = round(week_total_sec / 3600.0, 2)
standard_hours = float(getattr(user, "standard_hours_per_day", 8.0) or 8.0)
# Resolve weekly goal: prefer WeeklyTimeGoal for current week, else
# fall back to standard_hours_per_day * 5, defaulting to 40.
week_goal_hours = round(standard_hours * 5, 2) if standard_hours else 40.0
try:
from app.models import WeeklyTimeGoal
wgoal = WeeklyTimeGoal.get_current_week_goal(uid)
if wgoal and getattr(wgoal, "target_hours", None):
week_goal_hours = float(wgoal.target_hours)
except Exception:
# Table missing or any other issue — use fallback.
pass
if not week_goal_hours or week_goal_hours <= 0:
week_goal_hours = 40.0
week_goal_percent = int(min(100, max(0, round(week_hours / week_goal_hours * 100))))
billable_percent_week = (
int(round(week_billable_sec / week_total_sec * 100)) if week_total_sec > 0 else 0
)
top_project_week = cls._top_project_for_period(uid, week_start_dt, week_end_dt)
try:
active = TimeEntry.get_user_active_timer(uid)
except Exception:
active = None
return {
"today_hours": today_hours,
"week_hours": week_hours,
"week_goal_hours": round(week_goal_hours, 2),
"week_goal_percent": week_goal_percent,
"active_timer": _format_active_timer(active),
"billable_percent_week": billable_percent_week,
"top_project_week": top_project_week,
}
except Exception:
logger.exception("ProductivityService.get_summary failed for user %s", uid)
return empty
@staticmethod
def _top_project_for_period(user_id: int, start_dt, end_dt) -> Optional[Dict[str, Any]]:
try:
row = (
db.session.query(
Project.id,
Project.name,
func.coalesce(func.sum(TimeEntry.duration_seconds), 0).label("sec"),
)
.join(Project, Project.id == TimeEntry.project_id)
.filter(
TimeEntry.user_id == user_id,
TimeEntry.end_time.isnot(None),
TimeEntry.start_time >= start_dt,
TimeEntry.start_time < end_dt,
)
.group_by(Project.id, Project.name)
.order_by(func.sum(TimeEntry.duration_seconds).desc())
.first()
)
if not row or not row.sec:
return None
return {"name": row.name, "hours": round(int(row.sec) / 3600.0, 2)}
except Exception:
return None
# --------------------------------------------------------- daily breakdown
@classmethod
def get_daily_breakdown(cls, user, days: int = 14) -> List[Dict[str, Any]]:
"""One dict per calendar day for the last N days (oldest first)."""
try:
days = max(1, min(int(days), 90))
except (TypeError, ValueError):
days = 14
uid = _safe_user_id(user)
if uid is None:
return []
try:
today = _user_today(user)
start_day = today - timedelta(days=days - 1)
start_dt, end_dt = _user_period_bounds_app_naive(user, start_day, today)
user_tz = get_timezone_for_user(user)
app_tz = get_timezone_obj()
rows = (
db.session.query(
TimeEntry.start_time,
TimeEntry.duration_seconds,
TimeEntry.billable,
)
.filter(
TimeEntry.user_id == uid,
TimeEntry.end_time.isnot(None),
TimeEntry.start_time >= start_dt,
TimeEntry.start_time < end_dt,
)
.all()
)
by_day_seconds: Dict[date, int] = defaultdict(int)
by_day_billable: Dict[date, int] = defaultdict(int)
by_day_count: Dict[date, int] = defaultdict(int)
for start_time, duration_seconds, billable in rows:
local_dt = _to_user_local(start_time, user_tz, app_tz)
if local_dt is None:
continue
d = local_dt.date()
sec = int(duration_seconds or 0)
by_day_seconds[d] += sec
if billable:
by_day_billable[d] += sec
by_day_count[d] += 1
standard_hours = float(getattr(user, "standard_hours_per_day", 8.0) or 8.0)
out: List[Dict[str, Any]] = []
cur = start_day
while cur <= today:
sec = by_day_seconds.get(cur, 0)
bsec = by_day_billable.get(cur, 0)
out.append(
{
"date": cur.isoformat(),
"hours": round(sec / 3600.0, 2),
"billable_hours": round(bsec / 3600.0, 2),
"entry_count": int(by_day_count.get(cur, 0)),
"goal_hours": round(standard_hours, 2),
}
)
cur += timedelta(days=1)
return out
except Exception:
logger.exception("ProductivityService.get_daily_breakdown failed for user %s", uid)
return []
# ---------------------------------------------------------------- streak
@classmethod
def get_streak(cls, user) -> Dict[str, Any]:
empty = {
"current_streak": 0,
"longest_streak": 0,
"tracked_days_this_month": 0,
"total_days_this_month": 1,
}
uid = _safe_user_id(user)
if uid is None:
return empty
try:
today = _user_today(user)
user_tz = get_timezone_for_user(user)
app_tz = get_timezone_obj()
# Pull all completed entry start_times for the user. For most users
# this is bounded; for very heavy users we cap to ~3 years to stay safe.
cutoff_day = today - timedelta(days=365 * 3)
cutoff_dt, _ = _user_day_bounds_app_naive(user, cutoff_day)
rows = (
db.session.query(TimeEntry.start_time)
.filter(
TimeEntry.user_id == uid,
TimeEntry.end_time.isnot(None),
TimeEntry.start_time >= cutoff_dt,
)
.all()
)
tracked_days: set = set()
for (start_time,) in rows:
local_dt = _to_user_local(start_time, user_tz, app_tz)
if local_dt is not None:
tracked_days.add(local_dt.date())
# Current streak: consecutive days back from today; if today has 0 hours,
# start checking from yesterday.
current_streak = 0
anchor = today if today in tracked_days else today - timedelta(days=1)
cursor = anchor
while cursor in tracked_days:
current_streak += 1
cursor -= timedelta(days=1)
# Longest streak across all tracked days
longest_streak = 0
if tracked_days:
ordered = sorted(tracked_days)
run = 1
longest_streak = 1
for i in range(1, len(ordered)):
if (ordered[i] - ordered[i - 1]).days == 1:
run += 1
longest_streak = max(longest_streak, run)
else:
run = 1
month_start = today.replace(day=1)
tracked_this_month = sum(1 for d in tracked_days if month_start <= d <= today)
total_days_this_month = (today - month_start).days + 1
return {
"current_streak": int(current_streak),
"longest_streak": int(longest_streak),
"tracked_days_this_month": int(tracked_this_month),
"total_days_this_month": int(max(1, total_days_this_month)),
}
except Exception:
logger.exception("ProductivityService.get_streak failed for user %s", uid)
return empty
# ---------------------------------------------------------------- focus
@classmethod
def get_focus_stats(cls, user, days: int = 30) -> Dict[str, Any]:
empty = {
"avg_session_length_minutes": 0.0,
"longest_session_hours": 0.0,
"entries_per_day": 0.0,
"most_productive_hour": None,
"most_productive_day": None,
}
try:
days = max(1, min(int(days), 90))
except (TypeError, ValueError):
days = 30
uid = _safe_user_id(user)
if uid is None:
return empty
try:
today = _user_today(user)
start_day = today - timedelta(days=days - 1)
start_dt, end_dt = _user_period_bounds_app_naive(user, start_day, today)
user_tz = get_timezone_for_user(user)
app_tz = get_timezone_obj()
rows = (
db.session.query(TimeEntry.start_time, TimeEntry.duration_seconds)
.filter(
TimeEntry.user_id == uid,
TimeEntry.end_time.isnot(None),
TimeEntry.start_time >= start_dt,
TimeEntry.start_time < end_dt,
)
.all()
)
qualifying_durations: List[int] = []
longest_seconds = 0
tracked_days: set = set()
hour_seconds: Dict[int, int] = defaultdict(int)
dow_seconds: Dict[int, int] = defaultdict(int)
for start_time, duration_seconds in rows:
sec = int(duration_seconds or 0)
local_dt = _to_user_local(start_time, user_tz, app_tz)
if local_dt is None:
continue
tracked_days.add(local_dt.date())
hour_seconds[local_dt.hour] += sec
# python weekday(): Monday=0..Sunday=6 — matches _DOW_NAMES order.
dow_seconds[local_dt.weekday()] += sec
if sec >= 300: # exclude entries shorter than 5 minutes
qualifying_durations.append(sec)
if sec > longest_seconds:
longest_seconds = sec
if qualifying_durations:
avg_minutes = sum(qualifying_durations) / len(qualifying_durations) / 60.0
else:
avg_minutes = 0.0
entries_per_day = (
round(len(rows) / len(tracked_days), 2) if tracked_days else 0.0
)
most_productive_hour: Optional[int] = None
if hour_seconds:
most_productive_hour = int(
max(hour_seconds.items(), key=lambda kv: kv[1])[0]
)
most_productive_day: Optional[str] = None
if dow_seconds:
best_dow = max(dow_seconds.items(), key=lambda kv: kv[1])[0]
if 0 <= best_dow <= 6:
most_productive_day = _DOW_NAMES[best_dow]
# Build a 24-bar sparkline of hours per hour of day for the period.
hour_distribution = [
round(hour_seconds.get(h, 0) / 3600.0, 2) for h in range(24)
]
return {
"avg_session_length_minutes": round(avg_minutes, 1),
"longest_session_hours": round(longest_seconds / 3600.0, 2),
"entries_per_day": float(entries_per_day),
"most_productive_hour": most_productive_hour,
"most_productive_day": most_productive_day,
"hour_distribution": hour_distribution,
}
except Exception:
logger.exception("ProductivityService.get_focus_stats failed for user %s", uid)
return empty
# ----------------------------------------------------- project breakdown
@classmethod
def get_project_breakdown(cls, user, days: int = 30) -> List[Dict[str, Any]]:
try:
days = max(1, min(int(days), 90))
except (TypeError, ValueError):
days = 30
uid = _safe_user_id(user)
if uid is None:
return []
try:
today = _user_today(user)
start_day = today - timedelta(days=days - 1)
start_dt, end_dt = _user_period_bounds_app_naive(user, start_day, today)
rows = (
db.session.query(
Project.id,
Project.name,
func.coalesce(func.sum(TimeEntry.duration_seconds), 0).label("total_sec"),
func.coalesce(
func.sum(
db.case(
(TimeEntry.billable == True, TimeEntry.duration_seconds), # noqa: E712
else_=0,
)
),
0,
).label("billable_sec"),
func.count(TimeEntry.id).label("entry_count"),
)
.join(Project, Project.id == TimeEntry.project_id)
.filter(
TimeEntry.user_id == uid,
TimeEntry.end_time.isnot(None),
TimeEntry.start_time >= start_dt,
TimeEntry.start_time < end_dt,
)
.group_by(Project.id, Project.name)
.order_by(func.sum(TimeEntry.duration_seconds).desc())
.limit(8)
.all()
)
out: List[Dict[str, Any]] = []
for row in rows:
total_sec = int(row.total_sec or 0)
if total_sec <= 0:
continue
billable_sec = int(row.billable_sec or 0)
pid = int(row.id)
out.append(
{
"project_id": pid,
"name": row.name,
"hours": round(total_sec / 3600.0, 2),
"billable_hours": round(billable_sec / 3600.0, 2),
"entry_count": int(row.entry_count or 0),
"color": _PROJECT_PALETTE[pid % len(_PROJECT_PALETTE)],
}
)
return out
except Exception:
logger.exception(
"ProductivityService.get_project_breakdown failed for user %s", uid
)
return []
# -------------------------------------------------------- weekly heatmap
@classmethod
def get_weekly_heatmap(cls, user, weeks: int = 12) -> List[Dict[str, Any]]:
try:
weeks = max(1, min(int(weeks), 26))
except (TypeError, ValueError):
weeks = 12
uid = _safe_user_id(user)
if uid is None:
return []
days = weeks * 7
try:
today = _user_today(user)
start_day = today - timedelta(days=days - 1)
start_dt, end_dt = _user_period_bounds_app_naive(user, start_day, today)
user_tz = get_timezone_for_user(user)
app_tz = get_timezone_obj()
rows = (
db.session.query(TimeEntry.start_time, TimeEntry.duration_seconds)
.filter(
TimeEntry.user_id == uid,
TimeEntry.end_time.isnot(None),
TimeEntry.start_time >= start_dt,
TimeEntry.start_time < end_dt,
)
.all()
)
by_day: Dict[date, int] = defaultdict(int)
for start_time, duration_seconds in rows:
local_dt = _to_user_local(start_time, user_tz, app_tz)
if local_dt is None:
continue
by_day[local_dt.date()] += int(duration_seconds or 0)
out: List[Dict[str, Any]] = []
cur = start_day
while cur <= today:
hours = round(by_day.get(cur, 0) / 3600.0, 2)
if hours <= 0:
level = 0
elif hours < 2:
level = 1
elif hours < 4:
level = 2
elif hours < 6:
level = 3
else:
level = 4
out.append({"date": cur.isoformat(), "hours": hours, "level": level})
cur += timedelta(days=1)
return out
except Exception:
logger.exception(
"ProductivityService.get_weekly_heatmap failed for user %s", uid
)
return []
# ---------------------------------------------------------------- insights
@classmethod
def get_insights(
cls,
user,
summary: Dict[str, Any],
daily_breakdown: List[Dict[str, Any]],
streak: Dict[str, Any],
focus: Dict[str, Any],
projects: List[Dict[str, Any]],
) -> List[str]:
"""Build up to 4 plain-text insights from already-computed data."""
insights: List[str] = []
try:
# 1. Week-over-week comparison.
try:
this_week_hours = float(summary.get("week_hours") or 0.0)
# daily_breakdown is oldest first; last 7 entries are this week (Mon..today),
# the 7 entries before that are "last week" for comparison.
if len(daily_breakdown) >= 14:
last_week_hours = sum(
float(d.get("hours") or 0) for d in daily_breakdown[-14:-7]
)
else:
last_week_hours = 0.0
if last_week_hours > 0 and this_week_hours != last_week_hours:
diff = this_week_hours - last_week_hours
pct = abs(int(round(diff / last_week_hours * 100)))
if pct >= 1:
direction = "more" if diff > 0 else "less"
insights.append(
f"You logged {pct}% {direction} this week than last week"
)
except Exception:
pass
# 2. Streak insight.
try:
cur_streak = int(streak.get("current_streak") or 0)
if cur_streak >= 3:
insights.append(
f"You've tracked time {cur_streak} days in a row — keep it up!"
)
except Exception:
pass
# 3. Peak hour insight.
try:
hour = focus.get("most_productive_hour")
if hour is not None and 0 <= int(hour) <= 23:
h = int(hour)
next_h = (h + 1) % 24
insights.append(
f"Your most productive time is {h:02d}:00{next_h:02d}:00"
)
except Exception:
pass
# 4. Top project insight.
try:
if projects:
total = sum(float(p.get("hours") or 0) for p in projects)
top = projects[0]
if total > 0 and top.get("hours"):
pct = int(round(float(top["hours"]) / total * 100))
if pct > 0:
insights.append(
f"{top.get('name', 'Top project')} accounted for "
f"{pct}% of your tracked time"
)
except Exception:
pass
# 5. Billable rate.
try:
billable = int(summary.get("billable_percent_week") or 0)
if billable > 0:
insights.append(
f"Your billable rate this week is {billable}%"
)
except Exception:
pass
except Exception:
logger.exception("ProductivityService.get_insights failed")
return insights[:4]
@@ -0,0 +1,821 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header %}
{% block title %}{{ _('My productivity') }} · {{ app_name }}{% endblock %}
{% block content %}
{% set productivity_breadcrumbs = [{'text': _('Dashboard'), 'url': url_for('main.dashboard')}, {'text': _('My productivity')}] %}
{{ page_header('fas fa-chart-line', _('My productivity'), _('Your personal time tracking patterns, focus, and streaks.'), breadcrumbs=productivity_breadcrumbs) }}
<div id="productivityRoot"
class="space-y-6"
data-period="{{ period_days }}"
data-standard-hours="{{ standard_hours_per_day }}"
data-stats-url="{{ url_for('api.productivity_stats') }}">
<div class="flex items-center justify-end gap-2">
<span id="productivityRefreshStatus" class="text-xs text-text-muted-light dark:text-text-muted-dark" aria-live="polite"></span>
<button type="button" id="productivityRefreshBtn"
class="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg border border-border-light dark:border-border-dark bg-card-light dark:bg-card-dark text-sm text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark transition-colors">
<i class="fas fa-arrows-rotate" aria-hidden="true"></i>
<span>{{ _('Refresh') }}</span>
</button>
</div>
{# ---------- Row 1 — Summary cards ---------- #}
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{# Today's hours #}
<div class="bg-card-light dark:bg-card-dark p-5 rounded-xl border border-border-light dark:border-border-dark shadow-sm">
<div class="flex items-center justify-between mb-3">
<h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark uppercase tracking-wide">{{ _("Today's hours") }}</h3>
<i class="fas fa-clock text-blue-500" aria-hidden="true"></i>
</div>
<div class="flex items-center gap-4">
<div class="relative w-16 h-16 shrink-0">
<svg viewBox="0 0 36 36" class="w-16 h-16 -rotate-90">
<circle cx="18" cy="18" r="15.9155" fill="none"
class="stroke-gray-200 dark:stroke-gray-700" stroke-width="3"></circle>
<circle id="productivityTodayArc" cx="18" cy="18" r="15.9155" fill="none"
class="stroke-blue-500"
stroke-width="3"
stroke-linecap="round"
stroke-dasharray="100"
stroke-dashoffset="100"></circle>
</svg>
</div>
<div class="min-w-0">
<p class="text-3xl font-bold text-text-light dark:text-text-dark" id="productivityTodayHours">{{ '%.1f'|format(summary.today_hours or 0) }}</p>
<p class="text-xs text-text-muted-light dark:text-text-muted-dark">{{ _('today') }}</p>
</div>
</div>
<div id="productivityActiveTimerBadge" class="mt-3 inline-flex items-center gap-2 text-xs font-medium text-blue-600 dark:text-blue-400 {% if not summary.active_timer %}hidden{% endif %}">
<span class="w-2 h-2 bg-blue-500 rounded-full animate-pulse"></span>
<span>{{ _('Timer running') }}</span>
</div>
</div>
{# This week #}
<div class="bg-card-light dark:bg-card-dark p-5 rounded-xl border border-border-light dark:border-border-dark shadow-sm">
<div class="flex items-center justify-between mb-3">
<h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark uppercase tracking-wide">{{ _('This week') }}</h3>
<i class="fas fa-calendar-week text-emerald-500" aria-hidden="true"></i>
</div>
<p class="text-3xl font-bold text-text-light dark:text-text-dark">
<span id="productivityWeekHours">{{ '%.1f'|format(summary.week_hours or 0) }}</span>
<span class="text-base font-medium text-text-muted-light dark:text-text-muted-dark"> / <span id="productivityWeekGoal">{{ '%.0f'|format(summary.week_goal_hours or 0) }}</span> h</span>
</p>
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mb-3">{{ _('this week') }}</p>
<div class="w-full h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden flex">
<div id="productivityWeekBillableBar" class="h-full bg-emerald-500" style="width: 0%;"></div>
<div id="productivityWeekRemainderBar" class="h-full bg-emerald-300 dark:bg-emerald-700" style="width: 0%;"></div>
</div>
<p class="mt-2 text-xs text-text-muted-light dark:text-text-muted-dark">
<span id="productivityBillablePct">{{ summary.billable_percent_week or 0 }}</span>% {{ _('billable') }}
</p>
</div>
{# Streak #}
<div class="bg-card-light dark:bg-card-dark p-5 rounded-xl border border-border-light dark:border-border-dark shadow-sm">
<div class="flex items-center justify-between mb-3">
<h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark uppercase tracking-wide">{{ _('Streak') }}</h3>
<i id="productivityStreakIcon" class="fas {% if streak.current_streak >= 7 %}fa-fire text-orange-500{% else %}fa-calendar-check text-amber-500{% endif %}" aria-hidden="true"></i>
</div>
<p class="text-3xl font-bold text-text-light dark:text-text-dark"><span id="productivityCurrentStreak">{{ streak.current_streak or 0 }}</span></p>
<p class="text-xs text-text-muted-light dark:text-text-muted-dark">{{ _('day streak') }}</p>
<p class="mt-3 text-xs text-text-muted-light dark:text-text-muted-dark">{{ _('Best') }}: <span id="productivityLongestStreak">{{ streak.longest_streak or 0 }}</span> {{ _('days') }}</p>
</div>
{# Avg session #}
<div class="bg-card-light dark:bg-card-dark p-5 rounded-xl border border-border-light dark:border-border-dark shadow-sm">
<div class="flex items-center justify-between mb-3">
<h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark uppercase tracking-wide">{{ _('Avg session') }}</h3>
<i class="fas fa-stopwatch text-purple-500" aria-hidden="true"></i>
</div>
<p class="text-3xl font-bold text-text-light dark:text-text-dark" id="productivityAvgSession"></p>
<p class="text-xs text-text-muted-light dark:text-text-muted-dark">{{ _('per session (last 30 days)') }}</p>
<p class="mt-3 text-xs text-text-muted-light dark:text-text-muted-dark">
{{ _('Most productive') }}: <span id="productivityMostProductiveDay">{{ focus.most_productive_day or '—' }}</span>
</p>
</div>
</div>
{# ---------- Row 2 — Daily hours bar chart ---------- #}
<div class="bg-card-light dark:bg-card-dark p-5 rounded-xl border border-border-light dark:border-border-dark shadow-sm">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 mb-4">
<div>
<h2 class="text-lg font-semibold text-text-light dark:text-text-dark">{{ _('Daily hours') }}</h2>
<p class="text-xs text-text-muted-light dark:text-text-muted-dark">{{ _('Last 14 days, with daily goal reference') }}</p>
</div>
</div>
<div style="height: 220px;">
<canvas id="productivityDailyChart" aria-label="{{ _('Daily hours chart') }}"></canvas>
</div>
<p id="productivityDailyEmpty" class="hidden text-center text-sm text-text-muted-light dark:text-text-muted-dark mt-3">{{ _('No time tracked in the last 14 days.') }}</p>
</div>
{# ---------- Row 3 — projects + focus ---------- #}
<div class="grid grid-cols-1 lg:grid-cols-5 gap-4">
{# Project breakdown doughnut (60%) #}
<div class="lg:col-span-3 bg-card-light dark:bg-card-dark p-5 rounded-xl border border-border-light dark:border-border-dark shadow-sm">
<div class="flex items-center justify-between mb-4">
<div>
<h2 class="text-lg font-semibold text-text-light dark:text-text-dark">{{ _('Project breakdown') }}</h2>
<p class="text-xs text-text-muted-light dark:text-text-muted-dark">{{ _('Top projects (last 30 days)') }}</p>
</div>
</div>
<div class="relative" style="height: 240px;">
<canvas id="productivityProjectChart" aria-label="{{ _('Project breakdown chart') }}"></canvas>
<div id="productivityProjectCenter" class="pointer-events-none absolute inset-0 flex flex-col items-center justify-center text-center">
<span class="text-xs text-text-muted-light dark:text-text-muted-dark">{{ _('Total') }}</span>
<span class="text-2xl font-bold text-text-light dark:text-text-dark" id="productivityProjectTotal">0h</span>
</div>
</div>
<ul id="productivityProjectLegend" class="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-2 text-sm"></ul>
<p id="productivityProjectEmpty" class="hidden text-center text-sm text-text-muted-light dark:text-text-muted-dark mt-4">{{ _('No entries in the last 30 days') }}</p>
</div>
{# Focus stats (40%) #}
<div class="lg:col-span-2 bg-card-light dark:bg-card-dark p-5 rounded-xl border border-border-light dark:border-border-dark shadow-sm">
<div class="mb-4">
<h2 class="text-lg font-semibold text-text-light dark:text-text-dark">{{ _('Focus') }}</h2>
<p class="text-xs text-text-muted-light dark:text-text-muted-dark">{{ _('How and when you work best (last 30 days)') }}</p>
</div>
<dl class="space-y-3 text-sm">
<div>
<dt class="text-text-muted-light dark:text-text-muted-dark">{{ _('Most productive hour') }}</dt>
<dd class="font-semibold text-text-light dark:text-text-dark" id="productivityPeakHour"></dd>
<div class="mt-2" style="height: 60px;">
<canvas id="productivityHourSparkline" aria-hidden="true"></canvas>
</div>
</div>
<div class="flex justify-between border-t border-border-light dark:border-border-dark pt-3">
<dt class="text-text-muted-light dark:text-text-muted-dark">{{ _('Most productive day') }}</dt>
<dd class="font-semibold text-text-light dark:text-text-dark" id="productivityFocusDay">{{ focus.most_productive_day or '—' }}</dd>
</div>
<div class="flex justify-between border-t border-border-light dark:border-border-dark pt-3">
<dt class="text-text-muted-light dark:text-text-muted-dark">{{ _('Longest session') }}</dt>
<dd class="font-semibold text-text-light dark:text-text-dark" id="productivityLongestSession"></dd>
</div>
<div class="flex justify-between border-t border-border-light dark:border-border-dark pt-3">
<dt class="text-text-muted-light dark:text-text-muted-dark">{{ _('Entries per active day') }}</dt>
<dd class="font-semibold text-text-light dark:text-text-dark" id="productivityEntriesPerDay"></dd>
</div>
<div class="flex justify-between border-t border-border-light dark:border-border-dark pt-3">
<dt class="text-text-muted-light dark:text-text-muted-dark">{{ _('Tracked days this month') }}</dt>
<dd class="font-semibold text-text-light dark:text-text-dark"><span id="productivityTrackedDaysMonth">{{ streak.tracked_days_this_month or 0 }}</span> / <span id="productivityTotalDaysMonth">{{ streak.total_days_this_month or 1 }}</span></dd>
</div>
</dl>
</div>
</div>
{# ---------- Row 4 — Activity heatmap ---------- #}
<div class="bg-card-light dark:bg-card-dark p-5 rounded-xl border border-border-light dark:border-border-dark shadow-sm">
<div class="flex items-center justify-between mb-4">
<div>
<h2 class="text-lg font-semibold text-text-light dark:text-text-dark">{{ _('Activity heatmap') }}</h2>
<p class="text-xs text-text-muted-light dark:text-text-muted-dark">{{ _('Last 12 weeks of tracked time') }}</p>
</div>
</div>
<div class="overflow-x-auto">
<div id="productivityHeatmap" class="inline-block min-w-full"></div>
</div>
<div class="mt-3 flex items-center justify-end gap-2 text-xs text-text-muted-light dark:text-text-muted-dark">
<span>{{ _('Less') }}</span>
<span class="w-3 h-3 rounded-sm bg-gray-100 dark:bg-gray-800"></span>
<span class="w-3 h-3 rounded-sm bg-blue-200 dark:bg-blue-900"></span>
<span class="w-3 h-3 rounded-sm bg-blue-400 dark:bg-blue-700"></span>
<span class="w-3 h-3 rounded-sm bg-blue-600 dark:bg-blue-500"></span>
<span class="w-3 h-3 rounded-sm bg-blue-800 dark:bg-blue-300"></span>
<span>{{ _('More') }}</span>
</div>
</div>
{# ---------- Row 5 — Insights ---------- #}
<div class="bg-amber-50 dark:bg-amber-900/10 border border-amber-200 dark:border-amber-800/40 p-5 rounded-xl shadow-sm">
<div class="flex items-start gap-3">
<div class="shrink-0 w-10 h-10 rounded-lg bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center">
<i class="fas fa-lightbulb text-amber-500" aria-hidden="true"></i>
</div>
<div class="flex-1 min-w-0">
<h2 class="text-lg font-semibold text-text-light dark:text-text-dark mb-2">{{ _('Insights') }}</h2>
<ul id="productivityInsights" class="space-y-1.5 text-sm text-text-light dark:text-text-dark">
{% if insights %}
{% for line in insights %}
<li class="flex items-start gap-2">
<i class="fas fa-circle text-[6px] mt-2 text-amber-500" aria-hidden="true"></i>
<span>{{ line }}</span>
</li>
{% endfor %}
{% else %}
<li class="text-text-muted-light dark:text-text-muted-dark">{{ _('Start tracking time to unlock personal insights.') }}</li>
{% endif %}
</ul>
</div>
</div>
</div>
</div>
{# Server-rendered initial dataset for charts #}
<script id="productivityInitialData" type="application/json">
{{ {
'summary': summary,
'daily_breakdown': daily_breakdown,
'streak': streak,
'focus': focus,
'projects': projects,
'heatmap': heatmap,
'insights': insights,
'period': period_days
} | tojson }}
</script>
{% endblock %}
{% block scripts_extra %}
<script>
(function () {
'use strict';
var root = document.getElementById('productivityRoot');
if (!root) { return; }
var STATS_URL = root.getAttribute('data-stats-url');
var STANDARD_HOURS = parseFloat(root.getAttribute('data-standard-hours') || '8') || 8;
var DEFAULT_PERIOD = parseInt(root.getAttribute('data-period') || '30', 10) || 30;
var initialDataEl = document.getElementById('productivityInitialData');
var state = {
data: null,
dailyChart: null,
projectChart: null,
sparkChart: null,
refreshTimer: null,
period: DEFAULT_PERIOD,
};
try {
state.data = initialDataEl ? JSON.parse(initialDataEl.textContent || '{}') : {};
} catch (e) {
state.data = {};
}
function isDark() { return document.documentElement.classList.contains('dark'); }
function gridColor() { return isDark() ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.06)'; }
function tickColor() { return isDark() ? '#cbd5e1' : '#475569'; }
function fmtHours(h) {
h = Number(h) || 0;
if (h >= 100) { return Math.round(h) + 'h'; }
if (h >= 10) { return h.toFixed(1) + 'h'; }
return h.toFixed(2).replace(/\.?0+$/, '') + 'h';
}
function fmtMinutes(mins) {
mins = Math.max(0, Math.round(Number(mins) || 0));
if (mins < 60) { return mins + 'm'; }
var h = Math.floor(mins / 60);
var m = mins % 60;
return m === 0 ? h + 'h' : (h + 'h ' + m + 'm');
}
function fmtDateLabel(iso) {
try {
var d = new Date(iso + 'T00:00:00');
return d.toLocaleDateString(undefined, { weekday: 'short', day: 'numeric' });
} catch (e) {
return iso;
}
}
function fmtFullDate(iso) {
try {
var d = new Date(iso + 'T00:00:00');
return d.toLocaleDateString(undefined, { weekday: 'long', month: 'short', day: 'numeric' });
} catch (e) {
return iso;
}
}
// --------------- Chart 1: Daily bars ---------------
function renderDailyChart() {
var days = (state.data && state.data.daily_breakdown) || [];
var ctxEl = document.getElementById('productivityDailyChart');
var emptyEl = document.getElementById('productivityDailyEmpty');
if (!ctxEl) { return; }
var labels = days.map(function (d) { return fmtDateLabel(d.date); });
var hours = days.map(function (d) { return Number(d.hours) || 0; });
var billable = days.map(function (d) { return Number(d.billable_hours) || 0; });
var counts = days.map(function (d) { return Number(d.entry_count) || 0; });
var goal = days.map(function (d) { return Number(d.goal_hours) || STANDARD_HOURS; });
var dates = days.map(function (d) { return d.date; });
var hasData = hours.some(function (h) { return h > 0; });
if (emptyEl) { emptyEl.classList.toggle('hidden', hasData); }
if (state.dailyChart) {
state.dailyChart.data.labels = labels;
state.dailyChart.data.datasets[0].data = hours;
state.dailyChart.data.datasets[1].data = goal;
state.dailyChart._tooltipMeta = { dates: dates, billable: billable, counts: counts };
state.dailyChart.update();
return;
}
var ctx = ctxEl.getContext('2d');
state.dailyChart = new Chart(ctx, {
type: 'bar',
data: {
labels: labels,
datasets: [
{
type: 'bar',
label: 'Hours',
data: hours,
backgroundColor: '#3b82f6',
borderRadius: 6,
borderSkipped: false,
},
{
type: 'line',
label: 'Goal',
data: goal,
borderColor: '#ef4444',
borderDash: [6, 4],
borderWidth: 2,
pointRadius: 0,
fill: false,
tension: 0,
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
title: function (items) {
if (!items.length) { return ''; }
var meta = state.dailyChart && state.dailyChart._tooltipMeta;
var iso = meta ? meta.dates[items[0].dataIndex] : '';
return iso ? fmtFullDate(iso) : items[0].label;
},
label: function (item) {
var meta = state.dailyChart && state.dailyChart._tooltipMeta;
if (item.datasetIndex === 1) {
return 'Goal: ' + fmtHours(item.parsed.y);
}
var idx = item.dataIndex;
var bill = meta ? meta.billable[idx] : 0;
var count = meta ? meta.counts[idx] : 0;
return fmtHours(item.parsed.y) +
' (' + fmtHours(bill) + ' billable, ' +
count + ' entries)';
},
},
},
},
scales: {
x: {
grid: { color: gridColor() },
ticks: { color: tickColor() },
},
y: {
beginAtZero: true,
grid: { color: gridColor() },
ticks: { color: tickColor() },
},
},
},
});
state.dailyChart._tooltipMeta = { dates: dates, billable: billable, counts: counts };
}
// --------------- Chart 2: Project doughnut ---------------
function renderProjectChart() {
var projects = (state.data && state.data.projects) || [];
var ctxEl = document.getElementById('productivityProjectChart');
var legendEl = document.getElementById('productivityProjectLegend');
var emptyEl = document.getElementById('productivityProjectEmpty');
var totalEl = document.getElementById('productivityProjectTotal');
var centerEl = document.getElementById('productivityProjectCenter');
if (!ctxEl) { return; }
var hasData = projects.length > 0;
if (emptyEl) { emptyEl.classList.toggle('hidden', hasData); }
if (legendEl) { legendEl.innerHTML = ''; }
if (centerEl) { centerEl.classList.toggle('hidden', !hasData); }
var totalHours = projects.reduce(function (acc, p) { return acc + (Number(p.hours) || 0); }, 0);
if (totalEl) { totalEl.textContent = fmtHours(totalHours); }
var labels = projects.map(function (p) { return p.name; });
var data = projects.map(function (p) { return Number(p.hours) || 0; });
var colors = projects.map(function (p) { return p.color; });
if (legendEl) {
projects.forEach(function (p) {
var li = document.createElement('li');
li.className = 'flex items-center gap-2 text-text-light dark:text-text-dark';
var sw = document.createElement('span');
sw.className = 'inline-block w-3 h-3 rounded-sm shrink-0';
sw.style.backgroundColor = p.color;
var label = document.createElement('span');
label.className = 'truncate flex-1 min-w-0';
label.textContent = p.name;
var hrs = document.createElement('span');
hrs.className = 'text-text-muted-light dark:text-text-muted-dark text-xs shrink-0';
hrs.textContent = fmtHours(p.hours);
li.appendChild(sw);
li.appendChild(label);
li.appendChild(hrs);
legendEl.appendChild(li);
});
}
if (state.projectChart) {
state.projectChart.data.labels = labels;
state.projectChart.data.datasets[0].data = data;
state.projectChart.data.datasets[0].backgroundColor = colors;
state.projectChart.update();
return;
}
if (!hasData) { return; }
var ctx = ctxEl.getContext('2d');
state.projectChart = new Chart(ctx, {
type: 'doughnut',
data: {
labels: labels,
datasets: [{
data: data,
backgroundColor: colors,
borderWidth: 0,
hoverOffset: 6,
}],
},
options: {
responsive: true,
maintainAspectRatio: false,
cutout: '65%',
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: function (item) {
return item.label + ': ' + fmtHours(item.parsed);
},
},
},
},
},
});
}
// --------------- Chart 3: Hour sparkline ---------------
function renderSparkline() {
var ctxEl = document.getElementById('productivityHourSparkline');
if (!ctxEl) { return; }
var hours = (state.data && state.data.focus && state.data.focus.hour_distribution) || [];
if (!hours.length) {
hours = new Array(24).fill(0);
}
if (state.sparkChart) {
state.sparkChart.data.datasets[0].data = hours;
state.sparkChart.update();
return;
}
var ctx = ctxEl.getContext('2d');
state.sparkChart = new Chart(ctx, {
type: 'bar',
data: {
labels: hours.map(function (_, i) { return i; }),
datasets: [{
data: hours,
backgroundColor: '#8b5cf6',
borderRadius: 2,
barPercentage: 0.85,
categoryPercentage: 0.95,
}],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: { enabled: false },
},
scales: {
x: { display: false, grid: { display: false } },
y: { display: false, grid: { display: false }, beginAtZero: true },
},
},
});
}
// --------------- Heatmap ---------------
function renderHeatmap() {
var heatmap = (state.data && state.data.heatmap) || [];
var container = document.getElementById('productivityHeatmap');
if (!container) { return; }
container.innerHTML = '';
if (!heatmap.length) {
var empty = document.createElement('p');
empty.className = 'text-sm text-text-muted-light dark:text-text-muted-dark text-center py-4';
empty.textContent = '{{ _('No activity yet.') }}';
container.appendChild(empty);
return;
}
// Build columns of 7 days each. Heatmap is oldest first; first column starts on
// the day-of-week of the first entry, the rest are full Mon-Sun columns.
var cellClasses = [
'bg-gray-100 dark:bg-gray-800',
'bg-blue-200 dark:bg-blue-900',
'bg-blue-400 dark:bg-blue-700',
'bg-blue-600 dark:bg-blue-500',
'bg-blue-800 dark:bg-blue-300',
];
// Pad the start to align Monday-first columns: Python weekday Mon=0..Sun=6.
var firstDate = new Date(heatmap[0].date + 'T00:00:00');
var firstDow = (firstDate.getDay() + 6) % 7; // shift Sun=0 → Mon=0
var padded = [];
for (var p = 0; p < firstDow; p++) {
padded.push(null);
}
for (var i = 0; i < heatmap.length; i++) {
padded.push(heatmap[i]);
}
var columns = [];
for (var c = 0; c < padded.length; c += 7) {
columns.push(padded.slice(c, c + 7));
}
// Outer wrapper with month labels above
var wrap = document.createElement('div');
wrap.className = 'flex flex-col gap-1';
// Month label row
var monthRow = document.createElement('div');
monthRow.className = 'flex gap-[3px] text-[10px] text-text-muted-light dark:text-text-muted-dark pl-7';
var lastMonth = -1;
for (var ci = 0; ci < columns.length; ci++) {
var col = columns[ci];
var firstReal = null;
for (var ri = 0; ri < col.length; ri++) {
if (col[ri]) { firstReal = col[ri]; break; }
}
var label = document.createElement('span');
label.className = 'inline-block';
label.style.width = '12px';
if (firstReal) {
var dt = new Date(firstReal.date + 'T00:00:00');
var m = dt.getMonth();
if (m !== lastMonth) {
label.textContent = dt.toLocaleDateString(undefined, { month: 'short' });
label.style.minWidth = '32px';
label.style.width = 'auto';
lastMonth = m;
}
}
monthRow.appendChild(label);
}
wrap.appendChild(monthRow);
// Body: day-of-week labels column + grid columns
var body = document.createElement('div');
body.className = 'flex gap-[3px]';
var dowCol = document.createElement('div');
dowCol.className = 'flex flex-col gap-[3px] mr-1 text-[10px] text-text-muted-light dark:text-text-muted-dark';
var dowLabels = ['{{ _('Mon') }}', '', '{{ _('Wed') }}', '', '{{ _('Fri') }}', '', ''];
for (var d = 0; d < 7; d++) {
var dl = document.createElement('span');
dl.style.height = '12px';
dl.style.lineHeight = '12px';
dl.textContent = dowLabels[d];
dowCol.appendChild(dl);
}
body.appendChild(dowCol);
for (var ci2 = 0; ci2 < columns.length; ci2++) {
var colWrap = document.createElement('div');
colWrap.className = 'flex flex-col gap-[3px]';
var col2 = columns[ci2];
for (var ri2 = 0; ri2 < 7; ri2++) {
var cell = document.createElement('span');
var entry = col2[ri2];
cell.className = 'block rounded-sm';
cell.style.width = '12px';
cell.style.height = '12px';
if (!entry) {
cell.classList.add('opacity-30');
cell.classList.add(cellClasses[0]);
} else {
var lvl = Math.max(0, Math.min(4, entry.level || 0));
cell.classList.add(cellClasses[lvl]);
cell.title = entry.date + ': ' + fmtHours(entry.hours);
}
colWrap.appendChild(cell);
}
body.appendChild(colWrap);
}
wrap.appendChild(body);
container.appendChild(wrap);
}
// --------------- Summary cards ---------------
function renderSummary() {
var s = (state.data && state.data.summary) || {};
var todayHoursEl = document.getElementById('productivityTodayHours');
var arc = document.getElementById('productivityTodayArc');
var weekHoursEl = document.getElementById('productivityWeekHours');
var weekGoalEl = document.getElementById('productivityWeekGoal');
var weekBillableBar = document.getElementById('productivityWeekBillableBar');
var weekRemainderBar = document.getElementById('productivityWeekRemainderBar');
var billablePctEl = document.getElementById('productivityBillablePct');
var streakEl = document.getElementById('productivityCurrentStreak');
var longestEl = document.getElementById('productivityLongestStreak');
var streakIcon = document.getElementById('productivityStreakIcon');
var avgEl = document.getElementById('productivityAvgSession');
var mostDayEl = document.getElementById('productivityMostProductiveDay');
var activeBadge = document.getElementById('productivityActiveTimerBadge');
var todayHours = Number(s.today_hours) || 0;
if (todayHoursEl) { todayHoursEl.textContent = todayHours.toFixed(1); }
if (arc) {
var ratio = STANDARD_HOURS > 0 ? Math.min(1, todayHours / STANDARD_HOURS) : 0;
arc.setAttribute('stroke-dashoffset', String(100 - Math.round(ratio * 100)));
}
if (weekHoursEl) { weekHoursEl.textContent = (Number(s.week_hours) || 0).toFixed(1); }
if (weekGoalEl) { weekGoalEl.textContent = String(Math.round(Number(s.week_goal_hours) || 0)); }
var goalPct = Math.max(0, Math.min(100, Number(s.week_goal_percent) || 0));
var billPct = Math.max(0, Math.min(100, Number(s.billable_percent_week) || 0));
var billableShare = Math.round(goalPct * (billPct / 100));
var remainderShare = Math.max(0, goalPct - billableShare);
if (weekBillableBar) { weekBillableBar.style.width = billableShare + '%'; }
if (weekRemainderBar) { weekRemainderBar.style.width = remainderShare + '%'; }
if (billablePctEl) { billablePctEl.textContent = String(billPct); }
var streak = (state.data && state.data.streak) || {};
if (streakEl) { streakEl.textContent = String(streak.current_streak || 0); }
if (longestEl) { longestEl.textContent = String(streak.longest_streak || 0); }
if (streakIcon) {
streakIcon.className = 'fas ' + ((streak.current_streak || 0) >= 7
? 'fa-fire text-orange-500'
: 'fa-calendar-check text-amber-500');
}
var trackedMonth = document.getElementById('productivityTrackedDaysMonth');
var totalMonth = document.getElementById('productivityTotalDaysMonth');
if (trackedMonth) { trackedMonth.textContent = String(streak.tracked_days_this_month || 0); }
if (totalMonth) { totalMonth.textContent = String(streak.total_days_this_month || 1); }
var focus = (state.data && state.data.focus) || {};
if (avgEl) { avgEl.textContent = fmtMinutes(focus.avg_session_length_minutes || 0); }
if (mostDayEl) { mostDayEl.textContent = focus.most_productive_day || '—'; }
var focusDayEl = document.getElementById('productivityFocusDay');
if (focusDayEl) { focusDayEl.textContent = focus.most_productive_day || '—'; }
var peakEl = document.getElementById('productivityPeakHour');
if (peakEl) {
if (focus.most_productive_hour === null || focus.most_productive_hour === undefined) {
peakEl.textContent = '—';
} else {
var h = parseInt(focus.most_productive_hour, 10) || 0;
var pad = function (n) { return n < 10 ? '0' + n : '' + n; };
peakEl.textContent = pad(h) + ':00 ' + pad((h + 1) % 24) + ':00';
}
}
var longestSessionEl = document.getElementById('productivityLongestSession');
if (longestSessionEl) {
longestSessionEl.textContent = (Number(focus.longest_session_hours) || 0).toFixed(2) + 'h';
}
var entriesPerDayEl = document.getElementById('productivityEntriesPerDay');
if (entriesPerDayEl) {
entriesPerDayEl.textContent = (Number(focus.entries_per_day) || 0).toFixed(2);
}
if (activeBadge) {
activeBadge.classList.toggle('hidden', !s.active_timer);
}
}
function renderInsights() {
var ul = document.getElementById('productivityInsights');
if (!ul) { return; }
var lines = (state.data && state.data.insights) || [];
ul.innerHTML = '';
if (!lines.length) {
var empty = document.createElement('li');
empty.className = 'text-text-muted-light dark:text-text-muted-dark';
empty.textContent = '{{ _('Start tracking time to unlock personal insights.') }}';
ul.appendChild(empty);
return;
}
lines.forEach(function (line) {
var li = document.createElement('li');
li.className = 'flex items-start gap-2';
var dot = document.createElement('i');
dot.className = 'fas fa-circle text-[6px] mt-2 text-amber-500';
dot.setAttribute('aria-hidden', 'true');
var span = document.createElement('span');
span.textContent = line;
li.appendChild(dot);
li.appendChild(span);
ul.appendChild(li);
});
}
function renderAll() {
renderSummary();
renderInsights();
renderDailyChart();
renderProjectChart();
renderSparkline();
renderHeatmap();
}
function setStatus(text) {
var el = document.getElementById('productivityRefreshStatus');
if (el) { el.textContent = text || ''; }
}
function refresh() {
var btn = document.getElementById('productivityRefreshBtn');
if (btn) { btn.disabled = true; btn.classList.add('opacity-60'); }
setStatus('{{ _('Refreshing…') }}');
fetch(STATS_URL + '?period=' + encodeURIComponent(state.period), {
credentials: 'same-origin',
headers: { 'Accept': 'application/json' },
}).then(function (r) {
return r.ok ? r.json() : null;
}).then(function (json) {
if (json && json.ok) {
state.data = json;
renderAll();
setStatus('{{ _('Updated just now') }}');
} else {
setStatus('{{ _('Could not refresh.') }}');
}
}).catch(function () {
setStatus('{{ _('Could not refresh.') }}');
}).finally(function () {
if (btn) { btn.disabled = false; btn.classList.remove('opacity-60'); }
scheduleAutoRefresh();
});
}
function scheduleAutoRefresh() {
if (state.refreshTimer) {
clearTimeout(state.refreshTimer);
state.refreshTimer = null;
}
var hasActive = !!(state.data && state.data.summary && state.data.summary.active_timer);
var interval = hasActive ? 60 * 1000 : 5 * 60 * 1000;
state.refreshTimer = setTimeout(function () {
if (document.visibilityState === 'visible') {
refresh();
} else {
scheduleAutoRefresh();
}
}, interval);
}
document.addEventListener('visibilitychange', function () {
if (document.visibilityState === 'visible') {
scheduleAutoRefresh();
}
});
var refreshBtn = document.getElementById('productivityRefreshBtn');
if (refreshBtn) {
refreshBtn.addEventListener('click', function (e) {
e.preventDefault();
refresh();
});
}
renderAll();
scheduleAutoRefresh();
})();
</script>
{% endblock %}
+1 -1
View File
@@ -47,7 +47,7 @@ curl -H "X-API-Key: YOUR_API_TOKEN" \
| **Search** | `/api/v1/search` | Global search across projects, tasks, clients |
| **Time approvals** | `/api/v1/time-entry-approvals` | Approve, reject, request approval for time entries |
| **Admin version check** | `/api/version/check`, `/api/version/dismiss` | Compare install to latest GitHub release; dismiss per version (admin only; session or API token; not under `/api/v1`) |
| **Dashboard (session)** | `/api/stats/value-dashboard`, `/api/dashboard/stats`, … | JSON used by the logged-in web UI (session cookie); see [REST API reference](api/REST_API.md) for `value-dashboard` fields and caching |
| **Dashboard (session)** | `/api/stats/value-dashboard`, `/api/productivity/stats`, `/api/projects/<id>/forecast`, `/api/ai/suggest`, … | JSON used by the logged-in web UI (session cookie); see [REST API reference](api/REST_API.md) for fields and caching |
Access is controlled by **scopes** (e.g. `read:projects`, `write:time_entries`) on **`/api/v1`** routes. Create a token with the scopes you need; see [API Token Scopes](api/API_TOKEN_SCOPES.md). The admin version endpoints do not require a specific scope but require an **administrator** user. Legacy **`/api/...`** dashboard JSON routes require a normal **logged-in session**, not API-token scopes.
+2
View File
@@ -322,6 +322,8 @@ Time entries feed into Projects and Invoices; use **Reports** to see time and bi
**Analyze your time and productivity:**
For a personal view of streaks, focus patterns, and project mix, open **My productivity** in the sidebar (`/dashboard/productivity`). See [features/PRODUCTIVITY_DASHBOARD.md](features/PRODUCTIVITY_DASHBOARD.md).
1. Go to **Reports**
2. **Choose report type**:
+54
View File
@@ -333,6 +333,60 @@ Returns a **partial calendar week** (Monday 00:00 local time through **now**) co
The main dashboard renders this as a grouped bar chart (Chart.js) with a short summary line; data is loaded by `app/static/dashboard-enhancements.js` on dashboard refresh.
#### Personal productivity stats
```
GET /api/productivity/stats
```
Returns streaks, focus metrics, daily breakdown, project mix, activity heatmap, and insight strings for the **current user** only. Powers the **My productivity** page (`/dashboard/productivity`). See [docs/features/PRODUCTIVITY_DASHBOARD.md](../features/PRODUCTIVITY_DASHBOARD.md).
**Query parameters:**
- `period` (optional, int) — days for focus and project breakdown (default **30**, max **90**). Daily breakdown and heatmap use fixed windows (14 and 84 days).
**Caching:** up to **5 minutes** per user and `period` via `app.utils.cache.get_cache()` when available. Skipped when the user has an **active timer** so today/week stats stay current.
**Response (200):** `{ "ok": true, "period": 30, "summary": {...}, "daily_breakdown": [...], "streak": {...}, "focus": {...}, "projects": [...], "heatmap": [...], "insights": [...] }`
**Errors:** `{ "ok": false, "error": "..." }` with HTTP **500** on server failure.
#### Project forecast (web JSON)
```
GET /api/projects/<project_id>/forecast
```
Deterministic velocity, budget, timeline, and task metrics for a project the user can access. Optional AI narrative when the AI helper is enabled.
**Query parameters:**
- `ai` — when truthy (`1`, `true`, `yes`, `on`), include LLM-generated narrative, risks, and recommendations (requires AI helper enabled).
- `refresh` — when truthy, bypass the in-process cache for this project.
**Caching:** in-memory per process, **10 minutes** per project and mode (`deterministic` vs `ai`), unless `refresh=true`.
**Response (200):** `{ "ok": true, "forecast": {...}, "ai": {...} }` when `ai=true` (`ai` may report `ok: false` with `error_code` while still returning `forecast`).
See [docs/BUDGET_ALERTS_AND_FORECASTING.md](../BUDGET_ALERTS_AND_FORECASTING.md).
#### AI time entry suggestions (web JSON)
```
GET /api/ai/suggest
```
Returns up to five suggested time entries (recent project/task pairs with last notes/tags). Deterministic suggestions always; when `rich=1` and the AI helper is enabled, may merge LLM suggestions.
**Query parameters:**
- `q` (optional) — filter by project name or notes substring.
- `rich` (optional) — request LLM-enhanced suggestions when AI is enabled.
**Response (200):** `{ "ok": true, "suggestions": [{ "project_id", "project_name", "task_id", "task_name", "notes", "tags", "billable", "confidence", "source" }] }`
Used by the Start Timer modal and manual entry **Autofill** when `ai_enabled` is true in the template context.
### Search
#### Global Search
+79
View File
@@ -0,0 +1,79 @@
# Personal productivity dashboard
## Overview
The **My productivity** page (`/dashboard/productivity`) is a dedicated view for the signed-in users own time-tracking patterns. It is separate from the main dashboard timer and summary widgets.
Navigation: sidebar → **My productivity** (chart-line icon).
## What it shows
### Summary cards
- **Todays hours** — completed time for the users local calendar day, with a progress arc against `standard_hours_per_day` (user preference, default 8h). Shows a “Timer running” indicator when an active timer exists.
- **This week** — hours logged Monday through today vs weekly goal (`WeeklyTimeGoal` for the current week when set, otherwise `standard_hours_per_day × 5`, default 40h). Progress bar reflects billable share.
- **Streak** — consecutive local days with at least one completed entry (today counts only if hours &gt; 0; otherwise the streak starts from yesterday). Shows best-ever streak.
- **Average session** — mean duration of completed entries in the last 30 days (entries under 5 minutes excluded).
### Charts and panels
- **Daily hours** — Chart.js bar chart for the last 14 days with a dashed goal reference line.
- **Project breakdown** — doughnut chart of top 8 projects (last 30 days) with HTML legend.
- **Focus** — most productive hour (with 24-bar sparkline), day of week, longest session, entries per active day, tracked days this month.
- **Activity heatmap** — GitHub-style grid for the last 12 weeks (levels 04 by hours per day).
- **Insights** — up to four plain-text tips derived from the same data (week-over-week change, streak, peak hour, top project share, billable rate).
## Data and timezone
All bucketing uses the users timezone (`User.timezone`, falling back to application timezone). Completed entries only (`end_time` is set).
Implementation: [`app/services/productivity_service.py`](../../app/services/productivity_service.py).
## API
```
GET /api/productivity/stats
```
**Authentication:** session cookie (`@login_required`).
**Query parameters:**
| Param | Default | Max | Purpose |
|-------|---------|-----|---------|
| `period` | 30 | 90 | Days for focus stats and project breakdown |
**Response (200):**
```json
{
"ok": true,
"period": 30,
"summary": { "today_hours": 0, "week_hours": 0, "week_goal_hours": 40, "week_goal_percent": 0, "active_timer": null, "billable_percent_week": 0, "top_project_week": null },
"daily_breakdown": [{ "date": "2026-05-01", "hours": 0, "billable_hours": 0, "entry_count": 0, "goal_hours": 8 }],
"streak": { "current_streak": 0, "longest_streak": 0, "tracked_days_this_month": 0, "total_days_this_month": 15 },
"focus": { "avg_session_length_minutes": 0, "longest_session_hours": 0, "entries_per_day": 0, "most_productive_hour": null, "most_productive_day": null, "hour_distribution": [] },
"projects": [],
"heatmap": [{ "date": "2026-02-15", "hours": 0, "level": 0 }],
"insights": []
}
```
**Caching:** when `app.utils.cache.get_cache()` is available, responses are cached for **5 minutes** per `user_id` and `period`, except when `summary.active_timer` is set (so live timer state stays fresh).
**Errors:** `{ "ok": false, "error": "..." }` with HTTP 500 on unexpected failure. The service layer never raises; empty users get zeros and empty lists.
## Client behaviour
The page embeds initial JSON from the server render, then:
- **Refresh** button calls `GET /api/productivity/stats` and updates Chart.js datasets in place.
- Auto-refresh every **5 minutes** when the tab is visible (`document.visibilityState`).
- Auto-refresh every **60 seconds** when an active timer is running.
Template: [`app/templates/main/productivity_dashboard.html`](../../app/templates/main/productivity_dashboard.html).
## Related features
- Main dashboard **Value insights** widget uses [`StatsService`](../../app/services/stats_service.py) and `GET /api/stats/value-dashboard` (different aggregates).
- Per-project forecasting on the project detail page: [Budget alerts & forecasting](../BUDGET_ALERTS_AND_FORECASTING.md) and `GET /api/projects/<id>/forecast`.