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:
Dries Peeters
2026-05-15 08:56:15 +02:00
parent 7840c59fb9
commit 9e05f26fb9
6 changed files with 1377 additions and 1 deletions
+109
View File
@@ -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():
+517
View File
@@ -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 ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[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>
+5
View File
@@ -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,
+70 -1
View File
@@ -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
+4
View File
@@ -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