mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-18 20:29:44 -05:00
feat: add AI-powered project forecasting on project detail pages
Introduce ForecastService for deterministic velocity, budget, timeline, and task metrics plus optional LLM narratives. Expose GET /api/projects/<id>/forecast with a 10-minute in-memory cache, and add a self-contained forecast panel to active budgeted projects. Document the feature in the budget forecasting and project dashboard guides.
This commit is contained in:
@@ -791,6 +791,115 @@ def project_burndown(project_id):
|
||||
)
|
||||
|
||||
|
||||
# Module-level forecast cache: {project_id: (cached_at_epoch, payload_dict, kind)}
|
||||
# kind is "deterministic" or "ai" so cached AI payloads aren't served for plain requests.
|
||||
_FORECAST_CACHE: dict = {}
|
||||
_FORECAST_CACHE_TTL_SEC = 600 # 10 minutes
|
||||
|
||||
|
||||
def _forecast_cache_get(project_id: int, kind: str):
|
||||
try:
|
||||
entry = _FORECAST_CACHE.get(int(project_id))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if not entry:
|
||||
return None
|
||||
cached_at, payload, cached_kind = entry
|
||||
if cached_kind != kind:
|
||||
return None
|
||||
if (datetime.utcnow().timestamp() - cached_at) > _FORECAST_CACHE_TTL_SEC:
|
||||
_FORECAST_CACHE.pop(int(project_id), None)
|
||||
return None
|
||||
return payload
|
||||
|
||||
|
||||
def _forecast_cache_set(project_id: int, kind: str, payload: dict) -> None:
|
||||
try:
|
||||
_FORECAST_CACHE[int(project_id)] = (datetime.utcnow().timestamp(), payload, kind)
|
||||
except (TypeError, ValueError):
|
||||
return
|
||||
|
||||
|
||||
def _forecast_cache_bust(project_id: int) -> None:
|
||||
try:
|
||||
_FORECAST_CACHE.pop(int(project_id), None)
|
||||
except (TypeError, ValueError):
|
||||
return
|
||||
|
||||
|
||||
def _forecast_truthy_param(value) -> bool:
|
||||
if value is None:
|
||||
return False
|
||||
return str(value).strip().lower() in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
@api_bp.route("/api/projects/<int:project_id>/forecast")
|
||||
@login_required
|
||||
def project_forecast(project_id):
|
||||
"""Return deterministic (and optionally AI-narrated) forecast for a project.
|
||||
|
||||
Query params:
|
||||
ai (bool, default false): include LLM-generated narrative/risks/recommendations.
|
||||
refresh (bool, default false): bust the in-memory cache for this project.
|
||||
"""
|
||||
try:
|
||||
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"))
|
||||
|
||||
if refresh:
|
||||
_forecast_cache_bust(project_id)
|
||||
|
||||
cache_kind = "ai" if include_ai else "deterministic"
|
||||
if not refresh:
|
||||
cached = _forecast_cache_get(project_id, cache_kind)
|
||||
if cached is not None:
|
||||
return jsonify(cached)
|
||||
|
||||
from app.services.forecast_service import ForecastService
|
||||
|
||||
try:
|
||||
ai_enabled = LLMService().is_enabled()
|
||||
except Exception:
|
||||
ai_enabled = False
|
||||
|
||||
if include_ai:
|
||||
ai_result = ForecastService.get_ai_forecast(project_id, current_user)
|
||||
forecast_dict = ai_result.get("deterministic") or ForecastService.get_deterministic_forecast(
|
||||
project_id, current_user
|
||||
)
|
||||
payload = {
|
||||
"ok": True,
|
||||
"project_id": int(project_id),
|
||||
"ai_enabled": bool(ai_enabled),
|
||||
"forecast": forecast_dict,
|
||||
"ai": {
|
||||
"ok": bool(ai_result.get("ok")),
|
||||
"narrative": ai_result.get("narrative"),
|
||||
"risks": ai_result.get("risks") or [],
|
||||
"recommendations": ai_result.get("recommendations") or [],
|
||||
"error": ai_result.get("error"),
|
||||
"error_code": ai_result.get("error_code"),
|
||||
},
|
||||
}
|
||||
else:
|
||||
forecast_dict = ForecastService.get_deterministic_forecast(project_id, current_user)
|
||||
payload = {
|
||||
"ok": True,
|
||||
"project_id": int(project_id),
|
||||
"ai_enabled": bool(ai_enabled),
|
||||
"forecast": forecast_dict,
|
||||
}
|
||||
|
||||
_forecast_cache_set(project_id, cache_kind, payload)
|
||||
return jsonify(payload)
|
||||
except Exception as exc:
|
||||
current_app.logger.exception("project_forecast failed for project %s", project_id)
|
||||
return jsonify({"ok": False, "error": str(exc) or "internal_error"}), 500
|
||||
|
||||
|
||||
@api_bp.route("/api/focus-sessions/start", methods=["POST"])
|
||||
@login_required
|
||||
def start_focus_session():
|
||||
|
||||
@@ -0,0 +1,517 @@
|
||||
"""AI-powered project forecasting service.
|
||||
|
||||
Combines deterministic projections (velocity, burn rate, deadline risk) with an
|
||||
optional LLM-generated narrative. All read-only — never raises, always returns
|
||||
safe defaults so the forecast panel renders even with sparse or no data.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from datetime import date, datetime, timedelta
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from app import db
|
||||
from app.models import Project, Task, TimeEntry
|
||||
from app.services.llm_service import AIServiceError, LLMService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _safe_float(value: Any, default: float = 0.0) -> float:
|
||||
try:
|
||||
if value is None:
|
||||
return default
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _clamp_percent(value: float) -> int:
|
||||
try:
|
||||
v = int(round(value))
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
return max(0, min(100, v))
|
||||
|
||||
|
||||
class ForecastService:
|
||||
"""Read-only project forecasting (deterministic math + optional AI narrative)."""
|
||||
|
||||
# ------------------------------------------------------------- defaults
|
||||
|
||||
@staticmethod
|
||||
def _empty_forecast(project: Optional[Project] = None) -> Dict[str, Any]:
|
||||
today = date.today()
|
||||
return {
|
||||
"has_data": False,
|
||||
# Velocity
|
||||
"total_logged_hours": 0.0,
|
||||
"days_with_entries": 0,
|
||||
"avg_hours_per_active_day": 0.0,
|
||||
"first_entry_date": None,
|
||||
"last_entry_date": None,
|
||||
"elapsed_calendar_days": 0,
|
||||
"avg_hours_per_calendar_day": 0.0,
|
||||
"velocity_hours_per_day": 0.0,
|
||||
# Budget
|
||||
"budget_hours": _safe_float(getattr(project, "estimated_hours", 0)) if project else 0.0,
|
||||
"budget_amount": (
|
||||
_safe_float(getattr(project, "budget_amount", 0)) if project else 0.0
|
||||
),
|
||||
"hourly_rate": (
|
||||
_safe_float(getattr(project, "hourly_rate", 0)) if project else 0.0
|
||||
),
|
||||
"hours_remaining": _safe_float(getattr(project, "estimated_hours", 0)) if project else 0.0,
|
||||
"budget_used_percent": 0,
|
||||
"budget_amount_used": 0.0,
|
||||
"budget_amount_remaining": (
|
||||
_safe_float(getattr(project, "budget_amount", 0)) if project else 0.0
|
||||
),
|
||||
"at_risk": False,
|
||||
# Timeline
|
||||
"days_to_completion": None,
|
||||
"projected_completion_date": None,
|
||||
"project_deadline": None,
|
||||
"days_until_deadline": None,
|
||||
"deadline_risk": "no_data",
|
||||
# Tasks
|
||||
"total_tasks": 0,
|
||||
"completed_tasks": 0,
|
||||
"open_tasks": 0,
|
||||
"task_completion_percent": 0,
|
||||
"overdue_tasks": 0,
|
||||
# Burn rate
|
||||
"recent_hours_7d": 0.0,
|
||||
"prior_hours_7d": 0.0,
|
||||
"burn_rate_trend": "stable",
|
||||
# Daily breakdown for chart
|
||||
"daily_hours": [],
|
||||
# Generated at (UTC ISO)
|
||||
"generated_at": datetime.utcnow().isoformat() + "Z",
|
||||
"today": today.isoformat(),
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------- deterministic
|
||||
|
||||
@classmethod
|
||||
def get_deterministic_forecast(cls, project_id: int, user=None) -> Dict[str, Any]:
|
||||
"""Compute deterministic forecast metrics for a project.
|
||||
|
||||
Never raises. Returns a flat dict with all forecast keys populated.
|
||||
"""
|
||||
try:
|
||||
project = Project.query.get(int(project_id))
|
||||
except Exception:
|
||||
logger.exception("ForecastService: failed to load project %s", project_id)
|
||||
return cls._empty_forecast(None)
|
||||
|
||||
if project is None:
|
||||
return cls._empty_forecast(None)
|
||||
|
||||
forecast = cls._empty_forecast(project)
|
||||
today = date.today()
|
||||
|
||||
# ---------------------------------------------- velocity / time entries
|
||||
try:
|
||||
rows = (
|
||||
db.session.query(TimeEntry.start_time, TimeEntry.duration_seconds)
|
||||
.filter(
|
||||
TimeEntry.project_id == project.id,
|
||||
TimeEntry.end_time.isnot(None),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("ForecastService: time entry query failed for project %s", project_id)
|
||||
rows = []
|
||||
|
||||
total_seconds = 0
|
||||
by_day_seconds: Dict[date, int] = defaultdict(int)
|
||||
first_dt: Optional[datetime] = None
|
||||
last_dt: Optional[datetime] = None
|
||||
|
||||
for start_time, duration_seconds in rows:
|
||||
sec = int(duration_seconds or 0)
|
||||
if sec <= 0:
|
||||
continue
|
||||
total_seconds += sec
|
||||
try:
|
||||
d = start_time.date() if hasattr(start_time, "date") else None
|
||||
except Exception:
|
||||
d = None
|
||||
if d is None:
|
||||
continue
|
||||
by_day_seconds[d] += sec
|
||||
if first_dt is None or start_time < first_dt:
|
||||
first_dt = start_time
|
||||
if last_dt is None or start_time > last_dt:
|
||||
last_dt = start_time
|
||||
|
||||
total_logged_hours = round(total_seconds / 3600.0, 2)
|
||||
days_with_entries = len(by_day_seconds)
|
||||
avg_hours_per_active_day = (
|
||||
round(total_logged_hours / days_with_entries, 2) if days_with_entries > 0 else 0.0
|
||||
)
|
||||
|
||||
first_entry_date = first_dt.date() if first_dt else None
|
||||
last_entry_date = last_dt.date() if last_dt else None
|
||||
|
||||
if first_entry_date and last_entry_date and days_with_entries >= 2:
|
||||
elapsed_calendar_days = (last_entry_date - first_entry_date).days + 1
|
||||
elif days_with_entries == 1:
|
||||
elapsed_calendar_days = 1
|
||||
else:
|
||||
elapsed_calendar_days = 0
|
||||
|
||||
avg_hours_per_calendar_day = (
|
||||
round(total_logged_hours / elapsed_calendar_days, 2)
|
||||
if elapsed_calendar_days > 0
|
||||
else 0.0
|
||||
)
|
||||
|
||||
# Velocity: avg_hours_per_calendar_day primary; if short history (< 7d),
|
||||
# also compute last-7-days velocity and use the higher (more conservative)
|
||||
# so we don't under-estimate completion time.
|
||||
velocity_hours_per_day = avg_hours_per_calendar_day
|
||||
if elapsed_calendar_days < 7 and total_logged_hours > 0:
|
||||
recent_window_start = today - timedelta(days=6)
|
||||
recent_seconds = sum(
|
||||
sec for d, sec in by_day_seconds.items() if d >= recent_window_start
|
||||
)
|
||||
recent_hours = recent_seconds / 3600.0
|
||||
recent_velocity = round(recent_hours / 7.0, 2)
|
||||
velocity_hours_per_day = max(velocity_hours_per_day, recent_velocity)
|
||||
|
||||
forecast["total_logged_hours"] = total_logged_hours
|
||||
forecast["days_with_entries"] = int(days_with_entries)
|
||||
forecast["avg_hours_per_active_day"] = avg_hours_per_active_day
|
||||
forecast["first_entry_date"] = first_entry_date.isoformat() if first_entry_date else None
|
||||
forecast["last_entry_date"] = last_entry_date.isoformat() if last_entry_date else None
|
||||
forecast["elapsed_calendar_days"] = int(elapsed_calendar_days)
|
||||
forecast["avg_hours_per_calendar_day"] = avg_hours_per_calendar_day
|
||||
forecast["velocity_hours_per_day"] = float(velocity_hours_per_day)
|
||||
forecast["has_data"] = total_logged_hours > 0
|
||||
|
||||
# --------------------------------------------------------- budget calc
|
||||
budget_hours = _safe_float(getattr(project, "estimated_hours", 0))
|
||||
budget_amount = _safe_float(getattr(project, "budget_amount", 0))
|
||||
hourly_rate = _safe_float(getattr(project, "hourly_rate", 0))
|
||||
if hourly_rate <= 0 and budget_hours > 0 and budget_amount > 0:
|
||||
try:
|
||||
hourly_rate = budget_amount / budget_hours
|
||||
except ZeroDivisionError:
|
||||
hourly_rate = 0.0
|
||||
|
||||
hours_remaining = max(0.0, budget_hours - total_logged_hours)
|
||||
budget_used_percent = (
|
||||
_clamp_percent((total_logged_hours / budget_hours) * 100) if budget_hours > 0 else 0
|
||||
)
|
||||
budget_amount_used = round(total_logged_hours * hourly_rate, 2)
|
||||
budget_amount_remaining = max(0.0, budget_amount - budget_amount_used)
|
||||
at_risk = budget_used_percent >= 80 if budget_hours > 0 else False
|
||||
|
||||
forecast["budget_hours"] = round(budget_hours, 2)
|
||||
forecast["budget_amount"] = round(budget_amount, 2)
|
||||
forecast["hourly_rate"] = round(hourly_rate, 2)
|
||||
forecast["hours_remaining"] = round(hours_remaining, 2)
|
||||
forecast["budget_used_percent"] = int(budget_used_percent)
|
||||
forecast["budget_amount_used"] = round(budget_amount_used, 2)
|
||||
forecast["budget_amount_remaining"] = round(budget_amount_remaining, 2)
|
||||
forecast["at_risk"] = bool(at_risk)
|
||||
|
||||
# ------------------------------------------------------- timeline calc
|
||||
if velocity_hours_per_day > 0 and hours_remaining > 0:
|
||||
try:
|
||||
days_to_completion = int(math.ceil(hours_remaining / velocity_hours_per_day))
|
||||
except (ValueError, ZeroDivisionError):
|
||||
days_to_completion = None
|
||||
projected_completion_date = (
|
||||
today + timedelta(days=days_to_completion) if days_to_completion is not None else None
|
||||
)
|
||||
elif hours_remaining <= 0 and budget_hours > 0:
|
||||
days_to_completion = 0
|
||||
projected_completion_date = today
|
||||
else:
|
||||
days_to_completion = None
|
||||
projected_completion_date = None
|
||||
|
||||
deadline_value = getattr(project, "deadline", None)
|
||||
project_deadline: Optional[date] = None
|
||||
if deadline_value is not None:
|
||||
try:
|
||||
project_deadline = deadline_value if isinstance(deadline_value, date) else deadline_value.date()
|
||||
except Exception:
|
||||
project_deadline = None
|
||||
|
||||
days_until_deadline: Optional[int] = None
|
||||
if project_deadline is not None:
|
||||
days_until_deadline = (project_deadline - today).days
|
||||
|
||||
deadline_risk = cls._compute_deadline_risk(
|
||||
velocity_hours_per_day, projected_completion_date, project_deadline
|
||||
)
|
||||
|
||||
forecast["days_to_completion"] = days_to_completion
|
||||
forecast["projected_completion_date"] = (
|
||||
projected_completion_date.isoformat() if projected_completion_date else None
|
||||
)
|
||||
forecast["project_deadline"] = project_deadline.isoformat() if project_deadline else None
|
||||
forecast["days_until_deadline"] = days_until_deadline
|
||||
forecast["deadline_risk"] = deadline_risk
|
||||
|
||||
# ------------------------------------------------------------ tasks
|
||||
try:
|
||||
task_rows = (
|
||||
db.session.query(Task.status, Task.due_date)
|
||||
.filter(Task.project_id == project.id)
|
||||
.all()
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("ForecastService: task query failed for project %s", project_id)
|
||||
task_rows = []
|
||||
|
||||
total_tasks = len(task_rows)
|
||||
completed_tasks = sum(1 for status, _due in task_rows if status == "done")
|
||||
open_tasks = sum(1 for status, _due in task_rows if status not in ("done", "cancelled"))
|
||||
overdue_tasks = sum(
|
||||
1
|
||||
for status, due in task_rows
|
||||
if due is not None and due < today and status not in ("done", "cancelled")
|
||||
)
|
||||
task_completion_percent = (
|
||||
_clamp_percent((completed_tasks / total_tasks) * 100) if total_tasks > 0 else 0
|
||||
)
|
||||
|
||||
forecast["total_tasks"] = int(total_tasks)
|
||||
forecast["completed_tasks"] = int(completed_tasks)
|
||||
forecast["open_tasks"] = int(open_tasks)
|
||||
forecast["task_completion_percent"] = int(task_completion_percent)
|
||||
forecast["overdue_tasks"] = int(overdue_tasks)
|
||||
|
||||
# ------------------------------------------------------- burn rate 7/7
|
||||
last_7_start = today - timedelta(days=6)
|
||||
prior_7_start = today - timedelta(days=13)
|
||||
prior_7_end = today - timedelta(days=7)
|
||||
recent_seconds = sum(sec for d, sec in by_day_seconds.items() if d >= last_7_start)
|
||||
prior_seconds = sum(
|
||||
sec for d, sec in by_day_seconds.items() if prior_7_start <= d <= prior_7_end
|
||||
)
|
||||
recent_hours_7d = round(recent_seconds / 3600.0, 2)
|
||||
prior_hours_7d = round(prior_seconds / 3600.0, 2)
|
||||
|
||||
if prior_hours_7d <= 0 and recent_hours_7d <= 0:
|
||||
burn_rate_trend = "stable"
|
||||
elif prior_hours_7d <= 0 and recent_hours_7d > 0:
|
||||
burn_rate_trend = "increasing"
|
||||
elif recent_hours_7d > prior_hours_7d * 1.1:
|
||||
burn_rate_trend = "increasing"
|
||||
elif recent_hours_7d < prior_hours_7d * 0.9:
|
||||
burn_rate_trend = "decreasing"
|
||||
else:
|
||||
burn_rate_trend = "stable"
|
||||
|
||||
forecast["recent_hours_7d"] = recent_hours_7d
|
||||
forecast["prior_hours_7d"] = prior_hours_7d
|
||||
forecast["burn_rate_trend"] = burn_rate_trend
|
||||
|
||||
# ----------------------------------------- daily breakdown for chart
|
||||
daily_hours: List[Dict[str, Any]] = []
|
||||
chart_start = today - timedelta(days=13)
|
||||
cur = chart_start
|
||||
while cur <= today:
|
||||
daily_hours.append(
|
||||
{
|
||||
"date": cur.isoformat(),
|
||||
"hours": round(by_day_seconds.get(cur, 0) / 3600.0, 2),
|
||||
}
|
||||
)
|
||||
cur += timedelta(days=1)
|
||||
forecast["daily_hours"] = daily_hours
|
||||
|
||||
return forecast
|
||||
|
||||
@staticmethod
|
||||
def _compute_deadline_risk(
|
||||
velocity: float, projected_completion: Optional[date], deadline: Optional[date]
|
||||
) -> str:
|
||||
"""Map (velocity, projected completion, deadline) to a coarse risk label."""
|
||||
if velocity is None or velocity <= 0 or projected_completion is None:
|
||||
return "no_data"
|
||||
if deadline is None:
|
||||
return "on_track"
|
||||
try:
|
||||
delta_days = (projected_completion - deadline).days
|
||||
except Exception:
|
||||
return "no_data"
|
||||
if delta_days <= 0:
|
||||
return "on_track"
|
||||
if delta_days <= 7:
|
||||
return "at_risk"
|
||||
return "overdue"
|
||||
|
||||
# ------------------------------------------------------------ AI narrative
|
||||
|
||||
@classmethod
|
||||
def get_ai_forecast(cls, project_id: int, user) -> Dict[str, Any]:
|
||||
"""Run deterministic forecast and ask the LLM for a short narrative.
|
||||
|
||||
Returns a dict with keys: ok, narrative, risks, recommendations, deterministic.
|
||||
On any failure (LLM disabled, bad JSON, provider error) returns ok=False
|
||||
with a populated error string and empty risk/recommendation lists, while
|
||||
still including the deterministic forecast so the caller can render it.
|
||||
"""
|
||||
deterministic = cls.get_deterministic_forecast(project_id, user=user)
|
||||
|
||||
try:
|
||||
project = Project.query.get(int(project_id))
|
||||
except Exception:
|
||||
project = None
|
||||
if project is None:
|
||||
return {
|
||||
"ok": False,
|
||||
"error": "Project not found",
|
||||
"error_code": "not_found",
|
||||
"narrative": None,
|
||||
"risks": [],
|
||||
"recommendations": [],
|
||||
"deterministic": deterministic,
|
||||
}
|
||||
|
||||
service = LLMService()
|
||||
try:
|
||||
service.ensure_enabled()
|
||||
except AIServiceError as exc:
|
||||
return {
|
||||
"ok": False,
|
||||
"error": exc.message,
|
||||
"error_code": exc.code,
|
||||
"narrative": None,
|
||||
"risks": [],
|
||||
"recommendations": [],
|
||||
"deterministic": deterministic,
|
||||
}
|
||||
|
||||
context = {
|
||||
"project": {
|
||||
"name": project.name,
|
||||
"status": project.status,
|
||||
"estimated_hours": project.estimated_hours,
|
||||
"budget_amount": (
|
||||
float(project.budget_amount) if project.budget_amount is not None else None
|
||||
),
|
||||
"deadline": (
|
||||
getattr(project, "deadline", None).isoformat()
|
||||
if getattr(project, "deadline", None)
|
||||
else None
|
||||
),
|
||||
},
|
||||
"forecast": deterministic,
|
||||
"today": date.today().isoformat(),
|
||||
}
|
||||
|
||||
prompt = (
|
||||
"You are a project management assistant. Based on the following project "
|
||||
"data and forecast, provide:\n"
|
||||
"1. A 2-sentence executive summary of the project's health\n"
|
||||
"2. Up to 3 specific risks (as a JSON array of short strings)\n"
|
||||
"3. Up to 3 actionable recommendations (as a JSON array of short strings)\n\n"
|
||||
"Respond ONLY with valid JSON in this exact shape:\n"
|
||||
"{\n"
|
||||
" \"narrative\": \"...\",\n"
|
||||
" \"risks\": [\"...\", \"...\"],\n"
|
||||
" \"recommendations\": [\"...\", \"...\"]\n"
|
||||
"}\n\n"
|
||||
"Project data:\n"
|
||||
+ json.dumps(context, default=str, ensure_ascii=False)
|
||||
)
|
||||
|
||||
try:
|
||||
response = service._chat_completion(
|
||||
[
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"You are a project forecasting assistant. "
|
||||
"Respond only with the requested JSON."
|
||||
),
|
||||
},
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
max_tokens=400,
|
||||
)
|
||||
except AIServiceError as exc:
|
||||
return {
|
||||
"ok": False,
|
||||
"error": exc.message,
|
||||
"error_code": exc.code,
|
||||
"narrative": None,
|
||||
"risks": [],
|
||||
"recommendations": [],
|
||||
"deterministic": deterministic,
|
||||
}
|
||||
except Exception as exc:
|
||||
logger.exception("ForecastService: AI completion failed for project %s", project_id)
|
||||
return {
|
||||
"ok": False,
|
||||
"error": str(exc) or "ai_error",
|
||||
"error_code": "ai_error",
|
||||
"narrative": None,
|
||||
"risks": [],
|
||||
"recommendations": [],
|
||||
"deterministic": deterministic,
|
||||
}
|
||||
|
||||
raw_content = (response or {}).get("content") or ""
|
||||
parsed = cls._parse_ai_json(raw_content)
|
||||
if parsed is None:
|
||||
return {
|
||||
"ok": False,
|
||||
"error": "Could not parse AI response",
|
||||
"error_code": "ai_parse_error",
|
||||
"narrative": None,
|
||||
"risks": [],
|
||||
"recommendations": [],
|
||||
"deterministic": deterministic,
|
||||
}
|
||||
|
||||
narrative = parsed.get("narrative")
|
||||
risks = parsed.get("risks") or []
|
||||
recommendations = parsed.get("recommendations") or []
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"narrative": str(narrative).strip() if narrative else "",
|
||||
"risks": [str(r).strip() for r in risks if isinstance(r, (str, int, float))][:3],
|
||||
"recommendations": [
|
||||
str(r).strip() for r in recommendations if isinstance(r, (str, int, float))
|
||||
][:3],
|
||||
"deterministic": deterministic,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _parse_ai_json(content: str) -> Optional[Dict[str, Any]]:
|
||||
"""Strip ```json fences and parse the model's JSON response."""
|
||||
if not content:
|
||||
return None
|
||||
text = content.strip()
|
||||
# Strip ```json ... ``` fences, including bare ``` fences.
|
||||
fence_match = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, flags=re.DOTALL)
|
||||
if fence_match:
|
||||
text = fence_match.group(1)
|
||||
else:
|
||||
# Try to find the first JSON object in the text.
|
||||
obj_match = re.search(r"\{.*\}", text, flags=re.DOTALL)
|
||||
if obj_match:
|
||||
text = obj_match.group(0)
|
||||
try:
|
||||
data = json.loads(text)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
return data
|
||||
@@ -0,0 +1,672 @@
|
||||
{# Project AI Forecast component
|
||||
Required Jinja vars:
|
||||
project (Project): the project to forecast for
|
||||
Uses globals injected by context_processors: currency, ai_enabled
|
||||
Renders a self-contained card. Fetches data via JS from
|
||||
GET /api/projects/<id>/forecast (deterministic) and ?ai=true (AI insights).
|
||||
#}
|
||||
<div id="projectForecast-{{ project.id }}"
|
||||
class="bg-card-light dark:bg-card-dark p-6 rounded-xl border border-border-light dark:border-border-dark shadow-sm animated-card mt-6"
|
||||
data-project-id="{{ project.id }}"
|
||||
data-currency-symbol="{{ (currency or 'EUR')|currency_symbol }}"
|
||||
data-ai-enabled="{{ '1' if ai_enabled else '0' }}">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between gap-2 mb-4 flex-wrap">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<h2 class="text-lg font-semibold flex items-center">
|
||||
<i class="fas fa-chart-line mr-2 text-primary"></i>
|
||||
{{ _('Project forecast') }}
|
||||
</h2>
|
||||
<span class="px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide rounded bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300">{{ _('beta') }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button type="button"
|
||||
data-forecast-ai-btn
|
||||
class="hidden inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg bg-gradient-to-r from-indigo-600 to-purple-600 hover:from-indigo-700 hover:to-purple-700 text-white transition shadow-sm">
|
||||
<i class="fas fa-wand-magic-sparkles"></i>
|
||||
<span>{{ _('AI insights') }}</span>
|
||||
</button>
|
||||
<button type="button"
|
||||
data-forecast-refresh-btn
|
||||
title="{{ _('Refresh forecast') }}"
|
||||
aria-label="{{ _('Refresh forecast') }}"
|
||||
class="inline-flex items-center justify-center w-9 h-9 rounded-lg border border-border-light dark:border-border-dark bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 transition">
|
||||
<i class="fas fa-rotate"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading skeleton -->
|
||||
<div data-forecast-skeleton>
|
||||
<div class="animate-pulse space-y-4">
|
||||
<div class="h-3 w-1/3 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
||||
<div class="h-4 w-full bg-gray-200 dark:bg-gray-700 rounded"></div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div class="h-20 bg-gray-200 dark:bg-gray-700 rounded-lg"></div>
|
||||
<div class="h-20 bg-gray-200 dark:bg-gray-700 rounded-lg"></div>
|
||||
<div class="h-20 bg-gray-200 dark:bg-gray-700 rounded-lg"></div>
|
||||
<div class="h-20 bg-gray-200 dark:bg-gray-700 rounded-lg"></div>
|
||||
</div>
|
||||
<div class="h-[120px] bg-gray-200 dark:bg-gray-700 rounded-lg"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error state -->
|
||||
<div data-forecast-error class="hidden p-4 rounded-lg bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 text-sm flex items-center justify-between gap-3">
|
||||
<span><i class="fas fa-triangle-exclamation mr-2"></i><span data-forecast-error-text>{{ _('Could not load forecast.') }}</span></span>
|
||||
<button type="button" data-forecast-retry-btn class="px-2 py-1 text-xs rounded bg-red-600 text-white hover:bg-red-700 transition">{{ _('Retry') }}</button>
|
||||
</div>
|
||||
|
||||
<!-- Content (hidden until data is loaded) -->
|
||||
<div data-forecast-content class="hidden space-y-6">
|
||||
|
||||
<!-- Section 1: Health indicator bar + pills -->
|
||||
<div>
|
||||
<div class="flex justify-between text-xs text-text-muted-light dark:text-text-muted-dark mb-1.5">
|
||||
<span>{{ _('Hours progress') }}</span>
|
||||
<span><span data-forecast-logged-hours>0</span> / <span data-forecast-budget-hours>—</span> h</span>
|
||||
</div>
|
||||
<div class="w-full h-3 rounded-full bg-gray-200 dark:bg-gray-700 overflow-hidden flex" data-forecast-health-bar>
|
||||
<div class="h-full bg-emerald-500 transition-all" style="width: 0%" data-forecast-bar-logged
|
||||
title="{{ _('Logged hours') }}"></div>
|
||||
<div class="h-full bg-amber-400 transition-all" style="width: 0%" data-forecast-bar-projected
|
||||
title="{{ _('Projected remaining') }}"></div>
|
||||
<div class="h-full bg-red-500 transition-all" style="width: 0%" data-forecast-bar-over
|
||||
title="{{ _('Over budget') }}"></div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300"
|
||||
data-forecast-pill-budget>
|
||||
<i class="fas fa-wallet mr-1.5"></i><span data-forecast-budget-text>—</span>
|
||||
</span>
|
||||
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-indigo-50 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300"
|
||||
data-forecast-pill-eta>
|
||||
<i class="fas fa-flag-checkered mr-1.5"></i><span data-forecast-eta-text>—</span>
|
||||
</span>
|
||||
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200"
|
||||
data-forecast-pill-deadline>
|
||||
<i class="fas fa-circle-info mr-1.5"></i><span data-forecast-deadline-text>{{ _('No data') }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section 2: Metrics grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<!-- Velocity -->
|
||||
<div class="bg-gray-50 dark:bg-gray-800/50 p-4 rounded-lg border border-border-light dark:border-border-dark">
|
||||
<div class="text-xs text-text-muted-light dark:text-text-muted-dark mb-1">{{ _('Velocity') }}</div>
|
||||
<div class="text-2xl font-bold" data-forecast-velocity>0h/day</div>
|
||||
<div class="text-xs mt-1 flex items-center gap-1" data-forecast-burn-trend>
|
||||
<span class="text-text-muted-light dark:text-text-muted-dark">{{ _('Burn rate') }}</span>
|
||||
<span data-forecast-burn-trend-text></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Projected completion -->
|
||||
<div class="bg-gray-50 dark:bg-gray-800/50 p-4 rounded-lg border border-border-light dark:border-border-dark">
|
||||
<div class="text-xs text-text-muted-light dark:text-text-muted-dark mb-1">{{ _('Projected completion') }}</div>
|
||||
<div class="text-2xl font-bold" data-forecast-projection>—</div>
|
||||
<div class="text-xs mt-1 text-text-muted-light dark:text-text-muted-dark" data-forecast-projection-sub></div>
|
||||
</div>
|
||||
|
||||
<!-- Budget remaining -->
|
||||
<div class="bg-gray-50 dark:bg-gray-800/50 p-4 rounded-lg border border-border-light dark:border-border-dark">
|
||||
<div class="text-xs text-text-muted-light dark:text-text-muted-dark mb-1">{{ _('Budget remaining') }}</div>
|
||||
<div class="text-2xl font-bold" data-forecast-remaining>—</div>
|
||||
<div class="text-xs mt-1" data-forecast-remaining-sub></div>
|
||||
</div>
|
||||
|
||||
<!-- Tasks -->
|
||||
<div class="bg-gray-50 dark:bg-gray-800/50 p-4 rounded-lg border border-border-light dark:border-border-dark">
|
||||
<div class="text-xs text-text-muted-light dark:text-text-muted-dark mb-1">{{ _('Tasks') }}</div>
|
||||
<div class="text-2xl font-bold" data-forecast-tasks>0/0</div>
|
||||
<div class="text-xs mt-1" data-forecast-tasks-sub></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section 3: Burn chart -->
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h3 class="text-sm font-semibold text-text-muted-light dark:text-text-muted-dark">{{ _('Daily hours (last 14 days)') }}</h3>
|
||||
<span class="text-xs text-text-muted-light dark:text-text-muted-dark" data-forecast-chart-empty>—</span>
|
||||
</div>
|
||||
<div class="relative" style="height: 120px;">
|
||||
<canvas data-forecast-chart></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section 4: AI insights panel -->
|
||||
<div data-forecast-ai-panel class="hidden border-t border-border-light dark:border-border-dark pt-5">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h3 class="text-sm font-semibold flex items-center gap-2">
|
||||
<i class="fas fa-wand-magic-sparkles text-purple-500"></i>
|
||||
{{ _('AI insights') }}
|
||||
</h3>
|
||||
<span class="text-[10px] uppercase tracking-wide font-semibold px-2 py-0.5 rounded bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300">{{ _('beta') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- AI loading -->
|
||||
<div data-forecast-ai-loading class="flex items-center gap-2 text-sm text-text-muted-light dark:text-text-muted-dark">
|
||||
<span class="inline-block w-4 h-4 border-2 border-current border-r-transparent rounded-full animate-spin"></span>
|
||||
<span>{{ _('Generating AI insights…') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- AI error -->
|
||||
<div data-forecast-ai-error class="hidden p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 text-amber-800 dark:text-amber-200 text-sm">
|
||||
<i class="fas fa-circle-info mr-1.5"></i><span data-forecast-ai-error-text></span>
|
||||
</div>
|
||||
|
||||
<!-- AI content -->
|
||||
<div data-forecast-ai-content class="hidden space-y-4">
|
||||
<blockquote class="border-l-4 border-indigo-500 pl-3 py-1 italic text-sm text-text-light dark:text-text-dark bg-indigo-50/40 dark:bg-indigo-900/10 rounded-r">
|
||||
<span data-forecast-ai-narrative></span>
|
||||
</blockquote>
|
||||
|
||||
<div data-forecast-ai-risks-wrap>
|
||||
<h4 class="text-xs uppercase font-semibold text-text-muted-light dark:text-text-muted-dark mb-1.5">{{ _('Risks') }}</h4>
|
||||
<ul class="space-y-1.5 text-sm" data-forecast-ai-risks></ul>
|
||||
</div>
|
||||
|
||||
<div data-forecast-ai-recs-wrap>
|
||||
<h4 class="text-xs uppercase font-semibold text-text-muted-light dark:text-text-muted-dark mb-1.5">{{ _('Recommendations') }}</h4>
|
||||
<ul class="space-y-1.5 text-sm" data-forecast-ai-recs></ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var root = document.getElementById('projectForecast-{{ project.id }}');
|
||||
if (!root) return;
|
||||
|
||||
var projectId = root.dataset.projectId;
|
||||
var currencySymbol = root.dataset.currencySymbol || '$';
|
||||
var aiEnabledInitial = root.dataset.aiEnabled === '1';
|
||||
|
||||
var state = {
|
||||
chart: null,
|
||||
deterministic: null,
|
||||
ai: null,
|
||||
aiPanelOpen: false,
|
||||
};
|
||||
|
||||
var endpoint = '/api/projects/' + projectId + '/forecast';
|
||||
|
||||
// ---------- helpers ----------
|
||||
function $(selector) { return root.querySelector(selector); }
|
||||
function $all(selector) { return root.querySelectorAll(selector); }
|
||||
|
||||
function show(el) { if (el) el.classList.remove('hidden'); }
|
||||
function hide(el) { if (el) el.classList.add('hidden'); }
|
||||
|
||||
function fmtHours(h) {
|
||||
if (h === null || h === undefined || isNaN(h)) return '—';
|
||||
return (Math.round(h * 10) / 10).toFixed(1);
|
||||
}
|
||||
function fmtMoney(v) {
|
||||
if (v === null || v === undefined || isNaN(v)) return '—';
|
||||
try {
|
||||
return currencySymbol + Number(v).toLocaleString(undefined, { maximumFractionDigits: 0 });
|
||||
} catch (e) {
|
||||
return currencySymbol + Math.round(Number(v) || 0);
|
||||
}
|
||||
}
|
||||
function fmtDateLong(iso) {
|
||||
if (!iso) return '—';
|
||||
try {
|
||||
var d = new Date(iso + (iso.length === 10 ? 'T00:00:00' : ''));
|
||||
if (isNaN(d.getTime())) return iso;
|
||||
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
|
||||
} catch (e) { return iso; }
|
||||
}
|
||||
function fmtDateShort(iso) {
|
||||
if (!iso) return '';
|
||||
try {
|
||||
var d = new Date(iso + (iso.length === 10 ? 'T00:00:00' : ''));
|
||||
if (isNaN(d.getTime())) return iso;
|
||||
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||||
} catch (e) { return iso; }
|
||||
}
|
||||
function escapeHtml(s) {
|
||||
if (s === null || s === undefined) return '';
|
||||
return String(s).replace(/[&<>"']/g, function (c) {
|
||||
return ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c];
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- render ----------
|
||||
function renderHealthBar(f) {
|
||||
var totalLogged = Number(f.total_logged_hours) || 0;
|
||||
var budgetHours = Number(f.budget_hours) || 0;
|
||||
var velocity = Number(f.velocity_hours_per_day) || 0;
|
||||
var hoursRemaining = Number(f.hours_remaining) || 0;
|
||||
|
||||
var hasBudget = budgetHours > 0;
|
||||
var overHours = hasBudget ? Math.max(0, totalLogged - budgetHours) : 0;
|
||||
var loggedWithinBudget = hasBudget ? Math.min(totalLogged, budgetHours) : totalLogged;
|
||||
var projectedRemaining = hasBudget ? Math.max(0, budgetHours - totalLogged) : 0;
|
||||
|
||||
var totalWidth = Math.max(budgetHours, totalLogged * 1.2, 1);
|
||||
var loggedPct = (loggedWithinBudget / totalWidth) * 100;
|
||||
var projectedPct = (projectedRemaining / totalWidth) * 100;
|
||||
var overPct = (overHours / totalWidth) * 100;
|
||||
|
||||
var loggedEl = $('[data-forecast-bar-logged]');
|
||||
var projectedEl = $('[data-forecast-bar-projected]');
|
||||
var overEl = $('[data-forecast-bar-over]');
|
||||
if (loggedEl) {
|
||||
loggedEl.style.width = loggedPct + '%';
|
||||
loggedEl.setAttribute('title', '{{ _("Logged") }}: ' + fmtHours(loggedWithinBudget) + 'h');
|
||||
}
|
||||
if (projectedEl) {
|
||||
projectedEl.style.width = projectedPct + '%';
|
||||
projectedEl.setAttribute('title', '{{ _("Projected remaining") }}: ' + fmtHours(projectedRemaining) + 'h');
|
||||
}
|
||||
if (overEl) {
|
||||
overEl.style.width = overPct + '%';
|
||||
overEl.setAttribute('title', '{{ _("Over budget") }}: ' + fmtHours(overHours) + 'h');
|
||||
}
|
||||
|
||||
$('[data-forecast-logged-hours]').textContent = fmtHours(totalLogged);
|
||||
$('[data-forecast-budget-hours]').textContent = hasBudget ? fmtHours(budgetHours) : '—';
|
||||
|
||||
// Pills
|
||||
var budgetPillText = $('[data-forecast-budget-text]');
|
||||
if (hasBudget) {
|
||||
budgetPillText.textContent = (f.budget_used_percent || 0) + '% {{ _("budget used") }}';
|
||||
} else {
|
||||
budgetPillText.textContent = '{{ _("No budget set") }}';
|
||||
}
|
||||
|
||||
var etaText = $('[data-forecast-eta-text]');
|
||||
if (f.days_to_completion === 0) {
|
||||
etaText.textContent = '{{ _("Complete") }}';
|
||||
} else if (typeof f.days_to_completion === 'number') {
|
||||
etaText.textContent = f.days_to_completion + ' {{ _("days to completion") }}';
|
||||
} else {
|
||||
etaText.textContent = '{{ _("Insufficient data") }}';
|
||||
}
|
||||
|
||||
var deadlinePill = $('[data-forecast-pill-deadline]');
|
||||
var deadlineText = $('[data-forecast-deadline-text]');
|
||||
deadlinePill.className = 'inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium';
|
||||
switch (f.deadline_risk) {
|
||||
case 'on_track':
|
||||
deadlinePill.className += ' bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300';
|
||||
deadlineText.textContent = '{{ _("On track") }}';
|
||||
break;
|
||||
case 'at_risk':
|
||||
deadlinePill.className += ' bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300';
|
||||
deadlineText.textContent = '{{ _("At risk") }}';
|
||||
break;
|
||||
case 'overdue':
|
||||
deadlinePill.className += ' bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300';
|
||||
deadlineText.textContent = '{{ _("Overdue") }}';
|
||||
break;
|
||||
default:
|
||||
deadlinePill.className += ' bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200';
|
||||
deadlineText.textContent = '{{ _("No data") }}';
|
||||
}
|
||||
}
|
||||
|
||||
function renderMetrics(f) {
|
||||
$('[data-forecast-velocity]').textContent = fmtHours(f.avg_hours_per_calendar_day) + 'h/day';
|
||||
|
||||
var trendEl = $('[data-forecast-burn-trend-text]');
|
||||
var trendMap = {
|
||||
increasing: { label: '{{ _("increasing") }}', cls: 'text-emerald-600 dark:text-emerald-400', arrow: '↑' },
|
||||
decreasing: { label: '{{ _("decreasing") }}', cls: 'text-red-600 dark:text-red-400', arrow: '↓' },
|
||||
stable: { label: '{{ _("stable") }}', cls: 'text-gray-600 dark:text-gray-300', arrow: '→' },
|
||||
};
|
||||
var t = trendMap[f.burn_rate_trend] || trendMap.stable;
|
||||
trendEl.className = t.cls + ' font-medium';
|
||||
trendEl.textContent = t.label + ' ' + t.arrow;
|
||||
|
||||
var projection = $('[data-forecast-projection]');
|
||||
var projectionSub = $('[data-forecast-projection-sub]');
|
||||
if (f.projected_completion_date) {
|
||||
projection.textContent = fmtDateLong(f.projected_completion_date);
|
||||
if (typeof f.days_to_completion === 'number') {
|
||||
if (f.project_deadline && typeof f.days_until_deadline === 'number') {
|
||||
var delta = f.days_to_completion - f.days_until_deadline;
|
||||
if (delta <= 0) {
|
||||
projectionSub.textContent = Math.abs(delta) + ' {{ _("days before deadline") }}';
|
||||
} else {
|
||||
projectionSub.textContent = delta + ' {{ _("days past deadline") }}';
|
||||
}
|
||||
} else {
|
||||
projectionSub.textContent = f.days_to_completion + ' {{ _("days from today") }}';
|
||||
}
|
||||
} else {
|
||||
projectionSub.textContent = '';
|
||||
}
|
||||
} else {
|
||||
projection.textContent = '{{ _("Insufficient data") }}';
|
||||
projectionSub.textContent = (f.total_logged_hours > 0)
|
||||
? '{{ _("No estimated hours set") }}'
|
||||
: '{{ _("No time logged yet") }}';
|
||||
}
|
||||
|
||||
var remaining = $('[data-forecast-remaining]');
|
||||
var remainingSub = $('[data-forecast-remaining-sub]');
|
||||
var hasBudget = (f.budget_hours > 0) || (f.budget_amount > 0);
|
||||
if (!hasBudget) {
|
||||
remaining.textContent = '{{ _("No budget set") }}';
|
||||
remaining.classList.remove('text-amber-600', 'dark:text-amber-400', 'text-red-600', 'dark:text-red-400');
|
||||
remaining.classList.add('text-text-muted-light', 'dark:text-text-muted-dark');
|
||||
remainingSub.textContent = '';
|
||||
} else {
|
||||
var parts = [];
|
||||
if (f.budget_hours > 0) parts.push(fmtHours(f.hours_remaining) + 'h');
|
||||
if (f.budget_amount > 0) parts.push(fmtMoney(f.budget_amount_remaining));
|
||||
remaining.textContent = parts.join(' / ');
|
||||
remaining.classList.remove('text-text-muted-light', 'dark:text-text-muted-dark');
|
||||
if (f.at_risk) {
|
||||
remaining.classList.remove('text-emerald-600', 'dark:text-emerald-400');
|
||||
remaining.classList.add('text-amber-600', 'dark:text-amber-400');
|
||||
remainingSub.className = 'text-xs mt-1 text-amber-700 dark:text-amber-300 font-medium';
|
||||
remainingSub.textContent = '{{ _("Approaching budget limit") }}';
|
||||
} else {
|
||||
remaining.classList.remove('text-amber-600', 'dark:text-amber-400');
|
||||
remaining.classList.add('text-emerald-600', 'dark:text-emerald-400');
|
||||
remainingSub.className = 'text-xs mt-1 text-text-muted-light dark:text-text-muted-dark';
|
||||
remainingSub.textContent = (f.budget_used_percent || 0) + '% {{ _("used") }}';
|
||||
}
|
||||
}
|
||||
|
||||
$('[data-forecast-tasks]').textContent = (f.completed_tasks || 0) + '/' + (f.total_tasks || 0) + ' {{ _("done") }}';
|
||||
var tasksSub = $('[data-forecast-tasks-sub]');
|
||||
if ((f.overdue_tasks || 0) > 0) {
|
||||
tasksSub.className = 'text-xs mt-1 text-red-600 dark:text-red-400 font-medium';
|
||||
tasksSub.textContent = f.overdue_tasks + ' {{ _("overdue") }}';
|
||||
} else {
|
||||
tasksSub.className = 'text-xs mt-1 text-text-muted-light dark:text-text-muted-dark';
|
||||
tasksSub.textContent = (f.task_completion_percent || 0) + '% {{ _("complete") }}';
|
||||
}
|
||||
}
|
||||
|
||||
function renderChart(f) {
|
||||
var canvas = $('[data-forecast-chart]');
|
||||
var emptyLabel = $('[data-forecast-chart-empty]');
|
||||
if (!canvas || typeof Chart === 'undefined') return;
|
||||
|
||||
var daily = Array.isArray(f.daily_hours) ? f.daily_hours : [];
|
||||
var hasAnyHours = daily.some(function (d) { return Number(d.hours) > 0; });
|
||||
emptyLabel.textContent = hasAnyHours ? '' : '{{ _("No entries in the last 14 days") }}';
|
||||
|
||||
var labels = daily.map(function (d) { return fmtDateShort(d.date); });
|
||||
var values = daily.map(function (d) { return Number(d.hours) || 0; });
|
||||
var refValue = Number(f.avg_hours_per_calendar_day) || 0;
|
||||
var refLine = daily.map(function () { return refValue; });
|
||||
|
||||
if (state.chart) {
|
||||
try { state.chart.destroy(); } catch (e) { /* ignore */ }
|
||||
state.chart = null;
|
||||
}
|
||||
|
||||
var ctx = canvas.getContext('2d');
|
||||
state.chart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [
|
||||
{
|
||||
label: '{{ _("Hours") }}',
|
||||
data: values,
|
||||
borderColor: '#3b82f6',
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.12)',
|
||||
borderWidth: 2,
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointRadius: 2,
|
||||
pointHoverRadius: 4,
|
||||
pointBackgroundColor: '#3b82f6',
|
||||
},
|
||||
{
|
||||
label: '{{ _("Avg/day") }}',
|
||||
data: refLine,
|
||||
borderColor: 'rgba(107, 114, 128, 0.7)',
|
||||
borderDash: [4, 4],
|
||||
borderWidth: 1.5,
|
||||
fill: false,
|
||||
tension: 0,
|
||||
pointRadius: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
title: function (items) {
|
||||
if (!items.length) return '';
|
||||
var idx = items[0].dataIndex;
|
||||
var iso = daily[idx] ? daily[idx].date : '';
|
||||
return fmtDateLong(iso);
|
||||
},
|
||||
label: function (item) {
|
||||
if (item.datasetIndex === 1) {
|
||||
return '{{ _("Avg") }}: ' + fmtHours(item.parsed.y) + 'h';
|
||||
}
|
||||
return fmtHours(item.parsed.y) + 'h';
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
display: false,
|
||||
grid: { display: false },
|
||||
},
|
||||
y: {
|
||||
display: false,
|
||||
grid: { color: 'rgba(156, 163, 175, 0.18)' },
|
||||
beginAtZero: true,
|
||||
},
|
||||
},
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function renderEmptyState(f) {
|
||||
if (!f.has_data) {
|
||||
var emptyLabel = $('[data-forecast-chart-empty]');
|
||||
if (emptyLabel) emptyLabel.textContent = '{{ _("No time entries yet") }}';
|
||||
}
|
||||
}
|
||||
|
||||
function renderAll(payload) {
|
||||
if (!payload || !payload.forecast) return;
|
||||
var f = payload.forecast;
|
||||
state.deterministic = f;
|
||||
|
||||
renderHealthBar(f);
|
||||
renderMetrics(f);
|
||||
renderChart(f);
|
||||
renderEmptyState(f);
|
||||
|
||||
// Show AI button if backend reports AI enabled (override template hint).
|
||||
var aiBtn = $('[data-forecast-ai-btn]');
|
||||
if (aiBtn) {
|
||||
var enabled = (typeof payload.ai_enabled === 'boolean')
|
||||
? payload.ai_enabled
|
||||
: aiEnabledInitial;
|
||||
if (enabled) show(aiBtn); else hide(aiBtn);
|
||||
}
|
||||
}
|
||||
|
||||
function renderAi(aiPayload) {
|
||||
var loading = $('[data-forecast-ai-loading]');
|
||||
var errBox = $('[data-forecast-ai-error]');
|
||||
var errText = $('[data-forecast-ai-error-text]');
|
||||
var content = $('[data-forecast-ai-content]');
|
||||
|
||||
hide(loading);
|
||||
|
||||
if (!aiPayload || !aiPayload.ok) {
|
||||
hide(content);
|
||||
var code = aiPayload && aiPayload.error_code;
|
||||
if (code === 'ai_disabled') {
|
||||
errText.textContent = '{{ _("AI insights require the AI helper to be configured in Settings.") }}';
|
||||
} else {
|
||||
errText.textContent = '{{ _("Could not load AI insights. Try again later.") }}';
|
||||
}
|
||||
show(errBox);
|
||||
return;
|
||||
}
|
||||
|
||||
hide(errBox);
|
||||
var narrativeEl = $('[data-forecast-ai-narrative]');
|
||||
narrativeEl.textContent = aiPayload.narrative || '';
|
||||
|
||||
var risksWrap = $('[data-forecast-ai-risks-wrap]');
|
||||
var risksList = $('[data-forecast-ai-risks]');
|
||||
risksList.innerHTML = '';
|
||||
if (Array.isArray(aiPayload.risks) && aiPayload.risks.length) {
|
||||
aiPayload.risks.slice(0, 3).forEach(function (r) {
|
||||
var li = document.createElement('li');
|
||||
li.className = 'flex items-start gap-2';
|
||||
li.innerHTML = '<i class="fas fa-triangle-exclamation text-red-500 mt-0.5"></i><span></span>';
|
||||
li.querySelector('span').textContent = String(r);
|
||||
risksList.appendChild(li);
|
||||
});
|
||||
show(risksWrap);
|
||||
} else {
|
||||
hide(risksWrap);
|
||||
}
|
||||
|
||||
var recsWrap = $('[data-forecast-ai-recs-wrap]');
|
||||
var recsList = $('[data-forecast-ai-recs]');
|
||||
recsList.innerHTML = '';
|
||||
if (Array.isArray(aiPayload.recommendations) && aiPayload.recommendations.length) {
|
||||
aiPayload.recommendations.slice(0, 3).forEach(function (rc) {
|
||||
var li = document.createElement('li');
|
||||
li.className = 'flex items-start gap-2';
|
||||
li.innerHTML = '<i class="fas fa-circle-check text-emerald-500 mt-0.5"></i><span></span>';
|
||||
li.querySelector('span').textContent = String(rc);
|
||||
recsList.appendChild(li);
|
||||
});
|
||||
show(recsWrap);
|
||||
} else {
|
||||
hide(recsWrap);
|
||||
}
|
||||
|
||||
show(content);
|
||||
}
|
||||
|
||||
// ---------- fetch ----------
|
||||
function fetchForecast(refresh) {
|
||||
var skeleton = $('[data-forecast-skeleton]');
|
||||
var errBox = $('[data-forecast-error]');
|
||||
var content = $('[data-forecast-content]');
|
||||
|
||||
hide(errBox);
|
||||
show(skeleton);
|
||||
hide(content);
|
||||
|
||||
var url = endpoint + (refresh ? '?refresh=true' : '');
|
||||
return fetch(url, { headers: { 'Accept': 'application/json' }, credentials: 'same-origin' })
|
||||
.then(function (resp) {
|
||||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
||||
return resp.json();
|
||||
})
|
||||
.then(function (data) {
|
||||
if (!data || data.ok === false) throw new Error((data && data.error) || 'Forecast error');
|
||||
renderAll(data);
|
||||
hide(skeleton);
|
||||
show(content);
|
||||
return data;
|
||||
})
|
||||
.catch(function (err) {
|
||||
hide(skeleton);
|
||||
hide(content);
|
||||
var msg = (err && err.message) || '{{ _("Could not load forecast.") }}';
|
||||
$('[data-forecast-error-text]').textContent = msg;
|
||||
show(errBox);
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
function fetchAi(refresh) {
|
||||
var loading = $('[data-forecast-ai-loading]');
|
||||
var errBox = $('[data-forecast-ai-error]');
|
||||
var content = $('[data-forecast-ai-content]');
|
||||
hide(errBox);
|
||||
hide(content);
|
||||
show(loading);
|
||||
|
||||
var url = endpoint + '?ai=true' + (refresh ? '&refresh=true' : '');
|
||||
return fetch(url, { headers: { 'Accept': 'application/json' }, credentials: 'same-origin' })
|
||||
.then(function (resp) {
|
||||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
||||
return resp.json();
|
||||
})
|
||||
.then(function (data) {
|
||||
if (data && data.forecast) {
|
||||
renderAll(data);
|
||||
}
|
||||
state.ai = data && data.ai;
|
||||
renderAi(state.ai);
|
||||
return data;
|
||||
})
|
||||
.catch(function () {
|
||||
renderAi({ ok: false, error_code: 'ai_error' });
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- events ----------
|
||||
var aiBtn = $('[data-forecast-ai-btn]');
|
||||
if (aiBtn) {
|
||||
aiBtn.addEventListener('click', function () {
|
||||
var panel = $('[data-forecast-ai-panel]');
|
||||
show(panel);
|
||||
hide(aiBtn);
|
||||
state.aiPanelOpen = true;
|
||||
if (state.ai && state.ai.ok) {
|
||||
renderAi(state.ai);
|
||||
} else {
|
||||
fetchAi(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
var refreshBtn = $('[data-forecast-refresh-btn]');
|
||||
if (refreshBtn) {
|
||||
refreshBtn.addEventListener('click', function () {
|
||||
refreshBtn.classList.add('opacity-60', 'pointer-events-none');
|
||||
refreshBtn.querySelector('i').classList.add('animate-spin');
|
||||
state.ai = null;
|
||||
fetchForecast(true)
|
||||
.then(function () {
|
||||
if (state.aiPanelOpen) {
|
||||
return fetchAi(true);
|
||||
}
|
||||
})
|
||||
.finally(function () {
|
||||
refreshBtn.classList.remove('opacity-60', 'pointer-events-none');
|
||||
refreshBtn.querySelector('i').classList.remove('animate-spin');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
var retryBtn = $('[data-forecast-retry-btn]');
|
||||
if (retryBtn) {
|
||||
retryBtn.addEventListener('click', function () { fetchForecast(false); });
|
||||
}
|
||||
|
||||
// ---------- init ----------
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', function () { fetchForecast(false); });
|
||||
} else {
|
||||
fetchForecast(false);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
@@ -490,6 +490,11 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if project.status == 'active' and (project.estimated_hours or project.budget_amount) %}
|
||||
{% include 'components/project_forecast.html' %}
|
||||
{% endif %}
|
||||
|
||||
{% if current_user.is_admin or has_permission('delete_projects') %}
|
||||
{{ confirm_dialog(
|
||||
'confirmDeleteProject-' ~ project.id,
|
||||
|
||||
@@ -134,8 +134,77 @@ Detailed view for a specific project including:
|
||||
- Resource allocation table
|
||||
- Project-specific alerts
|
||||
|
||||
### Project Forecast Panel (project detail page)
|
||||
|
||||
Active projects with **estimated hours** and/or a **budget amount** show a **Project forecast** card on the project detail page (`/projects/<id>`). The panel loads data via JavaScript from the forecast API and does not require a page reload.
|
||||
|
||||
**Sections:**
|
||||
|
||||
1. **Health bar** — logged hours (green), projected hours to completion (amber), and over-budget hours (red), with stat pills for budget used %, days to completion, and deadline risk (`on_track` / `at_risk` / `overdue` / `no_data`).
|
||||
2. **Metrics grid** — velocity (h/day and burn-rate trend), projected completion date, budget remaining (hours and currency), and task completion (including overdue count).
|
||||
3. **Burn chart** — Chart.js line chart of daily hours for the last 14 days, with a dashed reference line at average hours per calendar day.
|
||||
4. **AI insights** (optional) — when the AI helper is enabled in Settings, users can open a narrative summary, up to three risks, and up to three recommendations generated by `LLMService` from deterministic forecast context.
|
||||
|
||||
Use **Refresh** to bypass the server-side cache (10 minutes). If AI is disabled, the AI insights control is hidden.
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### GET `/api/projects/<project_id>/forecast`
|
||||
|
||||
Return a deterministic project forecast (velocity, budget projection, timeline, tasks, burn rate, and 14-day `daily_hours` for charts). Optional AI narrative when `ai=true`.
|
||||
|
||||
**Authentication:** Session login (`@login_required`). User must have access to the project (`user_can_access_project`).
|
||||
|
||||
**Query parameters:**
|
||||
|
||||
| Parameter | Default | Description |
|
||||
|-----------|---------|-------------|
|
||||
| `ai` | `false` | If true, include LLM-generated `narrative`, `risks`, and `recommendations` (requires AI helper enabled). |
|
||||
| `refresh` | `false` | If true, bypass the in-memory per-project cache (TTL 10 minutes). |
|
||||
|
||||
**Response (deterministic):**
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"project_id": 123,
|
||||
"ai_enabled": true,
|
||||
"forecast": {
|
||||
"has_data": true,
|
||||
"total_logged_hours": 42.5,
|
||||
"velocity_hours_per_day": 3.2,
|
||||
"budget_hours": 80.0,
|
||||
"budget_used_percent": 53,
|
||||
"hours_remaining": 37.5,
|
||||
"days_to_completion": 12,
|
||||
"projected_completion_date": "2026-05-27",
|
||||
"deadline_risk": "on_track",
|
||||
"burn_rate_trend": "increasing",
|
||||
"daily_hours": [{"date": "2026-05-01", "hours": 4.0}],
|
||||
"total_tasks": 10,
|
||||
"completed_tasks": 6,
|
||||
"overdue_tasks": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response (`ai=true`, success):** Same as above, plus:
|
||||
|
||||
```json
|
||||
{
|
||||
"ai": {
|
||||
"ok": true,
|
||||
"narrative": "…",
|
||||
"risks": ["…"],
|
||||
"recommendations": ["…"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response (`ai=true`, AI disabled):** `ai.ok` is false with `error_code` `ai_disabled`; deterministic `forecast` is still returned.
|
||||
|
||||
**Implementation:** `app/services/forecast_service.py` (`ForecastService.get_deterministic_forecast`, `ForecastService.get_ai_forecast`).
|
||||
|
||||
### GET `/budget/dashboard`
|
||||
Display the main budget dashboard page
|
||||
|
||||
@@ -507,7 +576,7 @@ Potential future improvements:
|
||||
|
||||
1. **Email Notifications**: Send email alerts when budget thresholds are exceeded
|
||||
2. **Custom Alert Thresholds**: Allow multiple custom thresholds per project
|
||||
3. **Budget Forecasting AI**: Use machine learning to improve completion date predictions
|
||||
3. **Enhanced forecast models**: Refine completion-date confidence and support optional project deadlines on the `Project` model
|
||||
4. **Budget Templates**: Create reusable budget templates for similar projects
|
||||
5. **Multi-Currency Support**: Handle projects with different currencies
|
||||
6. **Budget Revisions**: Track budget changes and revisions over time
|
||||
|
||||
@@ -59,6 +59,10 @@ The Project Dashboard provides a comprehensive, visual overview of project perfo
|
||||
- **Project Name & Code**: Clear project identification
|
||||
- **Period Filter**: Dropdown to select time period
|
||||
|
||||
### Project forecast (project detail page)
|
||||
|
||||
For **active** projects with estimated hours and/or a budget amount, the project detail page includes a **Project forecast** panel (see [Budget Alerts & Forecasting](../BUDGET_ALERTS_AND_FORECASTING.md#project-forecast-panel-project-detail-page)). It shows velocity, budget burn, projected completion, a 14-day hours chart, and optional AI insights via `GET /api/projects/<id>/forecast`.
|
||||
|
||||
### Metrics Cards (4 Cards)
|
||||
1. **Total Hours Card**
|
||||
- Large number display of total hours
|
||||
|
||||
Reference in New Issue
Block a user