mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-18 12:19:18 -05:00
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:
@@ -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` 1–90 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, 15–240 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
@@ -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():
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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**:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
# Personal productivity dashboard
|
||||
|
||||
## Overview
|
||||
|
||||
The **My productivity** page (`/dashboard/productivity`) is a dedicated view for the signed-in user’s 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
|
||||
|
||||
- **Today’s hours** — completed time for the user’s 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 > 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 0–4 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 user’s 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`.
|
||||
Reference in New Issue
Block a user