"""
FocusDesk Performance Report Generator
=======================================
Takes your app's JSON data, sends it to Groq LLM for intelligent analysis,
then builds a professional PDF report with charts.
Usage:
python report_generator.py # uses sample_data.json
python report_generator.py your_data.json # uses your own JSON file
Requirements:
pip install groq reportlab matplotlib
"""
import json
import sys
import os
import io
from datetime import datetime, timedelta
from collections import defaultdict
# ── Charts ──────────────────────────────────────────────────────────────────
import matplotlib
matplotlib.use('Agg') # non-interactive backend (no display needed)
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
import numpy as np
# ── AI ───────────────────────────────────────────────────────────────────────
from groq import Groq
# ── PDF ───────────────────────────────────────────────────────────────────────
from reportlab.lib.pagesizes import A4
from reportlab.lib import colors
from reportlab.lib.units import cm
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.enums import TA_LEFT, TA_CENTER, TA_JUSTIFY
from reportlab.platypus import (
SimpleDocTemplate, Paragraph, Spacer, Image,
Table, TableStyle, HRFlowable, PageBreak
)
# =============================================================================
# CONFIGURATION — paste your Groq API key here
# =============================================================================
GROQ_API_KEY = os.environ.get("GROQ_API_KEY")
GROQ_MODEL = "llama-3.3-70b-versatile" # free tier, very capable
# =============================================================================
# SECTION 1 — DATA LOADING & VALIDATION
# =============================================================================
def load_json(path: str) -> dict:
"""
Loads and validates the JSON file.
Checks that all required top-level keys exist.
Exits with a clear error if the format is wrong.
"""
with open(path, 'r', encoding='utf-8') as f:
data = json.load(f)
required = ["user_profile", "strategy", "today_plan", "history"]
missing = [k for k in required if k not in data]
if missing:
print(f"[ERROR] JSON is missing required keys: {missing}")
print("Your JSON must match the FocusDesk app format exactly.")
sys.exit(1)
return data
# =============================================================================
# SECTION 2 — METRICS CALCULATION
# All the maths happens here. The LLM gets these numbers as context.
# =============================================================================
def calculate_metrics(data: dict) -> dict:
"""
Derives all quantitative metrics from the raw JSON.
Returns a dict with:
- completion rates per day
- overall success rate
- best/worst days of week
- task frequency (which tasks appear most)
- failure pattern analysis
- goal proximity score (0-100, how close to long-term goal)
"""
history = data.get("history", [])
profile = data.get("user_profile", {})
strategy = data.get("strategy", {})
# ── Basic counts ──────────────────────────────────────────────────────────
total_days = len(history)
completed_days = sum(1 for d in history if d.get("status") == "Completed")
failed_days = total_days - completed_days
overall_rate = round((completed_days / total_days * 100), 1) if total_days else 0
# ── Per-day completion rate (for bar chart) ───────────────────────────────
daily_rates = []
for record in history:
total_g = len(record.get("total_goals", []))
completed_g = len(record.get("completed_goals", []))
rate = round((completed_g / total_g * 100), 1) if total_g else 0
daily_rates.append({
"date": record["date"],
"rate": rate,
"status": record.get("status", ""),
})
# Sort oldest → newest for the chart
daily_rates.sort(key=lambda x: x["date"])
# ── Day-of-week performance ───────────────────────────────────────────────
dow_counts = defaultdict(list) # day_name → [completion_rates]
for record in history:
try:
dt = datetime.strptime(record["date"], "%Y-%m-%d")
day = dt.strftime("%A")
total_g = len(record.get("total_goals", []))
completed_g = len(record.get("completed_goals", []))
if total_g:
dow_counts[day].append(completed_g / total_g * 100)
except Exception:
pass
dow_avg = {day: round(sum(rates) / len(rates), 1)
for day, rates in dow_counts.items() if rates}
best_day = max(dow_avg, key=dow_avg.get) if dow_avg else "N/A"
worst_day = min(dow_avg, key=dow_avg.get) if dow_avg else "N/A"
# ── Task frequency — which tasks appear most across all days ─────────────
task_freq = defaultdict(int)
task_done = defaultdict(int)
for record in history:
for t in record.get("total_goals", []):
task_freq[t] += 1
for t in record.get("completed_goals", []):
task_done[t] += 1
# Top 5 most attempted tasks with their completion rates
top_tasks = []
for task, freq in sorted(task_freq.items(), key=lambda x: -x[1])[:5]:
done_rate = round(task_done[task] / freq * 100, 1)
top_tasks.append({"task": task, "attempts": freq, "completion_rate": done_rate})
# ── Failure reasons ───────────────────────────────────────────────────────
failure_reasons = [
{"date": r["date"], "reason": r.get("reason", "N/A"),
"incomplete": r.get("incomplete_goals", [])}
for r in history
if r.get("status") == "Incomplete" and r.get("reason", "N/A") != "N/A"
]
# ── Streak data ───────────────────────────────────────────────────────────
current_streak = profile.get("current_streak", 0)
longest_streak = profile.get("longest_streak", 0)
# ── Consistency score (weighted: recent days matter more) ─────────────────
# Last 7 days get weight 2, earlier days get weight 1
recent = [d for d in daily_rates[-7:]]
older = [d for d in daily_rates[:-7]]
weighted_sum = sum(d["rate"] * 2 for d in recent) + sum(d["rate"] for d in older)
weighted_total = (len(recent) * 2) + len(older)
consistency_score = round(weighted_sum / weighted_total, 1) if weighted_total else 0
# ── Goal proximity score ──────────────────────────────────────────────────
# A composite 0-100 score estimating how aligned current behaviour is
# with the stated long-term goal.
#
# Formula:
# 40% — overall completion rate (are they doing the work?)
# 30% — consistency score (is the work recent and sustained?)
# 20% — streak factor (capped at 14 days = full marks)
# 10% — task relevance (do tasks mention goal keywords?)
#
streak_factor = min(current_streak / 14 * 100, 100)
# Simple keyword match between tasks and long-term goal
goal_keywords = set(strategy.get("long_term", "").lower().split())
stop_words = {"a", "an", "the", "and", "or", "to", "in", "on", "at", "for",
"of", "with", "my", "i", "is", "be", "by", "as", "up"}
goal_keywords -= stop_words
all_tasks_text = " ".join(
t for r in history for t in r.get("total_goals", [])
).lower()
keyword_hits = sum(1 for kw in goal_keywords if kw in all_tasks_text)
relevance_pct = min(keyword_hits / max(len(goal_keywords), 1) * 100, 100)
goal_proximity = round(
(overall_rate * 0.40) +
(consistency_score * 0.30) +
(streak_factor * 0.20) +
(relevance_pct * 0.10),
1
)
return {
"total_days": total_days,
"completed_days": completed_days,
"failed_days": failed_days,
"overall_rate": overall_rate,
"daily_rates": daily_rates,
"dow_avg": dow_avg,
"best_day": best_day,
"worst_day": worst_day,
"top_tasks": top_tasks,
"failure_reasons": failure_reasons,
"current_streak": current_streak,
"longest_streak": longest_streak,
"consistency_score": consistency_score,
"goal_proximity": goal_proximity,
"streak_factor": round(streak_factor, 1),
"relevance_pct": round(relevance_pct, 1),
}
# =============================================================================
# SECTION 3 — GROQ LLM ANALYSIS
# =============================================================================
def get_ai_analysis(data: dict, metrics: dict) -> dict:
"""
Sends the user's full context + calculated metrics to Groq.
The prompt is engineered to produce:
1. Executive summary (2-3 sentences)
2. Goal proximity analysis — how close to the long-term goal
3. Behavioural patterns — what the numbers actually reveal
4. Action recommendations — ONLY if genuinely needed
5. One motivational closing line
Returns a dict with each section as a string.
"""
raw_key = os.environ.get("GROQ_API_KEY", "")
clean_key = raw_key.replace('"', '').replace("'", "").strip()
client = Groq(api_key=clean_key)
profile = data["user_profile"]
strategy = data["strategy"]
prompt = f"""
You are a professional performance analyst generating a report for {profile['name']}.
Your job is NOT to summarise what they did — they already know that.
Your job is to analyse the DATA and tell them what it MEANS for their future.
=== USER CONTEXT ===
Name: {profile['name']}
Current Streak: {metrics['current_streak']} days
Longest Streak: {metrics['longest_streak']} days
Observation Period: {metrics['total_days']} days
30-Day Goal: {strategy['30_day']}
60-Day Goal: {strategy['60_day']}
Long-Term Goal: {strategy['long_term']}
=== CALCULATED METRICS ===
Overall Task Completion Rate: {metrics['overall_rate']}%
Consistency Score (recency-weighted): {metrics['consistency_score']}%
Goal Proximity Score: {metrics['goal_proximity']}/100
- Completion contribution: {metrics['overall_rate']}% (weight 40%)
- Consistency contribution: {metrics['consistency_score']}% (weight 30%)
- Streak contribution: {metrics['streak_factor']}% (weight 20%)
- Task-to-goal relevance: {metrics['relevance_pct']}% (weight 10%)
Best performing day of week: {metrics['best_day']}
Worst performing day of week: {metrics['worst_day']}
Top 5 tasks by frequency:
{json.dumps(metrics['top_tasks'], indent=2)}
Failure incidents ({metrics['failed_days']} days):
{json.dumps(metrics['failure_reasons'], indent=2)}
=== YOUR TASK ===
Write the following sections. Be direct, specific, and data-driven.
Do NOT pad with generic advice. Every sentence must be grounded in the numbers above.
SECTION 1 - EXECUTIVE SUMMARY (exactly 3 sentences):
Synthesise the performance in a way that tells the person WHERE they stand right now.
Reference specific numbers. Do not be vague.
SECTION 2 - GOAL PROXIMITY ANALYSIS (3-4 sentences):
Based on the Goal Proximity Score of {metrics['goal_proximity']}/100, analyse:
- At this pace, is {profile['name']} on track for the 30-day goal? The 60-day goal? The long-term goal?
- Be mathematically honest — if {metrics['goal_proximity']} < 60, say it plainly.
- If > 80, acknowledge it and specify what would maintain it.
SECTION 3 - BEHAVIOURAL PATTERNS (3-4 sentences):
What do the failure reasons and day-of-week data reveal about this person's real patterns?
Do NOT just list the failures — interpret them. What is the underlying issue?
SECTION 4 - RECOMMENDATIONS:
CRITICAL RULE: Only include this section if the data genuinely shows a gap.
If Goal Proximity Score > 80 AND consistency > 80%, write: "NO_RECOMMENDATIONS_NEEDED"
Otherwise, give exactly 2-3 specific, actionable recommendations tied directly to the failure data.
No generic advice. Each recommendation must reference a specific pattern from the data.
SECTION 5 - CLOSING (exactly 1 sentence):
One honest, motivating sentence that reflects their actual score — not false hype.
Format your response EXACTLY like this (use these exact labels):
EXECUTIVE_SUMMARY: [your text]
GOAL_PROXIMITY_ANALYSIS: [your text]
BEHAVIOURAL_PATTERNS: [your text]
RECOMMENDATIONS: [your text or NO_RECOMMENDATIONS_NEEDED]
CLOSING: [your text]
"""
print(" Sending data to Groq LLM...")
response = client.chat.completions.create(
model=GROQ_MODEL,
messages=[{"role": "user", "content": prompt}],
temperature=0.4, # low temp = more precise, less hallucination
max_tokens=1200,
)
raw = response.choices[0].message.content.strip()
# ── Parse the labelled response into sections ─────────────────────────────
sections = {}
labels = ["EXECUTIVE_SUMMARY", "GOAL_PROXIMITY_ANALYSIS",
"BEHAVIOURAL_PATTERNS", "RECOMMENDATIONS", "CLOSING"]
for i, label in enumerate(labels):
start_tag = f"{label}:"
start_idx = raw.find(start_tag)
if start_idx == -1:
sections[label] = ""
continue
start_idx += len(start_tag)
# end is start of the next label, or end of string
end_idx = len(raw)
for next_label in labels[i + 1:]:
ni = raw.find(f"{next_label}:", start_idx)
if ni != -1:
end_idx = ni
break
sections[label] = raw[start_idx:end_idx].strip()
return sections
# =============================================================================
# SECTION 4 — CHART GENERATION
# Each chart is saved to a BytesIO buffer so no temp files are needed.
# =============================================================================
# ── Shared style constants ─────────────────────────────────────────────────
CHART_BG = "#0D0D0D"
CHART_FG = "#FFFFFF"
COLOR_GREEN = "#00C896"
COLOR_RED = "#FF4D6D"
COLOR_AMBER = "#FFB347"
COLOR_BLUE = "#4DA8FF"
COLOR_GRAY = "#888888"
def _fig_to_bytes(fig) -> io.BytesIO:
buf = io.BytesIO()
fig.savefig(buf, format='png', dpi=150, bbox_inches='tight',
facecolor=CHART_BG)
buf.seek(0)
plt.close(fig)
return buf
def chart_daily_completion(daily_rates: list) -> io.BytesIO:
"""
Bar chart: daily task completion % over the history period.
Green bars = 100%, amber = partial, red = below 50%.
"""
dates = [d["date"][-5:] for d in daily_rates] # MM-DD
rates = [d["rate"] for d in daily_rates]
bar_colors = [
COLOR_GREEN if r == 100 else
COLOR_AMBER if r >= 50 else
COLOR_RED
for r in rates
]
fig, ax = plt.subplots(figsize=(10, 4), facecolor=CHART_BG)
ax.set_facecolor(CHART_BG)
bars = ax.bar(dates, rates, color=bar_colors, width=0.6, zorder=3)
ax.axhline(y=80, color=COLOR_GRAY, linestyle='--', linewidth=0.8,
alpha=0.6, label='80% target line')
# Value labels on bars
for bar, rate in zip(bars, rates):
if rate > 0:
ax.text(bar.get_x() + bar.get_width() / 2,
bar.get_height() + 1.5,
f"{int(rate)}%",
ha='center', va='bottom',
color=CHART_FG, fontsize=7.5, fontweight='bold')
ax.set_ylim(0, 115)
ax.set_xlabel("Date", color=CHART_FG, fontsize=10, labelpad=8)
ax.set_ylabel("Completion %", color=CHART_FG, fontsize=10, labelpad=8)
ax.set_title("Daily Task Completion Rate", color=CHART_FG,
fontsize=13, fontweight='bold', pad=12)
ax.tick_params(axis='x', colors=CHART_FG, labelsize=8, rotation=45)
ax.tick_params(axis='y', colors=CHART_FG, labelsize=9)
for spine in ax.spines.values():
spine.set_edgecolor("#333333")
ax.yaxis.grid(True, color="#1E1E1E", linewidth=0.8, zorder=0)
ax.set_axisbelow(True)
legend_patches = [
mpatches.Patch(color=COLOR_GREEN, label='100% complete'),
mpatches.Patch(color=COLOR_AMBER, label='50-99%'),
mpatches.Patch(color=COLOR_RED, label='Below 50%'),
]
ax.legend(handles=legend_patches, facecolor="#1A1A1A",
edgecolor="#333333", labelcolor=CHART_FG, fontsize=8,
loc='upper left')
fig.tight_layout()
return _fig_to_bytes(fig)
def chart_goal_proximity_gauge(score: float) -> io.BytesIO:
"""
A horizontal gauge bar showing the Goal Proximity Score.
Visually communicates "how close are you" at a glance.
"""
fig, ax = plt.subplots(figsize=(8, 2.2), facecolor=CHART_BG)
ax.set_facecolor(CHART_BG)
# Background track
ax.barh(0, 100, height=0.5, color="#1E1E1E", zorder=1)
# Score fill — colour depends on level
fill_color = (COLOR_GREEN if score >= 75 else
COLOR_AMBER if score >= 50 else COLOR_RED)
ax.barh(0, score, height=0.5, color=fill_color, zorder=2)
# Score label in center
ax.text(score / 2, 0, f"{score:.1f}",
ha='center', va='center',
color=CHART_BG, fontsize=18, fontweight='bold', zorder=3)
# Zone markers
for x, label in [(50, "50"), (75, "75"), (100, "100")]:
ax.axvline(x=x, color="#444444", linewidth=1, zorder=4)
ax.text(x, -0.45, label, ha='center', va='top',
color=COLOR_GRAY, fontsize=8)
ax.text(25, 0.42, "At Risk", ha='center', color=COLOR_RED, fontsize=8)
ax.text(62, 0.42, "On Track", ha='center', color=COLOR_AMBER, fontsize=8)
ax.text(87, 0.42, "Excellent", ha='center', color=COLOR_GREEN, fontsize=8)
ax.set_xlim(0, 100)
ax.set_ylim(-0.6, 0.7)
ax.axis('off')
ax.set_title("Goal Proximity Score (0 = no alignment | 100 = perfect alignment)",
color=CHART_FG, fontsize=10, pad=10)
fig.tight_layout()
return _fig_to_bytes(fig)
def chart_dow_performance(dow_avg: dict) -> io.BytesIO:
"""
Horizontal bar chart: average completion rate by day of week.
Immediately shows which day the user consistently struggles.
"""
day_order = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
days = [d for d in day_order if d in dow_avg]
rates = [dow_avg[d] for d in days]
fig, ax = plt.subplots(figsize=(8, max(3, len(days) * 0.6 + 1)),
facecolor=CHART_BG)
ax.set_facecolor(CHART_BG)
bar_colors = [COLOR_GREEN if r >= 80 else
COLOR_AMBER if r >= 50 else COLOR_RED
for r in rates]
bars = ax.barh(days, rates, color=bar_colors, height=0.5, zorder=3)
for bar, rate in zip(bars, rates):
ax.text(bar.get_width() + 1.5, bar.get_y() + bar.get_height() / 2,
f"{rate:.0f}%", va='center', color=CHART_FG,
fontsize=9, fontweight='bold')
ax.axvline(x=80, color=COLOR_GRAY, linestyle='--',
linewidth=0.8, alpha=0.6)
ax.set_xlim(0, 115)
ax.set_xlabel("Avg Completion %", color=CHART_FG, fontsize=10, labelpad=8)
ax.set_title("Performance by Day of Week", color=CHART_FG,
fontsize=13, fontweight='bold', pad=12)
ax.tick_params(axis='x', colors=CHART_FG, labelsize=9)
ax.tick_params(axis='y', colors=CHART_FG, labelsize=10)
for spine in ax.spines.values():
spine.set_edgecolor("#333333")
ax.xaxis.grid(True, color="#1E1E1E", linewidth=0.8, zorder=0)
ax.set_axisbelow(True)
fig.tight_layout()
return _fig_to_bytes(fig)
def chart_score_breakdown(metrics: dict) -> io.BytesIO:
"""
Radar / spider chart showing the four components of Goal Proximity Score.
Gives a visual breakdown of WHERE the score comes from.
"""
categories = ['Task\nCompletion', 'Consistency\nScore',
'Streak\nFactor', 'Task\nRelevance']
values = [
metrics['overall_rate'],
metrics['consistency_score'],
metrics['streak_factor'],
metrics['relevance_pct'],
]
N = len(categories)
angles = [n / float(N) * 2 * np.pi for n in range(N)]
angles += angles[:1] # close the loop
vals = [v / 100 for v in values]
vals += vals[:1]
fig, ax = plt.subplots(figsize=(5, 5), subplot_kw=dict(polar=True),
facecolor=CHART_BG)
ax.set_facecolor(CHART_BG)
fig.patch.set_facecolor(CHART_BG)
ax.plot(angles, vals, color=COLOR_BLUE, linewidth=2)
ax.fill(angles, vals, color=COLOR_BLUE, alpha=0.25)
ax.set_xticks(angles[:-1])
ax.set_xticklabels(categories, color=CHART_FG, fontsize=9)
ax.set_ylim(0, 1)
ax.set_yticks([0.25, 0.5, 0.75, 1.0])
ax.set_yticklabels(["25%", "50%", "75%", "100%"],
color=COLOR_GRAY, fontsize=7)
ax.grid(color="#333333", linewidth=0.8)
ax.spines['polar'].set_color("#333333")
ax.set_title("Goal Proximity Score Breakdown",
color=CHART_FG, fontsize=11, fontweight='bold', pad=20)
# Add value labels at each vertex
for angle, val, raw_val in zip(angles[:-1], vals[:-1], values):
ax.text(angle, val + 0.08, f"{raw_val:.0f}%",
ha='center', va='center', color=CHART_FG,
fontsize=8, fontweight='bold')
fig.tight_layout()
return _fig_to_bytes(fig)
# =============================================================================
# SECTION 5 — PDF BUILDER
# =============================================================================
# ── Colour palette ─────────────────────────────────────────────────────────
PDF_BG = colors.HexColor("#0D0D0D")
PDF_WHITE = colors.HexColor("#FFFFFF")
PDF_ACCENT = colors.HexColor("#00C896")
PDF_SUBTEXT = colors.HexColor("#AAAAAA")
PDF_CARD_BG = colors.HexColor("#1A1A1A")
PDF_RED = colors.HexColor("#FF4D6D")
PDF_AMBER = colors.HexColor("#FFB347")
def _styles():
"""Returns a dict of all custom paragraph styles used in the PDF."""
base = getSampleStyleSheet()
return {
# --- NEW STYLE FOR FOCUSDESK BRANDING ---
"app_brand": ParagraphStyle(
"app_brand",
fontName="Helvetica-BoldOblique", # Matches FontWeight.w900 & FontStyle.italic
fontSize=26,
textColor=colors.HexColor("#18FFFF"), # Your exact Cyan Accent
spaceAfter=14,
alignment=TA_LEFT,
),
"title": ParagraphStyle(
"title",
fontName="Helvetica-Bold",
fontSize=20,
leading=34, # <-- FIX: This prevents the overlapping!
textColor=PDF_WHITE,
spaceAfter=1, # Added a bit more breathing room
alignment=TA_LEFT,
),
"subtitle": ParagraphStyle(
"subtitle",
fontName="Helvetica",
fontSize=12,
textColor=PDF_ACCENT,
spaceAfter=6,
alignment=TA_LEFT,
),
"meta": ParagraphStyle(
"meta",
fontName="Helvetica",
fontSize=9,
leading=14, # <-- FIX: Prevents overlap if the goal wraps to 2 lines
textColor=PDF_SUBTEXT,
spaceAfter=2,
),
"section_heading": ParagraphStyle(
"section_heading",
fontName="Helvetica-Bold",
fontSize=14,
textColor=PDF_ACCENT,
spaceBefore=18,
spaceAfter=6,
borderPad=0,
),
"body": ParagraphStyle(
"body",
fontName="Helvetica",
fontSize=10,
textColor=PDF_WHITE,
leading=16,
spaceAfter=8,
alignment=TA_JUSTIFY,
),
"body_gray": ParagraphStyle(
"body_gray",
fontName="Helvetica",
fontSize=10,
textColor=PDF_SUBTEXT,
leading=16,
spaceAfter=8,
alignment=TA_JUSTIFY,
),
"bullet": ParagraphStyle(
"bullet",
fontName="Helvetica",
fontSize=10,
textColor=PDF_WHITE,
leading=15,
leftIndent=14,
spaceAfter=4,
),
"stat_label": ParagraphStyle(
"stat_label",
fontName="Helvetica",
fontSize=8,
textColor=PDF_SUBTEXT,
alignment=TA_CENTER,
spaceAfter=2,
),
"stat_value": ParagraphStyle(
"stat_value",
fontName="Helvetica-Bold",
fontSize=20,
textColor=PDF_WHITE,
alignment=TA_CENTER,
spaceAfter=0,
),
"stat_unit": ParagraphStyle(
"stat_unit",
fontName="Helvetica",
fontSize=9,
textColor=PDF_ACCENT,
alignment=TA_CENTER,
),
"caption": ParagraphStyle(
"caption",
fontName="Helvetica-Oblique",
fontSize=8,
textColor=PDF_SUBTEXT,
alignment=TA_CENTER,
spaceAfter=12,
),
"closing": ParagraphStyle(
"closing",
fontName="Helvetica-BoldOblique",
fontSize=12,
textColor=PDF_ACCENT,
leading=18,
alignment=TA_CENTER,
spaceBefore=16,
spaceAfter=16,
),
"recommendation": ParagraphStyle(
"recommendation",
fontName="Helvetica",
fontSize=10,
textColor=PDF_WHITE,
leading=15,
leftIndent=12,
spaceBefore=4,
spaceAfter=6,
borderPad=6,
),
}
def _divider():
return HRFlowable(width="100%", thickness=0.5,
color=colors.HexColor("#333333"), spaceAfter=10)
# def _stat_card(label: str, value: str, unit: str, s: dict) -> Table:
# """
# A small 3-row table that renders as a stat card:
# LABEL
# VALUE
# unit
# Used in the summary stat row.
# """
# cell = [
# [Paragraph(label, s["stat_label"])],
# [Paragraph(value, s["stat_value"])],
# [Paragraph(unit, s["stat_unit"])],
# ]
# t = Table(cell, colWidths=[3.8 * cm])
# t.setStyle(TableStyle([
# ('BACKGROUND', (0, 0), (-1, -1), PDF_CARD_BG),
# ('ROUNDEDCORNERS', [6]),
# ('TOPPADDING', (0, 0), (-1, -1), 10),
# ('BOTTOMPADDING', (0, 0), (-1, -1), 10),
# ('LEFTPADDING', (0, 0), (-1, -1), 8),
# ('RIGHTPADDING', (0, 0), (-1, -1), 8),
# ('ALIGN', (0, 0), (-1, -1), 'CENTER'),
# ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
# ('LINEBELOW', (0, 0), (-1, 0), 0.5, colors.HexColor("#333333")),
# ]))
# return t
def build_pdf(data: dict, metrics: dict, ai_sections: dict,
chart_daily, chart_gauge, chart_dow, chart_radar,
output_path: str):
"""
Assembles the complete PDF from all sections and charts.
"""
s = _styles()
doc = SimpleDocTemplate(
output_path,
pagesize=A4,
leftMargin=2 * cm, rightMargin=2 * cm,
topMargin=2 * cm, bottomMargin=2 * cm,
)
profile = data["user_profile"]
strategy = data["strategy"]
story = []
W = A4[0] - 4 * cm # usable width
# ── Page 1: Cover / Header ────────────────────────────────────────────────
story.append(Spacer(1, 0.1 * cm))
# Using spaces to mimic Flutter's letterSpacing: 3.0
story.append(Paragraph("FocusDesk", s["app_brand"]))
story.append(Paragraph(f"Performance Report — {profile['name']}", s["title"]))
story.append(Spacer(1, 0.3 * cm))
report_date = datetime.now().strftime("%B %d, %Y")
story.append(Spacer(1, 0.4 * cm))
story.append(_divider())
# ── Stat cards row ────────────────────────────────────────────────────────
# ── Stat cards row ────────────────────────────────────────────────────────
card_data = [
# Row 1: Labels (Forced to 2 lines for perfect uniform height)
[
Paragraph("OVERALL
COMPLETION", s["stat_label"]),
Paragraph("CURRENT
STREAK", s["stat_label"]),
Paragraph("LONGEST
STREAK", s["stat_label"]),
Paragraph("CONSISTENCY
SCORE", s["stat_label"]),
Paragraph("GOAL
PROXIMITY", s["stat_label"])
],
# Row 2: Values
[
Paragraph(f"{metrics['overall_rate']}", s["stat_value"]),
Paragraph(f"{metrics['current_streak']}", s["stat_value"]),
Paragraph(f"{metrics['longest_streak']}", s["stat_value"]),
Paragraph(f"{metrics['consistency_score']}", s["stat_value"]),
Paragraph(f"{metrics['goal_proximity']}", s["stat_value"])
],
# Row 3: Units
[
Paragraph("%", s["stat_unit"]),
Paragraph("days", s["stat_unit"]),
Paragraph("days", s["stat_unit"]),
Paragraph("%", s["stat_unit"]),
Paragraph("/ 100", s["stat_unit"])
]
]
# Create one unified table spanning the width
cards = Table(card_data, colWidths=[W / 5] * 5)
cards.setStyle(TableStyle([
# Unified background block
('BACKGROUND', (0, 0), (-1, -1), PDF_CARD_BG),
('ROUNDEDCORNERS', [6]),
# Center alignment for everything
('ALIGN', (0, 0), (-1, -1), 'CENTER'),
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
# Padding to give it breathing room
('TOPPADDING', (0, 0), (-1, -1), 12),
('BOTTOMPADDING', (0, 0), (-1, -1), 10),
# A single, clean horizontal line under the labels
('LINEBELOW', (0, 0), (-1, 0), 0.5, colors.HexColor("#333333")),
]))
story.append(cards)
story.append(Spacer(1, 0.5 * cm))
# ── Goal Proximity Gauge ──────────────────────────────────────────────────
story.append(Paragraph("Goal Proximity Score", s["section_heading"]))
gauge_img = Image(chart_gauge, width=W, height=5.5 * cm)
story.append(gauge_img)
story.append(Paragraph(
"A composite score (0–100) measuring how closely current behaviour aligns "
"with the stated long-term goal. Weighted across: completion rate (40%), "
"consistency (30%), streak (20%), task relevance (10%).",
s["caption"]
))
# ── Executive Summary ─────────────────────────────────────────────────────
story.append(Paragraph("Executive Summary", s["section_heading"]))
story.append(_divider())
for para in ai_sections.get("EXECUTIVE_SUMMARY", "").split("\n"):
if para.strip():
story.append(Paragraph(para.strip(), s["body"]))
# ── Goal Proximity Analysis ───────────────────────────────────────────────
story.append(Paragraph("Goal Alignment Analysis", s["section_heading"]))
story.append(_divider())
for para in ai_sections.get("GOAL_PROXIMITY_ANALYSIS", "").split("\n"):
if para.strip():
story.append(Paragraph(para.strip(), s["body"]))
story.append(PageBreak())
# ── Page 2: Charts ────────────────────────────────────────────────────────
story.append(Paragraph("Daily Performance Breakdown", s["section_heading"]))
story.append(_divider())
daily_img = Image(chart_daily, width=W, height=8.5 * cm)
story.append(daily_img)
story.append(Paragraph(
"Each bar represents task completion for a single day. "
"Green = all tasks done. Amber = partial. Red = below 50%.",
s["caption"]
))
# Side-by-side: DOW chart + Radar chart
dow_img = Image(chart_dow, width=W * 0.54, height=7.5 * cm)
radar_img = Image(chart_radar, width=W * 0.44, height=7.5 * cm)
side = Table(
[[dow_img, radar_img]],
colWidths=[W * 0.54, W * 0.44],
)
side.setStyle(TableStyle([
('ALIGN', (0, 0), (-1, -1), 'CENTER'),
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
('LEFTPADDING', (0, 0), (-1, -1), 0),
('RIGHTPADDING', (0, 0), (-1, -1), 0),
]))
story.append(side)
story.append(Paragraph(
"Left: Average completion by day of week — reveals structural weak spots in the week. "
"Right: Score components that make up the Goal Proximity Score.",
s["caption"]
))
story.append(PageBreak())
# ── Page 3: Behavioural Patterns + Recommendations ────────────────────────
story.append(Paragraph("Behavioural Patterns", s["section_heading"]))
story.append(_divider())
for para in ai_sections.get("BEHAVIOURAL_PATTERNS", "").split("\n"):
if para.strip():
story.append(Paragraph(para.strip(), s["body"]))
# ── Recommendations (conditional) ────────────────────────────────────────
rec_text = ai_sections.get("RECOMMENDATIONS", "").strip()
if rec_text and rec_text != "NO_RECOMMENDATIONS_NEEDED":
story.append(Spacer(1, 0.3 * cm))
story.append(Paragraph("Recommended Actions", s["section_heading"]))
story.append(_divider())
# Split by newline or numbered list markers
lines = rec_text.split("\n")
for line in lines:
line = line.strip()
if not line:
continue
# Render as bullet if it starts with a number or dash
if line[0] in "0123456789-•":
story.append(Paragraph(f"→ {line.lstrip('0123456789.-• ').strip()}", s["bullet"]))
else:
story.append(Paragraph(line, s["body"]))
elif rec_text == "NO_RECOMMENDATIONS_NEEDED":
story.append(Spacer(1, 0.3 * cm))
story.append(Paragraph("Performance Note", s["section_heading"]))
story.append(_divider())
story.append(Paragraph(
"Based on the current data, no additional recommendations are required. "
"The existing approach is producing strong alignment with the stated goals. "
"The priority at this stage is consistency, not change.",
s["body_gray"]
))
# ── Top Tasks table ───────────────────────────────────────────────────────
if metrics.get("top_tasks"):
story.append(Spacer(1, 0.4 * cm))
story.append(Paragraph("Most Frequent Tasks", s["section_heading"]))
story.append(_divider())
table_data = [["Task", "Attempts", "Completion Rate"]]
for t in metrics["top_tasks"]:
rate_color = (COLOR_GREEN if t["completion_rate"] >= 80 else
COLOR_AMBER if t["completion_rate"] >= 50 else COLOR_RED)
table_data.append([
Paragraph(t["task"][:55], s["body"]),
Paragraph(str(t["attempts"]), s["body"]),
Paragraph(f"{t['completion_rate']}%", ParagraphStyle(
"rate", fontName="Helvetica-Bold", fontSize=10,
textColor=colors.HexColor(rate_color), alignment=TA_CENTER,
)),
])
task_table = Table(table_data,
colWidths=[W * 0.60, W * 0.15, W * 0.25])
task_table.setStyle(TableStyle([
('BACKGROUND', (0, 0), (-1, 0), colors.HexColor("#222222")),
('TEXTCOLOR', (0, 0), (-1, 0), PDF_ACCENT),
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, 0), 9),
('ALIGN', (1, 0), (-1, -1), 'CENTER'),
('ROWBACKGROUNDS', (0, 1), (-1, -1),
[PDF_CARD_BG, colors.HexColor("#111111")]),
('GRID', (0, 0), (-1, -1), 0.3, colors.HexColor("#333333")),
('TOPPADDING', (0, 0), (-1, -1), 7),
('BOTTOMPADDING', (0, 0), (-1, -1), 7),
('LEFTPADDING', (0, 0), (-1, -1), 8),
('RIGHTPADDING', (0, 0), (-1, -1), 8),
]))
story.append(task_table)
# ── Closing line ──────────────────────────────────────────────────────────
story.append(Spacer(1, 1 * cm))
story.append(_divider())
closing = ai_sections.get("CLOSING", "Keep going.")
story.append(Paragraph(f'"{closing}"', s["closing"]))
story.append(Spacer(1, 0.3 * cm))
story.append(Paragraph(
f"FocusDesk Report · Generated {report_date}",
s["meta"]
))
# ── Build with dark background on every page ──────────────────────────────
def dark_background(canvas_obj, doc_obj):
canvas_obj.saveState()
canvas_obj.setFillColor(PDF_BG)
canvas_obj.rect(0, 0, A4[0], A4[1], fill=1, stroke=0)
canvas_obj.restoreState()
doc.build(story,
onFirstPage=dark_background,
onLaterPages=dark_background)
# =============================================================================
# SECTION 6 — MAIN ENTRY POINT
# =============================================================================
def main():
# ── 1. Determine input file ───────────────────────────────────────────────
if len(sys.argv) > 1:
json_path = sys.argv[1]
else:
json_path = r"C:\Users\grahi\Downloads\sample_data.json"
if not os.path.exists(json_path):
print(f"[ERROR] File not found: {json_path}")
sys.exit(1)
if not GROQ_API_KEY:
print("[ERROR] Please set your GROQ_API_KEY environment variable.")
sys.exit(1)
print(f"\n FocusDesk Report Generator")
print(f" {'─' * 40}")
print(f" Input: {json_path}")
# ── 2. Load data ──────────────────────────────────────────────────────────
print(" Loading JSON data...")
data = load_json(json_path)
print(f" User: {data['user_profile']['name']} | "
f"History: {len(data['history'])} days")
# ── 3. Calculate metrics ──────────────────────────────────────────────────
print(" Calculating metrics...")
metrics = calculate_metrics(data)
print(f" Overall rate: {metrics['overall_rate']}% | "
f"Goal proximity: {metrics['goal_proximity']}/100")
# ── 4. Get AI analysis ────────────────────────────────────────────────────
ai_sections = get_ai_analysis(data, metrics)
print(" AI analysis complete.")
# ── 5. Generate charts ────────────────────────────────────────────────────
print(" Generating charts...")
chart_daily = chart_daily_completion(metrics["daily_rates"])
chart_gauge = chart_goal_proximity_gauge(metrics["goal_proximity"])
chart_dow = chart_dow_performance(metrics["dow_avg"])
chart_radar = chart_score_breakdown(metrics)
print(" Charts ready.")
# ── 6. Build PDF ──────────────────────────────────────────────────────────
name = data["user_profile"]["name"].replace(" ", "_")
date_str = datetime.now().strftime("%Y%m%d")
output_path = f"FocusDesk_Report_{name}_{date_str}.pdf"
print(" Building PDF...")
build_pdf(data, metrics, ai_sections,
chart_daily, chart_gauge, chart_dow, chart_radar,
output_path)
print(f"\n Report saved: {output_path}")
print(f" {'─' * 40}\n")
if __name__ == "__main__":
main()