Spaces:
Runtime error
Runtime error
Mathias Claude Opus 4.5 commited on
Commit ·
d378246
1
Parent(s): 8e9f28f
Add Prospects & Discovery columns + column auto-detection
Browse files- Add Prospects % and Discovery % columns to dashboard (6 activity columns total)
- Show "No target" for missing activity rows
- Add column_config.json for dynamic column configuration
- Add detect_columns.py script for monthly config updates
- Add /api/reload-config and /api/column-config endpoints
- Update leaderboard scoring to include all activity types
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- app.py +132 -8
- column_config.json +15 -0
- detect_columns.py +231 -0
- static/index.html +17 -5
app.py
CHANGED
|
@@ -52,15 +52,87 @@ KNOWN_ACTIVITIES = {
|
|
| 52 |
'prospects (activated)', 'discovery', 'sql', 'sql (offer sent)'
|
| 53 |
}
|
| 54 |
|
| 55 |
-
#
|
| 56 |
-
|
| 57 |
-
|
|
|
|
|
|
|
|
|
|
| 58 |
(2, 3, 7, 8, 9), # Week 2: daily D-H (3-7), target I (8), pct J (9)
|
| 59 |
(3, 10, 14, 15, 16), # Week 3: daily K-O (10-14), target P (15), pct Q (16)
|
| 60 |
(4, 17, 21, 22, 23), # Week 4: daily R-V (17-21), target W (22), pct X (23)
|
| 61 |
(5, 24, 28, 30, 31), # Week 5: daily Y-AC (24-28), target AE (30), pct AF (31) - extra empty col
|
| 62 |
]
|
| 63 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
|
| 65 |
def get_activity(row):
|
| 66 |
"""Extract activity type from row (column C, index 2)."""
|
|
@@ -162,7 +234,8 @@ def process_activity_row(row, case_name, gs_name, case_data):
|
|
| 162 |
}
|
| 163 |
|
| 164 |
# Extract weekly data: actual (sum of daily), target, and percentage from sheet
|
| 165 |
-
|
|
|
|
| 166 |
actual = sum_daily(row, daily_start, daily_end)
|
| 167 |
target = safe_int(row, target_col)
|
| 168 |
percentage = extract_percentage(row, pct_col)
|
|
@@ -176,15 +249,16 @@ def process_activity_row(row, case_name, gs_name, case_data):
|
|
| 176 |
if percentage is not None:
|
| 177 |
case_data[key]["weeks"][week_num][f"{activity_key}Pct"] = percentage
|
| 178 |
|
| 179 |
-
# Get monthly target and actual from
|
| 180 |
-
|
| 181 |
-
|
|
|
|
| 182 |
|
| 183 |
# Update monthly totals
|
| 184 |
if activity_key == "sql":
|
| 185 |
case_data[key]["monthlyTotal"]["sql"] = monthly_actual
|
| 186 |
case_data[key]["monthlyTotal"]["sqlTarget"] = monthly_target
|
| 187 |
-
elif activity_key in ["calls", "emails", "linkedin", "prospects"]:
|
| 188 |
# Activity includes all non-SQL activities
|
| 189 |
case_data[key]["monthlyTotal"]["activity"] += monthly_actual
|
| 190 |
case_data[key]["monthlyTotal"]["activityTarget"] += monthly_target
|
|
@@ -369,6 +443,56 @@ async def cache_status():
|
|
| 369 |
return JSONResponse(content={"cached": False})
|
| 370 |
|
| 371 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 372 |
@app.get("/api/debug")
|
| 373 |
async def debug_data():
|
| 374 |
"""Debug endpoint to see raw sheet data structure and parsed blocks."""
|
|
|
|
| 52 |
'prospects (activated)', 'discovery', 'sql', 'sql (offer sent)'
|
| 53 |
}
|
| 54 |
|
| 55 |
+
# Column configuration file path
|
| 56 |
+
CONFIG_FILE = "column_config.json"
|
| 57 |
+
|
| 58 |
+
# Default week configuration (fallback if config file not found)
|
| 59 |
+
# Format: (week_number, daily_start, daily_end, target_col, percentage_col)
|
| 60 |
+
DEFAULT_WEEK_CONFIGS = [
|
| 61 |
(2, 3, 7, 8, 9), # Week 2: daily D-H (3-7), target I (8), pct J (9)
|
| 62 |
(3, 10, 14, 15, 16), # Week 3: daily K-O (10-14), target P (15), pct Q (16)
|
| 63 |
(4, 17, 21, 22, 23), # Week 4: daily R-V (17-21), target W (22), pct X (23)
|
| 64 |
(5, 24, 28, 30, 31), # Week 5: daily Y-AC (24-28), target AE (30), pct AF (31) - extra empty col
|
| 65 |
]
|
| 66 |
|
| 67 |
+
# Default monthly column configuration (fallback)
|
| 68 |
+
DEFAULT_MONTHLY_CONFIG = {"target_col": 32, "actual_col": 33}
|
| 69 |
+
|
| 70 |
+
# Global column configuration (loaded at startup, can be reloaded)
|
| 71 |
+
_column_config = {"weeks": None, "monthly": None, "loaded_at": None}
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def load_column_config(force_reload=False):
|
| 75 |
+
"""
|
| 76 |
+
Load column configuration from JSON file.
|
| 77 |
+
Falls back to hardcoded defaults if file not found or invalid.
|
| 78 |
+
"""
|
| 79 |
+
global _column_config
|
| 80 |
+
|
| 81 |
+
if _column_config["weeks"] is not None and not force_reload:
|
| 82 |
+
return _column_config
|
| 83 |
+
|
| 84 |
+
try:
|
| 85 |
+
with open(CONFIG_FILE, "r") as f:
|
| 86 |
+
config = json.load(f)
|
| 87 |
+
|
| 88 |
+
# Convert weeks config to tuple format
|
| 89 |
+
weeks = []
|
| 90 |
+
for w in config.get("weeks", []):
|
| 91 |
+
weeks.append((
|
| 92 |
+
w["week_num"],
|
| 93 |
+
w["daily_start"],
|
| 94 |
+
w["daily_end"],
|
| 95 |
+
w["target_col"],
|
| 96 |
+
w["pct_col"]
|
| 97 |
+
))
|
| 98 |
+
|
| 99 |
+
monthly = config.get("monthly", DEFAULT_MONTHLY_CONFIG)
|
| 100 |
+
|
| 101 |
+
_column_config["weeks"] = weeks if weeks else DEFAULT_WEEK_CONFIGS
|
| 102 |
+
_column_config["monthly"] = monthly
|
| 103 |
+
_column_config["loaded_at"] = datetime.now().isoformat()
|
| 104 |
+
print(f"Column config loaded from {CONFIG_FILE}: {len(weeks)} weeks")
|
| 105 |
+
|
| 106 |
+
except FileNotFoundError:
|
| 107 |
+
print(f"Config file {CONFIG_FILE} not found, using defaults")
|
| 108 |
+
_column_config["weeks"] = DEFAULT_WEEK_CONFIGS
|
| 109 |
+
_column_config["monthly"] = DEFAULT_MONTHLY_CONFIG
|
| 110 |
+
_column_config["loaded_at"] = datetime.now().isoformat()
|
| 111 |
+
|
| 112 |
+
except Exception as e:
|
| 113 |
+
print(f"Error loading config: {e}, using defaults")
|
| 114 |
+
_column_config["weeks"] = DEFAULT_WEEK_CONFIGS
|
| 115 |
+
_column_config["monthly"] = DEFAULT_MONTHLY_CONFIG
|
| 116 |
+
_column_config["loaded_at"] = datetime.now().isoformat()
|
| 117 |
+
|
| 118 |
+
return _column_config
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def get_week_configs():
|
| 122 |
+
"""Get the current week configurations."""
|
| 123 |
+
config = load_column_config()
|
| 124 |
+
return config["weeks"]
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def get_monthly_config():
|
| 128 |
+
"""Get the current monthly column configuration."""
|
| 129 |
+
config = load_column_config()
|
| 130 |
+
return config["monthly"]
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
# Load config at module initialization
|
| 134 |
+
load_column_config()
|
| 135 |
+
|
| 136 |
|
| 137 |
def get_activity(row):
|
| 138 |
"""Extract activity type from row (column C, index 2)."""
|
|
|
|
| 234 |
}
|
| 235 |
|
| 236 |
# Extract weekly data: actual (sum of daily), target, and percentage from sheet
|
| 237 |
+
week_configs = get_week_configs()
|
| 238 |
+
for week_num, daily_start, daily_end, target_col, pct_col in week_configs:
|
| 239 |
actual = sum_daily(row, daily_start, daily_end)
|
| 240 |
target = safe_int(row, target_col)
|
| 241 |
percentage = extract_percentage(row, pct_col)
|
|
|
|
| 249 |
if percentage is not None:
|
| 250 |
case_data[key]["weeks"][week_num][f"{activity_key}Pct"] = percentage
|
| 251 |
|
| 252 |
+
# Get monthly target and actual from config
|
| 253 |
+
monthly_config = get_monthly_config()
|
| 254 |
+
monthly_target = safe_int(row, monthly_config["target_col"])
|
| 255 |
+
monthly_actual = safe_int(row, monthly_config["actual_col"])
|
| 256 |
|
| 257 |
# Update monthly totals
|
| 258 |
if activity_key == "sql":
|
| 259 |
case_data[key]["monthlyTotal"]["sql"] = monthly_actual
|
| 260 |
case_data[key]["monthlyTotal"]["sqlTarget"] = monthly_target
|
| 261 |
+
elif activity_key in ["calls", "emails", "linkedin", "prospects", "discovery"]:
|
| 262 |
# Activity includes all non-SQL activities
|
| 263 |
case_data[key]["monthlyTotal"]["activity"] += monthly_actual
|
| 264 |
case_data[key]["monthlyTotal"]["activityTarget"] += monthly_target
|
|
|
|
| 443 |
return JSONResponse(content={"cached": False})
|
| 444 |
|
| 445 |
|
| 446 |
+
@app.post("/api/reload-config")
|
| 447 |
+
async def reload_config(request: Request):
|
| 448 |
+
"""
|
| 449 |
+
Reload column configuration from column_config.json.
|
| 450 |
+
Also invalidates the data cache to force a fresh fetch with new config.
|
| 451 |
+
"""
|
| 452 |
+
global _cache
|
| 453 |
+
|
| 454 |
+
# Optional: verify webhook secret if configured
|
| 455 |
+
if WEBHOOK_SECRET:
|
| 456 |
+
auth_header = request.headers.get("X-Webhook-Secret", "")
|
| 457 |
+
if not hmac.compare_digest(auth_header, WEBHOOK_SECRET):
|
| 458 |
+
raise HTTPException(status_code=401, detail="Invalid webhook secret")
|
| 459 |
+
|
| 460 |
+
# Reload the column configuration
|
| 461 |
+
config = load_column_config(force_reload=True)
|
| 462 |
+
|
| 463 |
+
# Invalidate data cache to use new config
|
| 464 |
+
_cache["data"] = None
|
| 465 |
+
_cache["timestamp"] = None
|
| 466 |
+
|
| 467 |
+
return JSONResponse(content={
|
| 468 |
+
"success": True,
|
| 469 |
+
"message": "Column config reloaded, cache invalidated",
|
| 470 |
+
"config_loaded_at": config["loaded_at"],
|
| 471 |
+
"weeks_count": len(config["weeks"]),
|
| 472 |
+
"timestamp": datetime.now().isoformat()
|
| 473 |
+
})
|
| 474 |
+
|
| 475 |
+
|
| 476 |
+
@app.get("/api/column-config")
|
| 477 |
+
async def get_column_config():
|
| 478 |
+
"""Return current column configuration."""
|
| 479 |
+
config = load_column_config()
|
| 480 |
+
return JSONResponse(content={
|
| 481 |
+
"weeks": [
|
| 482 |
+
{
|
| 483 |
+
"week_num": w[0],
|
| 484 |
+
"daily_start": w[1],
|
| 485 |
+
"daily_end": w[2],
|
| 486 |
+
"target_col": w[3],
|
| 487 |
+
"pct_col": w[4]
|
| 488 |
+
}
|
| 489 |
+
for w in config["weeks"]
|
| 490 |
+
],
|
| 491 |
+
"monthly": config["monthly"],
|
| 492 |
+
"loaded_at": config["loaded_at"]
|
| 493 |
+
})
|
| 494 |
+
|
| 495 |
+
|
| 496 |
@app.get("/api/debug")
|
| 497 |
async def debug_data():
|
| 498 |
"""Debug endpoint to see raw sheet data structure and parsed blocks."""
|
column_config.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"generated_at": "2026-01-23T10:30:00Z",
|
| 3 |
+
"sheet_id": "1af6-2KsRqeTQxdw5KVRp2WCrM6RT7HIcl70m-GgGZB4",
|
| 4 |
+
"sheet_name": "DAILY - for SDR to add data\ud83c\udf1f",
|
| 5 |
+
"weeks": [
|
| 6 |
+
{"week_num": 2, "daily_start": 3, "daily_end": 7, "target_col": 8, "pct_col": 9},
|
| 7 |
+
{"week_num": 3, "daily_start": 10, "daily_end": 14, "target_col": 15, "pct_col": 16},
|
| 8 |
+
{"week_num": 4, "daily_start": 17, "daily_end": 21, "target_col": 22, "pct_col": 23},
|
| 9 |
+
{"week_num": 5, "daily_start": 24, "daily_end": 28, "target_col": 30, "pct_col": 31}
|
| 10 |
+
],
|
| 11 |
+
"monthly": {
|
| 12 |
+
"target_col": 32,
|
| 13 |
+
"actual_col": 33
|
| 14 |
+
}
|
| 15 |
+
}
|
detect_columns.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Column Auto-Detection Script for SDR Status Tracker
|
| 4 |
+
|
| 5 |
+
Reads header rows from the Google Sheet and generates column_config.json
|
| 6 |
+
with detected week configurations and monthly column positions.
|
| 7 |
+
|
| 8 |
+
Usage:
|
| 9 |
+
python detect_columns.py # Generate config
|
| 10 |
+
python detect_columns.py --dry-run # Preview without writing file
|
| 11 |
+
"""
|
| 12 |
+
import os
|
| 13 |
+
import re
|
| 14 |
+
import json
|
| 15 |
+
import argparse
|
| 16 |
+
from datetime import datetime
|
| 17 |
+
from google.oauth2 import service_account
|
| 18 |
+
from googleapiclient.discovery import build
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
# Configuration
|
| 22 |
+
SHEET_ID = os.environ.get("GOOGLE_SHEET_ID", "1af6-2KsRqeTQxdw5KVRp2WCrM6RT7HIcl70m-GgGZB4")
|
| 23 |
+
SHEET_NAME = "DAILY - for SDR to add data🌟"
|
| 24 |
+
CONFIG_FILE = "column_config.json"
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def get_sheets_service():
|
| 28 |
+
"""Create Google Sheets API service using service account credentials."""
|
| 29 |
+
creds_json = os.environ.get("GOOGLE_CREDENTIALS")
|
| 30 |
+
if not creds_json:
|
| 31 |
+
raise RuntimeError("GOOGLE_CREDENTIALS environment variable not set")
|
| 32 |
+
|
| 33 |
+
creds_dict = json.loads(creds_json)
|
| 34 |
+
credentials = service_account.Credentials.from_service_account_info(
|
| 35 |
+
creds_dict,
|
| 36 |
+
scopes=["https://www.googleapis.com/auth/spreadsheets.readonly"]
|
| 37 |
+
)
|
| 38 |
+
return build("sheets", "v4", credentials=credentials)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def fetch_header_rows(service):
|
| 42 |
+
"""Fetch the first 4 rows from the sheet (headers)."""
|
| 43 |
+
result = service.spreadsheets().values().get(
|
| 44 |
+
spreadsheetId=SHEET_ID,
|
| 45 |
+
range=f"'{SHEET_NAME}'!A1:AZ4" # Wide range to capture all columns
|
| 46 |
+
).execute()
|
| 47 |
+
return result.get("values", [])
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def detect_week_markers(row1):
|
| 51 |
+
"""
|
| 52 |
+
Detect WEEK markers from Row 1.
|
| 53 |
+
Returns list of (week_num, start_col) tuples.
|
| 54 |
+
Pattern: "WEEK 2", "WEEK 3", etc.
|
| 55 |
+
"""
|
| 56 |
+
week_pattern = re.compile(r"WEEK\s*(\d+)", re.IGNORECASE)
|
| 57 |
+
weeks = []
|
| 58 |
+
|
| 59 |
+
for col_idx, cell in enumerate(row1):
|
| 60 |
+
if cell:
|
| 61 |
+
match = week_pattern.search(str(cell))
|
| 62 |
+
if match:
|
| 63 |
+
week_num = int(match.group(1))
|
| 64 |
+
weeks.append((week_num, col_idx))
|
| 65 |
+
|
| 66 |
+
return weeks
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def detect_daily_columns(row4, week_start_col, next_week_start=None):
|
| 70 |
+
"""
|
| 71 |
+
Detect daily columns within a week block.
|
| 72 |
+
Daily columns contain day-of-month numbers (1-31) in Row 4.
|
| 73 |
+
Returns (daily_start, daily_end, target_col, pct_col).
|
| 74 |
+
"""
|
| 75 |
+
# Search range: from week_start to next_week_start (or end of row)
|
| 76 |
+
end_col = next_week_start if next_week_start else len(row4)
|
| 77 |
+
|
| 78 |
+
daily_start = None
|
| 79 |
+
daily_end = None
|
| 80 |
+
target_col = None
|
| 81 |
+
pct_col = None
|
| 82 |
+
|
| 83 |
+
for col_idx in range(week_start_col, end_col):
|
| 84 |
+
if col_idx >= len(row4):
|
| 85 |
+
break
|
| 86 |
+
|
| 87 |
+
cell = str(row4[col_idx]).strip().lower() if col_idx < len(row4) else ""
|
| 88 |
+
|
| 89 |
+
# Check if it's a day number (1-31)
|
| 90 |
+
if cell.isdigit() and 1 <= int(cell) <= 31:
|
| 91 |
+
if daily_start is None:
|
| 92 |
+
daily_start = col_idx
|
| 93 |
+
daily_end = col_idx
|
| 94 |
+
|
| 95 |
+
# Check for TARGET column
|
| 96 |
+
if "target" in cell or cell == "t":
|
| 97 |
+
target_col = col_idx
|
| 98 |
+
|
| 99 |
+
# Check for PERCENTAGE column (often contains % or "pct" or just a number)
|
| 100 |
+
if "%" in cell or "pct" in cell.lower():
|
| 101 |
+
pct_col = col_idx
|
| 102 |
+
|
| 103 |
+
# If no explicit pct_col found, it's usually right after target
|
| 104 |
+
if target_col is not None and pct_col is None:
|
| 105 |
+
pct_col = target_col + 1
|
| 106 |
+
|
| 107 |
+
return daily_start, daily_end, target_col, pct_col
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def detect_monthly_columns(row4):
|
| 111 |
+
"""
|
| 112 |
+
Detect monthly TARGET and ACTUAL columns.
|
| 113 |
+
These are typically labeled "TARGET" and "ACTUAL" (or similar) near the end.
|
| 114 |
+
"""
|
| 115 |
+
target_col = None
|
| 116 |
+
actual_col = None
|
| 117 |
+
|
| 118 |
+
for col_idx, cell in enumerate(row4):
|
| 119 |
+
cell_str = str(cell).strip().lower() if cell else ""
|
| 120 |
+
|
| 121 |
+
# Look for monthly target (usually labeled differently from weekly)
|
| 122 |
+
if "monthly" in cell_str or (col_idx > 25 and "target" in cell_str):
|
| 123 |
+
if target_col is None:
|
| 124 |
+
target_col = col_idx
|
| 125 |
+
|
| 126 |
+
# Look for monthly actual
|
| 127 |
+
if "actual" in cell_str or (col_idx > 25 and col_idx == target_col + 1):
|
| 128 |
+
actual_col = col_idx
|
| 129 |
+
|
| 130 |
+
# Default fallback based on current known structure
|
| 131 |
+
if target_col is None:
|
| 132 |
+
target_col = 32 # Column AG
|
| 133 |
+
if actual_col is None:
|
| 134 |
+
actual_col = 33 # Column AH
|
| 135 |
+
|
| 136 |
+
return target_col, actual_col
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def detect_columns():
|
| 140 |
+
"""
|
| 141 |
+
Main detection function.
|
| 142 |
+
Returns the detected configuration as a dictionary.
|
| 143 |
+
"""
|
| 144 |
+
print("Connecting to Google Sheets API...")
|
| 145 |
+
service = get_sheets_service()
|
| 146 |
+
|
| 147 |
+
print(f"Fetching headers from sheet: {SHEET_NAME}")
|
| 148 |
+
headers = fetch_header_rows(service)
|
| 149 |
+
|
| 150 |
+
if len(headers) < 4:
|
| 151 |
+
raise RuntimeError(f"Expected at least 4 header rows, got {len(headers)}")
|
| 152 |
+
|
| 153 |
+
row1 = headers[0] if len(headers) > 0 else []
|
| 154 |
+
row4 = headers[3] if len(headers) > 3 else []
|
| 155 |
+
|
| 156 |
+
print(f"Row 1 has {len(row1)} columns")
|
| 157 |
+
print(f"Row 4 has {len(row4)} columns")
|
| 158 |
+
|
| 159 |
+
# Detect week markers
|
| 160 |
+
week_markers = detect_week_markers(row1)
|
| 161 |
+
print(f"Detected {len(week_markers)} week markers: {week_markers}")
|
| 162 |
+
|
| 163 |
+
# Detect columns for each week
|
| 164 |
+
weeks_config = []
|
| 165 |
+
for i, (week_num, start_col) in enumerate(week_markers):
|
| 166 |
+
# Get next week's start column (or None for last week)
|
| 167 |
+
next_start = week_markers[i + 1][1] if i + 1 < len(week_markers) else None
|
| 168 |
+
|
| 169 |
+
daily_start, daily_end, target_col, pct_col = detect_daily_columns(
|
| 170 |
+
row4, start_col, next_start
|
| 171 |
+
)
|
| 172 |
+
|
| 173 |
+
week_config = {
|
| 174 |
+
"week_num": week_num,
|
| 175 |
+
"daily_start": daily_start,
|
| 176 |
+
"daily_end": daily_end,
|
| 177 |
+
"target_col": target_col,
|
| 178 |
+
"pct_col": pct_col
|
| 179 |
+
}
|
| 180 |
+
weeks_config.append(week_config)
|
| 181 |
+
|
| 182 |
+
print(f" Week {week_num}: daily={daily_start}-{daily_end}, target={target_col}, pct={pct_col}")
|
| 183 |
+
|
| 184 |
+
# Detect monthly columns
|
| 185 |
+
monthly_target, monthly_actual = detect_monthly_columns(row4)
|
| 186 |
+
print(f"Monthly columns: target={monthly_target}, actual={monthly_actual}")
|
| 187 |
+
|
| 188 |
+
config = {
|
| 189 |
+
"generated_at": datetime.utcnow().isoformat() + "Z",
|
| 190 |
+
"sheet_id": SHEET_ID,
|
| 191 |
+
"sheet_name": SHEET_NAME,
|
| 192 |
+
"weeks": weeks_config,
|
| 193 |
+
"monthly": {
|
| 194 |
+
"target_col": monthly_target,
|
| 195 |
+
"actual_col": monthly_actual
|
| 196 |
+
}
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
return config
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
def main():
|
| 203 |
+
parser = argparse.ArgumentParser(description="Detect column mappings from Google Sheet")
|
| 204 |
+
parser.add_argument("--dry-run", action="store_true", help="Preview config without writing file")
|
| 205 |
+
args = parser.parse_args()
|
| 206 |
+
|
| 207 |
+
try:
|
| 208 |
+
config = detect_columns()
|
| 209 |
+
|
| 210 |
+
print("\n" + "=" * 50)
|
| 211 |
+
print("Detected Configuration:")
|
| 212 |
+
print("=" * 50)
|
| 213 |
+
print(json.dumps(config, indent=2))
|
| 214 |
+
|
| 215 |
+
if args.dry_run:
|
| 216 |
+
print("\n[DRY RUN] Config not written to file")
|
| 217 |
+
else:
|
| 218 |
+
# Write config file
|
| 219 |
+
with open(CONFIG_FILE, "w") as f:
|
| 220 |
+
json.dump(config, f, indent=2)
|
| 221 |
+
print(f"\nConfig written to {CONFIG_FILE}")
|
| 222 |
+
|
| 223 |
+
return 0
|
| 224 |
+
|
| 225 |
+
except Exception as e:
|
| 226 |
+
print(f"Error: {e}")
|
| 227 |
+
return 1
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
if __name__ == "__main__":
|
| 231 |
+
exit(main())
|
static/index.html
CHANGED
|
@@ -325,7 +325,7 @@
|
|
| 325 |
<tr>
|
| 326 |
<th>SDR</th>
|
| 327 |
<th>Case</th>
|
| 328 |
-
<th colspan="
|
| 329 |
<th colspan="2" class="weekly-monthly-divider" style="text-align: center; background: var(--emerald-100);">Monthly Totals</th>
|
| 330 |
</tr>
|
| 331 |
<tr>
|
|
@@ -335,6 +335,8 @@
|
|
| 335 |
<th class="progress-cell">Calls %</th>
|
| 336 |
<th class="progress-cell">Emails %</th>
|
| 337 |
<th class="progress-cell">LinkedIn %</th>
|
|
|
|
|
|
|
| 338 |
<th class="progress-cell weekly-monthly-divider">SQL %</th>
|
| 339 |
<th class="progress-cell">Activity %</th>
|
| 340 |
</tr>
|
|
@@ -670,12 +672,12 @@
|
|
| 670 |
|
| 671 |
function showLoadingState() {
|
| 672 |
const tbody = document.getElementById('caseTable');
|
| 673 |
-
tbody.innerHTML = '<tr><td colspan="
|
| 674 |
}
|
| 675 |
|
| 676 |
function showErrorState(message) {
|
| 677 |
const tbody = document.getElementById('caseTable');
|
| 678 |
-
tbody.innerHTML = `<tr><td colspan="
|
| 679 |
}
|
| 680 |
|
| 681 |
// Try to load from cache first for immediate display
|
|
@@ -792,13 +794,15 @@
|
|
| 792 |
// Returns null for metrics without targets
|
| 793 |
function getWeeklyProgress(c, weekNum) {
|
| 794 |
const week = c.weeks[weekNum];
|
| 795 |
-
if (!week) return { sql: null, calls: null, emails: null, linkedin: null };
|
| 796 |
|
| 797 |
return {
|
| 798 |
sql: week.sqlTarget > 0 ? Math.round((week.sql / week.sqlTarget) * 100) : null,
|
| 799 |
calls: week.callsTarget > 0 ? Math.round((week.calls / week.callsTarget) * 100) : null,
|
| 800 |
emails: week.emailsTarget > 0 ? Math.round((week.emails / week.emailsTarget) * 100) : null,
|
| 801 |
-
linkedin: week.linkedinTarget > 0 ? Math.round((week.linkedin / week.linkedinTarget) * 100) : null
|
|
|
|
|
|
|
| 802 |
};
|
| 803 |
}
|
| 804 |
|
|
@@ -894,6 +898,8 @@
|
|
| 894 |
<td class="progress-cell">${renderProgressBar(weekProgress.calls, true)}</td>
|
| 895 |
<td class="progress-cell">${renderProgressBar(weekProgress.emails, true)}</td>
|
| 896 |
<td class="progress-cell">${renderProgressBar(weekProgress.linkedin, true)}</td>
|
|
|
|
|
|
|
| 897 |
<td class="progress-cell weekly-monthly-divider">${renderProgressBar(monthlySQLProgress, false)}</td>
|
| 898 |
<td class="progress-cell">${renderProgressBar(monthlyActivityProgress, false)}</td>
|
| 899 |
`;
|
|
@@ -963,6 +969,12 @@
|
|
| 963 |
if (week.linkedinTarget > 0) {
|
| 964 |
targets.push({ actual: week.linkedin || 0, target: week.linkedinTarget });
|
| 965 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 966 |
});
|
| 967 |
|
| 968 |
weeklyByGS[gs] = calculateScore(targets);
|
|
|
|
| 325 |
<tr>
|
| 326 |
<th>SDR</th>
|
| 327 |
<th>Case</th>
|
| 328 |
+
<th colspan="6" style="text-align: center; background: var(--zodiac-100);">Current Week</th>
|
| 329 |
<th colspan="2" class="weekly-monthly-divider" style="text-align: center; background: var(--emerald-100);">Monthly Totals</th>
|
| 330 |
</tr>
|
| 331 |
<tr>
|
|
|
|
| 335 |
<th class="progress-cell">Calls %</th>
|
| 336 |
<th class="progress-cell">Emails %</th>
|
| 337 |
<th class="progress-cell">LinkedIn %</th>
|
| 338 |
+
<th class="progress-cell">Prospects %</th>
|
| 339 |
+
<th class="progress-cell">Discovery %</th>
|
| 340 |
<th class="progress-cell weekly-monthly-divider">SQL %</th>
|
| 341 |
<th class="progress-cell">Activity %</th>
|
| 342 |
</tr>
|
|
|
|
| 672 |
|
| 673 |
function showLoadingState() {
|
| 674 |
const tbody = document.getElementById('caseTable');
|
| 675 |
+
tbody.innerHTML = '<tr><td colspan="10" style="text-align: center; padding: 40px; color: var(--text-muted);">Loading data from Google Sheets...</td></tr>';
|
| 676 |
}
|
| 677 |
|
| 678 |
function showErrorState(message) {
|
| 679 |
const tbody = document.getElementById('caseTable');
|
| 680 |
+
tbody.innerHTML = `<tr><td colspan="10" style="text-align: center; padding: 40px; color: var(--danger);">${message}</td></tr>`;
|
| 681 |
}
|
| 682 |
|
| 683 |
// Try to load from cache first for immediate display
|
|
|
|
| 794 |
// Returns null for metrics without targets
|
| 795 |
function getWeeklyProgress(c, weekNum) {
|
| 796 |
const week = c.weeks[weekNum];
|
| 797 |
+
if (!week) return { sql: null, calls: null, emails: null, linkedin: null, prospects: null, discovery: null };
|
| 798 |
|
| 799 |
return {
|
| 800 |
sql: week.sqlTarget > 0 ? Math.round((week.sql / week.sqlTarget) * 100) : null,
|
| 801 |
calls: week.callsTarget > 0 ? Math.round((week.calls / week.callsTarget) * 100) : null,
|
| 802 |
emails: week.emailsTarget > 0 ? Math.round((week.emails / week.emailsTarget) * 100) : null,
|
| 803 |
+
linkedin: week.linkedinTarget > 0 ? Math.round((week.linkedin / week.linkedinTarget) * 100) : null,
|
| 804 |
+
prospects: week.prospectsTarget > 0 ? Math.round((week.prospects / week.prospectsTarget) * 100) : null,
|
| 805 |
+
discovery: week.discoveryTarget > 0 ? Math.round((week.discovery / week.discoveryTarget) * 100) : null
|
| 806 |
};
|
| 807 |
}
|
| 808 |
|
|
|
|
| 898 |
<td class="progress-cell">${renderProgressBar(weekProgress.calls, true)}</td>
|
| 899 |
<td class="progress-cell">${renderProgressBar(weekProgress.emails, true)}</td>
|
| 900 |
<td class="progress-cell">${renderProgressBar(weekProgress.linkedin, true)}</td>
|
| 901 |
+
<td class="progress-cell">${renderProgressBar(weekProgress.prospects, true)}</td>
|
| 902 |
+
<td class="progress-cell">${renderProgressBar(weekProgress.discovery, true)}</td>
|
| 903 |
<td class="progress-cell weekly-monthly-divider">${renderProgressBar(monthlySQLProgress, false)}</td>
|
| 904 |
<td class="progress-cell">${renderProgressBar(monthlyActivityProgress, false)}</td>
|
| 905 |
`;
|
|
|
|
| 969 |
if (week.linkedinTarget > 0) {
|
| 970 |
targets.push({ actual: week.linkedin || 0, target: week.linkedinTarget });
|
| 971 |
}
|
| 972 |
+
if (week.prospectsTarget > 0) {
|
| 973 |
+
targets.push({ actual: week.prospects || 0, target: week.prospectsTarget });
|
| 974 |
+
}
|
| 975 |
+
if (week.discoveryTarget > 0) {
|
| 976 |
+
targets.push({ actual: week.discovery || 0, target: week.discoveryTarget });
|
| 977 |
+
}
|
| 978 |
});
|
| 979 |
|
| 980 |
weeklyByGS[gs] = calculateScore(targets);
|