""" 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()