Spaces:
Sleeping
Sleeping
| """ | |
| 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<br/>COMPLETION", s["stat_label"]), | |
| Paragraph("CURRENT<br/>STREAK", s["stat_label"]), | |
| Paragraph("LONGEST<br/>STREAK", s["stat_label"]), | |
| Paragraph("CONSISTENCY<br/>SCORE", s["stat_label"]), | |
| Paragraph("GOAL<br/>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() |