This workflow corresponds to n8n.io template #14783 — we link there as the canonical source.
This workflow follows the Gmail → Postgres recipe pattern — see all workflows that pair these two integrations.
The workflow JSON
Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →
{
"id": "",
"meta": {
"templateCredsSetupCompleted": false
},
"name": "Weekly Jira Report",
"tags": [],
"nodes": [
{
"id": "590d7a1e-a79d-4e27-bc96-6a2fc07709a6",
"name": "Label Updated3",
"type": "n8n-nodes-base.code",
"position": [
1616,
5840
],
"parameters": {
"jsCode": "// Label every item from this Jira query with _type = 'updated'\n// Aggregate reads this field \u2014 no guessing, no re-classification.\nreturn $input.all().map(item => ({\n json: { ...item.json, _type: 'updated' }\n}));"
},
"typeVersion": 2
},
{
"id": "f9081239-6fcb-462b-aea2-293f6463d48b",
"name": "Label Created3",
"type": "n8n-nodes-base.code",
"position": [
1616,
6032
],
"parameters": {
"jsCode": "// Label every item from this Jira query with _type = 'created'\n// Aggregate reads this field \u2014 no guessing, no re-classification.\nreturn $input.all().map(item => ({\n json: { ...item.json, _type: 'created' }\n}));"
},
"typeVersion": 2
},
{
"id": "be16c79f-ecb8-4233-ad04-fa1cb1d639c5",
"name": "Label Completed3",
"type": "n8n-nodes-base.code",
"position": [
1616,
6208
],
"parameters": {
"jsCode": "// Label every item from this Jira query with _type = 'completed'\n// Aggregate reads this field \u2014 no guessing, no re-classification.\nreturn $input.all().map(item => ({\n json: { ...item.json, _type: 'completed' }\n}));"
},
"typeVersion": 2
},
{
"id": "98ee8def-8041-4eb2-88b0-2323fdb54ceb",
"name": "Label Due Soon3",
"type": "n8n-nodes-base.code",
"position": [
1616,
6400
],
"parameters": {
"jsCode": "// Label every item from this Jira query with _type = 'dueSoon'\n// Aggregate reads this field \u2014 no guessing, no re-classification.\nreturn $input.all().map(item => ({\n json: { ...item.json, _type: 'dueSoon' }\n}));"
},
"typeVersion": 2
},
{
"id": "382ee5d5-ed15-4659-a325-ed81ee03d144",
"name": "Label Epics3",
"type": "n8n-nodes-base.code",
"position": [
1616,
6560
],
"parameters": {
"jsCode": "// Label every item from this Jira query with _type = 'epics'\n// Aggregate reads this field \u2014 no guessing, no re-classification.\nreturn $input.all().map(item => ({\n json: { ...item.json, _type: 'epics' }\n}));"
},
"typeVersion": 2
},
{
"id": "23665390-c444-4cf5-bc05-4207df9cdf5b",
"name": "Label All Issues3",
"type": "n8n-nodes-base.code",
"position": [
1616,
6736
],
"parameters": {
"jsCode": "// Label every item from this Jira query with _type = 'allIssues'\n// Aggregate reads this field \u2014 no guessing, no re-classification.\nreturn $input.all().map(item => ({\n json: { ...item.json, _type: 'allIssues' }\n}));"
},
"typeVersion": 2
},
{
"id": "857590ff-eab4-4e8d-8d19-4eb52b6ac84e",
"name": "Sticky Note19",
"type": "n8n-nodes-base.stickyNote",
"position": [
-528,
5184
],
"parameters": {
"width": 960,
"height": 1232,
"content": "## What it is ? [ WEEKLY AUTOMATED REPORTING SYSTEM \ud83e\udde0 ]\n\n### \ud83d\udcca An automated system that monitors the team's Jira project every Monday morning, measures progress against the active sprint, and delivers a structured report to stakeholders \u2014 with no manual effort required.\n\n\n## What problem it solves ?\n\n### Sprint progress is invisible until someone manually compiles it. By then it is too late to act. Blocked work goes unnoticed. Overdue issues accumulate quietly. Team capacity is unbalanced without anyone realising. This system makes sprint health visible every week, automatically, before problems escalate.\n\n\n## How it works ?\n\n### Every Monday at ??:?? the system runs automatically. It pulls live data from Jira, measures what happened over the past 7 days, and produces three outputs simultaneously:\n\n- An **HTML report** + **PDF Attachement** sent by email to the team\n- A **Markdown version** for documentation and archiving\n- A set of **structured records** saved to a database for historical analysis\n\n> ### The entire process takes under one minute and requires no human input.\n\n\n## Multi-Project support\n\n### The system runs one instance per Jira project. All instances share the same database, so cross-project analysis is available out of the box. Adding a new project requires changing a single configuration value (**CONFIGURATION NODE**) \u2014 everything else adapts automatically\n\n\n## Historical Analysis\n\n### Every report is saved to a project shared database. Over time this builds a searchable record that answers questions no single report can:\n\n- Is the team's velocity improving sprint over sprint?\n- Is lead time decreasing as the team matures?\n- Which team member consistently resolves issues fastest?\n- Which epics have stalled across multiple weeks?\n- How does this project compare to others running in parallel?\n\n> ### This turns weekly snapshots into a continuous picture of team performance\n\n\n## JIRA SPACE CONFIGURATION COMPATIBILITY\n\n### Link to Documentation: https://www.notion.so/JIRA-COMPATIBILITY-GUIDE-335f6ec488f68037853adb1722711cae?source=copy_link \ud83d\udd0e\n\n\n## \ud83d\udce9 Contact / Questions / Custom Requests\n\n### Workflow created by\n**aixautomation** \n- Email: aixautomation01@gmail.com \n- Website: https://www.aixautomation.tech \n\n### Need help?\nIf you have any questions about this workflow, need support setting it up, or want to request a custom workflow, feel free to get in touch."
},
"typeVersion": 1
},
{
"id": "d549de26-63c6-4d8b-a8e0-1c9f2139220c",
"name": "Sticky Note21",
"type": "n8n-nodes-base.stickyNote",
"position": [
864,
5696
],
"parameters": {
"color": 7,
"width": 1232,
"height": 1456,
"content": "## PREPARE DATA ( DATA CONFIGURATION / JIRA ISSUES ) \ud83e\udd16"
},
"typeVersion": 1
},
{
"id": "e9712f61-25e8-48c8-b5fa-d6f046483c87",
"name": "Sticky Note22",
"type": "n8n-nodes-base.stickyNote",
"position": [
544,
5456
],
"parameters": {
"color": 7,
"width": 272,
"height": 448,
"content": "## CREATE TABLES FOR FIRST TIME RUNNING WORKFLOW 1\ufe0f\u20e3"
},
"typeVersion": 1
},
{
"id": "5e54e6de-37e6-470b-a5c3-42c6659822d3",
"name": "Sticky Note23",
"type": "n8n-nodes-base.stickyNote",
"position": [
4976,
6176
],
"parameters": {
"color": 7,
"width": 2192,
"height": 928,
"content": "## STORE IN DATABASE FOR LATER ANALYTICS WITH (PowerBI, MetaBase...) \ud83d\udcca"
},
"typeVersion": 1
},
{
"id": "f20fc7d1-22ae-4096-a51a-cf9c64114cbc",
"name": "Sticky Note",
"type": "n8n-nodes-base.stickyNote",
"position": [
864,
5824
],
"parameters": {
"color": 3,
"width": 336,
"height": 352,
"content": "## PROJECT CONFIG \u2014 change only this NODE \n### Variables to change:\n(**Project Key**, **Jira Domain**, **Email To**) "
},
"typeVersion": 1
},
{
"id": "4bba9917-977e-4e8e-be19-a54e38f44552",
"name": "Sticky Note25",
"type": "n8n-nodes-base.stickyNote",
"position": [
2464,
5952
],
"parameters": {
"color": 7,
"width": 1824,
"height": 1184,
"content": "## BUILDING BLOCK (Email, Pdf, Markdown) \u2692\ufe0f"
},
"typeVersion": 1
},
{
"id": "e5c47e57-0022-484d-a3cf-bf04fa1c5919",
"name": "Sticky Note1",
"type": "n8n-nodes-base.stickyNote",
"position": [
-192,
4672
],
"parameters": {
"width": 368,
"height": 320,
"content": "## Manual testing"
},
"typeVersion": 1
},
{
"id": "af544d3e-3e6f-48d7-9a04-17cfe06a7278",
"name": "Prepare Assignee Rows1",
"type": "n8n-nodes-base.code",
"position": [
5920,
6528
],
"parameters": {
"jsCode": "// Prepare Assignee KPI rows \u2014 reads purely from $input\nconst d = $input.first().json;\nconst rows = (d.kpis?.assigneeKPIs || []);\nif (rows.length === 0) return [{ json: { skipped: true } }];\nreturn rows.map(a => ({ json: {\n report_id: d.id,\n project_key: d.project_key,\n project_short: d.project_short,\n sprint_name: d.sprint_name,\n week_start: d.week_start,\n assignee: a.assignee,\n total_issues: a.total ?? 0,\n done: a.done ?? 0,\n in_progress: a.inProgress ?? 0,\n todo: a.toDo ?? 0,\n blocked: a.blocked ?? 0,\n story_points_done: a.spDone ?? 0,\n story_points_total: a.storyPoints ?? 0,\n avg_lead_time: a.avgLeadTime ?? null,\n completion_rate: a.completionRate ?? 0,\n}}));"
},
"typeVersion": 2
},
{
"id": "730d7a1f-1ab9-4ede-8919-04320afce844",
"name": "Prepare Epic Rows1",
"type": "n8n-nodes-base.code",
"position": [
5920,
6784
],
"parameters": {
"jsCode": "// Prepare Epic Snapshot rows \u2014 reads purely from $input\nconst d = $input.first().json;\nconst epics = d.epicProgress || [];\nif (epics.length === 0) return [{ json: { skipped: true } }];\nreturn epics.map(e => ({ json: {\n report_id: d.id,\n project_key: d.projectKey,\n sprint_name: d.sprintName,\n week_start: d.weekStart,\n epic_key: e.key,\n epic_name: e.name,\n total_issues: e.total ?? 0,\n done_pct: e.donePct ?? 0,\n in_progress_pct: e.inProgPct ?? 0,\n todo_pct: e.todoPct ?? 0,\n}}));"
},
"typeVersion": 2
},
{
"id": "cb84cce3-5c4e-488c-81cb-c1c1c228acef",
"name": "When clicking \u2018Execute workflow\u2019",
"type": "n8n-nodes-base.manualTrigger",
"position": [
-48,
4800
],
"parameters": {},
"typeVersion": 1
},
{
"id": "c3b2504e-6fd0-464a-b9b7-ef7b4aab08f8",
"name": "CREATE TABLES",
"type": "n8n-nodes-base.postgres",
"position": [
624,
5728
],
"parameters": {
"query": "-- Drop old unique constraints and replace with multi-project safe ones\n\nCREATE TABLE IF NOT EXISTS sprint_reports (\n id SERIAL PRIMARY KEY,\n project_key TEXT NOT NULL,\n project_name TEXT, -- full name: \"PFE Project: Agent IA...\"\n project_short TEXT, -- display: \"Agent IA Backlog...\"\n sprint_name TEXT NOT NULL,\n week_start DATE NOT NULL,\n week_end DATE NOT NULL,\n sprint_start DATE,\n sprint_end DATE,\n sprint_pct_elapsed INT,\n sprint_goal TEXT,\n total_issues INT,\n completed_this_week INT,\n updated_this_week INT,\n created_this_week INT,\n due_soon INT,\n html_report TEXT,\n markdown_report TEXT,\n subject_line TEXT,\n status_overview JSONB,\n priority_breakdown JSONB,\n due_soon_issues JSONB,\n created_at TIMESTAMP DEFAULT NOW(),\n\n -- scoped to project + sprint + week (a sprint can span multiple weeks)\n UNIQUE (project_key, sprint_name, week_start)\n);\n\nCREATE TABLE IF NOT EXISTS weekly_kpis (\n id SERIAL PRIMARY KEY,\n report_id INT REFERENCES sprint_reports(id) ON DELETE CASCADE,\n project_key TEXT NOT NULL,\n sprint_name TEXT NOT NULL,\n week_start DATE NOT NULL,\n -- velocity\n story_points_completed INT DEFAULT 0,\n story_points_total INT DEFAULT 0,\n story_points_remaining INT DEFAULT 0,\n -- completion\n completion_rate INT DEFAULT 0, -- %\n throughput_week INT DEFAULT 0,\n throughput_sprint INT DEFAULT 0,\n -- health\n sprint_health TEXT, -- 'On Track' | 'Slight Risk' | 'At Risk' | 'Critical'\n health_gap INT DEFAULT 0, -- pct points behind target\n -- lead time\n avg_lead_time NUMERIC(5,1), -- null = no resolved issues\n min_lead_time NUMERIC(5,1),\n max_lead_time NUMERIC(5,1),\n -- counts\n done_count INT DEFAULT 0,\n in_progress_count INT DEFAULT 0,\n todo_count INT DEFAULT 0,\n blocked_count INT DEFAULT 0,\n overdue_count INT DEFAULT 0,\n -- sub-arrays stored as jsonb (not useful to aggregate row-by-row)\n lead_time_by_priority JSONB,\n blocked_list JSONB,\n overdue_list JSONB,\n created_at TIMESTAMP DEFAULT NOW(),\n\n UNIQUE (project_key, sprint_name, week_start)\n);\n\nCREATE TABLE IF NOT EXISTS assignee_kpis (\n id SERIAL PRIMARY KEY,\n report_id INT REFERENCES sprint_reports(id) ON DELETE CASCADE,\n project_key TEXT NOT NULL,\n project_short TEXT,\n sprint_name TEXT NOT NULL,\n week_start DATE NOT NULL,\n assignee TEXT NOT NULL,\n total_issues INT DEFAULT 0,\n done INT DEFAULT 0,\n in_progress INT DEFAULT 0,\n todo INT DEFAULT 0,\n blocked INT DEFAULT 0,\n story_points_done INT DEFAULT 0,\n story_points_total INT DEFAULT 0,\n avg_lead_time NUMERIC(5,1),\n completion_rate INT DEFAULT 0,\n created_at TIMESTAMP DEFAULT NOW(),\n\n UNIQUE (project_key, sprint_name, week_start, assignee)\n);\n\nCREATE TABLE IF NOT EXISTS epic_snapshots (\n id SERIAL PRIMARY KEY,\n report_id INT REFERENCES sprint_reports(id) ON DELETE CASCADE,\n project_key TEXT NOT NULL,\n sprint_name TEXT NOT NULL,\n week_start DATE NOT NULL,\n epic_key TEXT NOT NULL,\n epic_name TEXT,\n total_issues INT DEFAULT 0,\n done_pct INT DEFAULT 0,\n in_progress_pct INT DEFAULT 0,\n todo_pct INT DEFAULT 0,\n created_at TIMESTAMP DEFAULT NOW(),\n\n UNIQUE (project_key, sprint_name, week_start, epic_key)\n);\n\n-- Indexes covering every query pattern to run\nCREATE INDEX ON sprint_reports (project_key, week_start); -- SELECT * FROM sprint_reports WHERE project_key='AIBAP' AND week_start='2026-03-30';\nCREATE INDEX ON sprint_reports (sprint_name); -- SELECT * FROM sprint_reports WHERE sprint_name='Sprint 24';\nCREATE INDEX ON weekly_kpis (project_key, week_start); -- SELECT * FROM weekly_kpis WHERE project_key='AIBAP' AND week_start='2026-03-30';\nCREATE INDEX ON weekly_kpis (sprint_name);\nCREATE INDEX ON assignee_kpis (project_key, assignee, week_start);\nCREATE INDEX ON assignee_kpis (assignee); -- cross-project: \"show me assignee across all projects\", SELECT * FROM assignee_kpis WHERE assignee='John';\nCREATE INDEX ON epic_snapshots(project_key, epic_key, week_start); -- SELECT * FROM epic_snapshots WHERE project_key='AIBAP' AND epic_key='EPIC-123' AND week_start='2026-03-30';",
"options": {},
"operation": "executeQuery"
},
"credentials": {
"postgres": {
"name": "<your credential>"
}
},
"retryOnFail": true,
"typeVersion": 2.6,
"alwaysOutputData": true,
"waitBetweenTries": 5000
},
{
"id": "b80f62ec-0175-4c35-9f31-d9676927505f",
"name": "CONFIGURATION NODE",
"type": "n8n-nodes-base.code",
"position": [
944,
6032
],
"parameters": {
"jsCode": "// ============================================================\n// PROJECT CONFIG \u2014 change only this block per workflow instance\n// Duplicate this workflow for each Jira project, update below.\n// ============================================================\n\nconst PROJECT_KEY = 'AIBAP';\nconst JIRA_BASE_URL = 'https://your-domain.atlassian.net'; // \u2190 change to your Jira domain\nconst EMAIL_TO = 'user@example.com';\n\n// \u2500\u2500 Date range (automatic) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst now = new Date();\nconst sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);\nconst sevenDaysAhead = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);\n\nconst fmtISO = (d) => d.toISOString().split('T')[0];\nconst fmtLong = (d) => d.toLocaleDateString('en-GB', {\n day: 'numeric', month: 'long', year: 'numeric'\n});\n\nreturn [{\n json: {\n projectKey: PROJECT_KEY,\n jiraBaseUrl: JIRA_BASE_URL,\n emailTo: EMAIL_TO,\n\n today: fmtISO(now),\n sevenDaysAgo: fmtISO(sevenDaysAgo),\n sevenDaysAhead:fmtISO(sevenDaysAhead),\n dateRange: `${fmtLong(sevenDaysAgo)} \u2014 ${fmtLong(now)}`,\n weekLabel: `Week of ${fmtLong(sevenDaysAgo)}`,\n weekStart: fmtISO(sevenDaysAgo),\n weekEnd: fmtISO(now),\n }\n}];"
},
"typeVersion": 2
},
{
"id": "69eb136c-ac71-4936-bbfe-4a314bf11e1c",
"name": "Get Updated Issues (7d)",
"type": "n8n-nodes-base.jira",
"position": [
1376,
5840
],
"parameters": {
"options": {
"jql": "=project = \"{{ $input.first().json.projectKey }}\" AND sprint in openSprints() AND updated >= -7d ORDER BY updated DESC"
},
"operation": "getAll",
"returnAll": true
},
"credentials": {
"jiraSoftwareCloudApi": {
"name": "<your credential>"
}
},
"typeVersion": 1
},
{
"id": "426a018d-11c8-4586-b9b3-3da7cf01c966",
"name": "Get Created Issues (7d)",
"type": "n8n-nodes-base.jira",
"position": [
1376,
6032
],
"parameters": {
"options": {
"jql": "=project = \"{{ $input.first().json.projectKey }}\" AND sprint in openSprints() AND created >= -7d"
},
"operation": "getAll",
"returnAll": true
},
"credentials": {
"jiraSoftwareCloudApi": {
"name": "<your credential>"
}
},
"typeVersion": 1
},
{
"id": "72ad2e90-9db8-4522-80d4-4a1993098e7c",
"name": "Get Completed Issues (7d)",
"type": "n8n-nodes-base.jira",
"position": [
1376,
6208
],
"parameters": {
"options": {
"jql": "=project = \"{{ $input.first().json.projectKey }}\" AND sprint in openSprints() AND statusCategory in (\"Done\") AND updated >= -7d"
},
"operation": "getAll",
"returnAll": true
},
"credentials": {
"jiraSoftwareCloudApi": {
"name": "<your credential>"
}
},
"typeVersion": 1
},
{
"id": "a03b8110-5ec1-4092-a58b-fc484b8a84d0",
"name": "Get Due Soon Issues",
"type": "n8n-nodes-base.jira",
"position": [
1376,
6400
],
"parameters": {
"options": {
"jql": "=project = \"{{ $input.first().json.projectKey }}\" AND sprint in openSprints() AND duedate >= now() AND duedate <= endOfDay(\"+7d\") AND statusCategory != \"Done\""
},
"operation": "getAll",
"returnAll": true
},
"credentials": {
"jiraSoftwareCloudApi": {
"name": "<your credential>"
}
},
"typeVersion": 1
},
{
"id": "397dead5-76e3-45f6-95b4-b09e598a5f29",
"name": "Get All Epics",
"type": "n8n-nodes-base.jira",
"position": [
1376,
6560
],
"parameters": {
"options": {
"jql": "=project = \"{{ $input.first().json.projectKey }}\" AND issuetype = Epic"
},
"operation": "getAll",
"returnAll": true
},
"credentials": {
"jiraSoftwareCloudApi": {
"name": "<your credential>"
}
},
"typeVersion": 1
},
{
"id": "0e27063a-275e-4f65-b487-54623d9f74ef",
"name": "Get All Issues (Status Overview)",
"type": "n8n-nodes-base.jira",
"position": [
1376,
6736
],
"parameters": {
"options": {
"jql": "=project = \"{{ $input.first().json.projectKey }}\" AND sprint in openSprints()"
},
"operation": "getAll",
"returnAll": true
},
"credentials": {
"jiraSoftwareCloudApi": {
"name": "<your credential>"
}
},
"typeVersion": 1
},
{
"id": "e785939e-7f04-4f6b-aa8c-e136fc5a82f1",
"name": "Labels Config",
"type": "n8n-nodes-base.code",
"position": [
1376,
6960
],
"parameters": {
"jsCode": "// Stamp the config item with _type = 'config'\nreturn $input.all().map(item => ({\n json: { ...item.json, _type: 'config' }\n}));"
},
"typeVersion": 2
},
{
"id": "91b11225-b274-409d-a82d-ef80a393b102",
"name": "WAIT_FOR_BODY_ATTACHEMENT",
"type": "n8n-nodes-base.merge",
"position": [
4096,
6784
],
"parameters": {
"mode": "combine",
"options": {},
"combineBy": "combineAll"
},
"typeVersion": 3.2
},
{
"id": "78bdce21-c456-46ec-8c35-4d4fd443d8bb",
"name": "HTML",
"type": "n8n-nodes-base.html",
"position": [
3456,
6080
],
"parameters": {
"html": "{{ $input.last().json.html }}"
},
"typeVersion": 1.2
},
{
"id": "ad7874c1-ac06-4d6c-9eb4-5b06978b0072",
"name": "Build Email HTML",
"type": "n8n-nodes-base.code",
"position": [
2944,
6080
],
"parameters": {
"jsCode": "// ============================================================\n// BUILD EMAIL HTML \u2014 Slim digest v2 (business-reader restructure)\n// Order: Header \u2192 Alert \u2192 Exec Summary \u2192 Health+Timeline \u2192\n// 3 Key Numbers \u2192 Donut Status \u2192 Blocked Detail \u2192 CTA \u2192 Footer\n// ============================================================\n\nconst d = $input.first().json;\nconst {\n projectKey, projectName, projectShort,\n sprintName, sprintGoal, sprintDates, sprintStartFmt, sprintEndFmt,\n sprintPct, sprintDaysLeft,\n reportTitle, weekLabel, dateRange,\n metrics, kpis, statusOverview, dueSoonIssues\n} = d;\n\nconst JIRA_BASE = $('CONFIGURATION NODE').first().json.jiraBaseUrl + \"/browse\";\nconst BRAND = '#0F3D6E';\nconst BRAND_ACC = '#0052CC';\nconst SURFACE = '#FFFFFF';\nconst SURFACE2 = '#F4F5F7';\nconst BORDER = '#DFE1E6';\nconst TEXT1 = '#172B4D';\nconst TEXT2 = '#5E6C84';\nconst TEXT3 = '#8993A4';\n\nconst STATUS_COLOR_MAP = {\n 'Done':'#36B37E','Termin\u00e9':'#36B37E',\n 'In Progress':'#0052CC','En cours':'#0052CC',\n 'In Review':'#6554C0','En r\u00e9vision':'#6554C0','Revue en cours':'#6554C0',\n 'To Do':'#DFE1E6','\u00c0 faire':'#DFE1E6','Nouveau':'#DFE1E6','New':'#DFE1E6',\n 'Blocked':'#BF2600'\n};\n\nconst normalizeStatus = (s) => {\n if (!s) return 'Unknown';\n const map = {\n 'Done':'Done','Termin\u00e9':'Done',\n 'In Progress':'In Progress','En cours':'In Progress',\n 'In Review':'In Review','En r\u00e9vision':'In Review','Revue en cours':'In Review',\n 'To Do':'To Do','\u00c0 faire':'To Do','Nouveau':'To Do','New':'To Do',\n 'Blocked':'Blocked',\n };\n if (map[s]) return map[s];\n const sl = s.toLowerCase();\n if (sl.startsWith('termin\u00e9') || sl.startsWith('termine')) return 'Done';\n if (sl.startsWith('en cours')) return 'In Progress';\n if (sl.includes('r\u00e9vision') || sl.includes('review')) return 'In Review';\n if (sl.startsWith('\u00e0 faire') || sl === 'nouveau' || sl === 'new') return 'To Do';\n return s;\n};\n\n// \u2500\u2500 Health colors \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst healthColor = {'On Track':'#006644','Slight Risk':'#FF8B00','At Risk':'#BF2600','Critical':'#BF2600'}[kpis.sprintHealth] || TEXT2;\nconst healthBg = {'On Track':'#E3FCEF','Slight Risk':'#FFFAE6','At Risk':'#FFEBE6','Critical':'#FFEBE6'}[kpis.sprintHealth] || SURFACE2;\nconst healthBorder= {'On Track':'#36B37E','Slight Risk':'#FF8B00','At Risk':'#BF2600','Critical':'#BF2600'}[kpis.sprintHealth] || BORDER;\n\n// \u2500\u2500 Sprint timeline helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst daysLabel = sprintDaysLeft===0 ? 'Sprint ended'\n : sprintDaysLeft===1 ? '1 day left'\n : sprintDaysLeft!==null ? `${sprintDaysLeft} days left` : '';\nconst daysColor = sprintDaysLeft===0 ? '#BF2600' : (sprintPct>=80 ? '#FF8B00' : TEXT2);\nconst donePct = metrics.totalIssues > 0\n ? Math.round((statusOverview.find(s=>s.status==='Done'||s.status==='Termin\u00e9')?.count||0) / metrics.totalIssues * 100) : 0;\nconst barColor = (sprintPct>=90 && donePct<60) ? '#BF2600' : (sprintPct>=70 && donePct<40) ? '#FF8B00' : '#36B37E';\nconst sprintBandFill = Math.max(0, Math.min(100, sprintPct));\n\n// \u2500\u2500 Donut SVG \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst totalIssues = metrics.totalIssues || 1;\nconst R_OUT=54, R_IN=34, CX=64, CY=64, GAP=0.04;\nlet angle = -Math.PI/2;\nconst donutPaths = statusOverview.map(s => {\n const frac = s.count/totalIssues;\n const sweep = frac*2*Math.PI - GAP;\n if (sweep<=0) return '';\n const sA=angle+GAP/2, eA=sA+sweep;\n angle += frac*2*Math.PI;\n const x1=CX+R_OUT*Math.cos(sA),y1=CY+R_OUT*Math.sin(sA);\n const x2=CX+R_OUT*Math.cos(eA),y2=CY+R_OUT*Math.sin(eA);\n const x3=CX+R_IN*Math.cos(eA), y3=CY+R_IN*Math.sin(eA);\n const x4=CX+R_IN*Math.cos(sA), y4=CY+R_IN*Math.sin(sA);\n const lg=sweep>Math.PI?1:0;\n const col=STATUS_COLOR_MAP[s.status]||'#8993A4';\n return `<path d=\"M${x1.toFixed(2)},${y1.toFixed(2)} A${R_OUT},${R_OUT} 0 ${lg},1 ${x2.toFixed(2)},${y2.toFixed(2)} L${x3.toFixed(2)},${y3.toFixed(2)} A${R_IN},${R_IN} 0 ${lg},0 ${x4.toFixed(2)},${y4.toFixed(2)} Z\" fill=\"${col}\"/>`;\n}).join('');\n\nconst donutSvg = `<svg width=\"110\" height=\"110\" viewBox=\"0 0 128 128\" xmlns=\"http://www.w3.org/2000/svg\">\n ${donutPaths}\n <text x=\"64\" y=\"58\" text-anchor=\"middle\" font-size=\"22\" font-weight=\"700\" fill=\"${TEXT1}\" font-family=\"-apple-system,Arial,sans-serif\">${metrics.totalIssues}</text>\n <text x=\"64\" y=\"75\" text-anchor=\"middle\" font-size=\"10\" fill=\"${TEXT2}\" font-family=\"-apple-system,Arial,sans-serif\">issues</text>\n</svg>`;\n\nconst statusLegend = statusOverview.map(s => {\n const col = STATUS_COLOR_MAP[s.status]||'#8993A4';\n const pct = Math.round(s.count/totalIssues*100);\n return `<tr>\n <td style=\"padding:4px 8px 4px 0;width:12px;\"><div style=\"width:8px;height:8px;border-radius:2px;background:${col};\"></div></td>\n <td style=\"padding:4px 0;font-size:11px;color:${TEXT1};\">${normalizeStatus(s.status)}</td>\n <td style=\"padding:4px 0;text-align:right;white-space:nowrap;\">\n <span style=\"font-size:11px;font-weight:700;color:${TEXT1};\">${s.count}</span>\n <span style=\"font-size:10px;color:${TEXT3};margin-left:2px;\">(${pct}%)</span>\n </td>\n </tr>`;\n}).join('');\n\n// \u2500\u2500 Derived values \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst hasRisks = kpis.blockedCount > 0 || kpis.overdueCount > 0;\nconst isComplete = sprintDaysLeft === 0;\n\n// Health reason hint \u2014 tells manager WHY there's a risk\nconst healthReason = (() => {\n if (kpis.blockedCount > 0 && kpis.overdueCount > 0)\n return `${kpis.blockedCount} blocked \u00b7 ${kpis.overdueCount} overdue`;\n if (kpis.blockedCount > 0)\n return `${kpis.blockedCount} issue${kpis.blockedCount>1?'s':''} blocked`;\n if (kpis.overdueCount > 0)\n return `${kpis.overdueCount} item${kpis.overdueCount>1?'s':''} overdue`;\n return kpis.healthGap > 0 ? `${kpis.healthGap}% behind target` : 'Ahead of schedule';\n})();\n\n// Executive summary \u2014 one sentence a manager can act on\nconst execSummary = (() => {\n const parts = [];\n parts.push(`Sprint is <strong>${isComplete ? 'complete' : sprintPct + '% elapsed'}</strong> with <strong>${kpis.completionRate}% of work delivered</strong> (${kpis.doneCount} of ${metrics.totalIssues} issues done).`);\n if (kpis.blockedCount > 0)\n parts.push(`<strong style=\"color:#BF2600;\">${kpis.blockedCount} issue${kpis.blockedCount>1?'s are':' is'} blocked</strong> and require${kpis.blockedCount===1?'s':''} immediate attention.`);\n if (kpis.overdueCount > 0)\n parts.push(`<strong style=\"color:#BF2600;\">${kpis.overdueCount} item${kpis.overdueCount>1?'s are':' is'} overdue</strong>.`);\n if (!hasRisks)\n parts.push('No blockers or overdue items. \ud83d\udfe2');\n return parts.join(' ');\n})();\n\n// \u2500\u2500 1. Alert banner (risks only) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst alertBanner = hasRisks ? `\n<tr><td style=\"background:#FFF3CD;border-left:4px solid #FF8B00;border-right:1px solid ${BORDER};padding:11px 20px;\">\n <table width=\"100%\" cellpadding=\"0\" cellspacing=\"0\"><tr valign=\"middle\">\n <td style=\"font-size:10px;font-weight:700;color:#7A4F00;text-transform:uppercase;letter-spacing:0.07em;width:1%;white-space:nowrap;padding-right:12px;\">\u26a0 Action Required</td>\n <td style=\"font-size:11px;color:#5C3D00;line-height:1.5;\">\n ${(kpis.blockedList||[]).map(b=>`<strong>${b.key}</strong> blocked ${b.daysBlocked}d \u2014 ${b.assignee}`).join(' \u00b7 ')}\n ${(kpis.overdueList||[]).length > 0 ? ' \u00b7 ' + kpis.overdueList.map(o=>`<strong>${o.key}</strong> overdue ${o.daysOverdue}d`).join(', ') : ''}\n </td>\n </tr></table>\n</td></tr>` : '';\n\n// \u2500\u2500 2. Exec summary bar \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst execSummaryRow = `\n<tr><td style=\"background:${SURFACE2};border-left:1px solid ${BORDER};border-right:1px solid ${BORDER};border-top:1px solid ${BORDER};padding:12px 24px;\">\n <div style=\"font-size:12px;color:${TEXT1};line-height:1.7;\">${execSummary}</div>\n</td></tr>`;\n\n// \u2500\u2500 3. Sprint Health + Timeline combined \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst healthAndTimeline = `\n<tr><td style=\"background:${healthBg};border-left:1px solid ${BORDER};border-right:1px solid ${BORDER};border-top:4px solid ${healthBorder};padding:16px 30px;\">\n <table width=\"100%\" cellpadding=\"0\" cellspacing=\"0\"><tr valign=\"middle\">\n\n <!-- Health status -->\n <td style=\"padding-right:20px;border-right:1px solid rgba(0,0,0,0.08);white-space:nowrap;\">\n <div style=\"font-size:9px;font-weight:700;color:${healthColor};text-transform:uppercase;letter-spacing:0.08em;margin-bottom:4px;\">Sprint Health</div>\n <div style=\"font-size:20px;font-weight:700;color:${healthColor};line-height:1;\">${kpis.sprintHealth}</div>\n <div style=\"font-size:10px;color:${TEXT2};margin-top:4px;\">${healthReason}</div>\n </td>\n\n <!-- Timeline bar -->\n <td style=\"padding-left:20px;\">\n <table width=\"100%\" cellpadding=\"0\" cellspacing=\"0\">\n <tr>\n <td style=\"padding-bottom:5px;font-size:9px;font-weight:700;letter-spacing:0.07em;color:${TEXT3};text-transform:uppercase;\">Sprint Timeline</td>\n <td style=\"padding-bottom:5px;text-align:right;font-size:9px;font-weight:700;color:${daysColor};\">${daysLabel}</td>\n </tr>\n <tr><td colspan=\"2\">\n <table width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" style=\"background:#DFE1E6;border-radius:4px;height:8px;\">\n <tr>\n <td width=\"${sprintBandFill}%\" style=\"background:${barColor};height:8px;border-radius:4px;font-size:0;line-height:0;\"> </td>\n <td style=\"height:8px;font-size:0;line-height:0;\"> </td>\n </tr>\n </table>\n </td></tr>\n <tr>\n <td style=\"padding-top:4px;font-size:9px;color:${TEXT3};\">${sprintStartFmt}</td>\n <td style=\"padding-top:4px;text-align:right;font-size:9px;color:${TEXT3};\">${sprintEndFmt}</td>\n </tr>\n </table>\n </td>\n\n </tr></table>\n</td></tr>`;\n\n// \u2500\u2500 4. Unified metrics section \u2014 all numbers in one block \u2500\u2500\u2500\u2500\u2500\n// Row 1 (primary): Completion \u00b7 Blocked \u00b7 Due Soon \u2014 large, action-oriented\n// Row 2 (secondary): Completed \u00b7 Updated \u00b7 Created \u00b7 Avg Delivery \u2014 compact week context\nconst unifiedMetrics = `\n<tr><td style=\"background:${SURFACE};border-left:1px solid ${BORDER};border-right:1px solid ${BORDER};border-top:1px solid ${BORDER};padding:18px 24px 14px;\">\n\n <!-- Section label -->\n <div style=\"font-size:9px;font-weight:700;color:${TEXT3};text-transform:uppercase;letter-spacing:0.08em;margin-bottom:14px;\">Sprint at a Glance</div>\n\n <!-- Row 1: 3 primary KPIs \u2014 large numbers, colour-coded by urgency -->\n <table width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" style=\"table-layout:fixed;margin-bottom:0;\"><tr valign=\"top\">\n\n <td style=\"padding-right:5px;\">\n <table width=\"100%\" cellpadding=\"0\" cellspacing=\"0\"><tr>\n <td style=\"border-left:3px solid #36B37E;background:#F2FBF6;border-radius:0 5px 5px 0;padding:11px 13px;\">\n <div style=\"font-size:9px;font-weight:700;color:#006644;text-transform:uppercase;letter-spacing:0.08em;margin-bottom:4px;\">Completion</div>\n <div style=\"font-size:26px;font-weight:700;color:#006644;line-height:1;\">${kpis.completionRate}%</div>\n <div style=\"font-size:10px;color:${TEXT3};margin-top:4px;\">${kpis.doneCount} of ${metrics.totalIssues} done</div>\n </td>\n </tr></table>\n </td>\n\n <td style=\"padding:0 5px;\">\n <table width=\"100%\" cellpadding=\"0\" cellspacing=\"0\"><tr>\n <td style=\"border-left:3px solid ${kpis.blockedCount>0?'#BF2600':BORDER};background:${kpis.blockedCount>0?'#FFEBE6':SURFACE2};border-radius:0 5px 5px 0;padding:11px 13px;\">\n <div style=\"font-size:9px;font-weight:700;color:${kpis.blockedCount>0?'#BF2600':TEXT3};text-transform:uppercase;letter-spacing:0.08em;margin-bottom:4px;\">Blocked</div>\n <div style=\"font-size:26px;font-weight:700;color:${kpis.blockedCount>0?'#BF2600':TEXT3};line-height:1;\">${kpis.blockedCount}</div>\n <div style=\"font-size:10px;color:${TEXT3};margin-top:4px;\">${kpis.blockedCount>0?'need attention':'no blockers \ud83d\udfe2'}</div>\n </td>\n </tr></table>\n </td>\n\n <td style=\"padding-left:5px;\">\n <table width=\"100%\" cellpadding=\"0\" cellspacing=\"0\"><tr>\n <td style=\"border-left:3px solid ${metrics.dueSoon>0?'#FF8B00':BORDER};background:${metrics.dueSoon>0?'#FFFAE6':SURFACE2};border-radius:0 5px 5px 0;padding:11px 13px;\">\n <div style=\"font-size:9px;font-weight:700;color:${metrics.dueSoon>0?'#FF8B00':TEXT3};text-transform:uppercase;letter-spacing:0.08em;margin-bottom:4px;\">Due Soon</div>\n <div style=\"font-size:26px;font-weight:700;color:${metrics.dueSoon>0?'#FF8B00':TEXT3};line-height:1;\">${metrics.dueSoon}</div>\n <div style=\"font-size:10px;color:${TEXT3};margin-top:4px;\">next 7 days</div>\n </td>\n </tr></table>\n </td>\n\n </tr></table>\n\n <!-- Divider with label -->\n <table width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" style=\"margin:12px 0 10px;\">\n <tr valign=\"middle\">\n <td style=\"border-top:1px solid ${BORDER};font-size:0;\"> </td>\n <td style=\"white-space:nowrap;padding:0 10px;font-size:9px;font-weight:700;color:${TEXT3};text-transform:uppercase;letter-spacing:0.07em;\">This Week</td>\n <td style=\"border-top:1px solid ${BORDER};font-size:0;\"> </td>\n </tr>\n </table>\n\n <!-- Row 2: 4 weekly numbers \u2014 compact, equal columns -->\n <table width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" style=\"table-layout:fixed;\"><tr valign=\"middle\">\n\n <td style=\"text-align:center;padding:8px 4px;border-right:1px solid ${BORDER};\">\n <div style=\"font-size:20px;font-weight:700;color:#006644;line-height:1;\">${metrics.completed}</div>\n <div style=\"font-size:9px;font-weight:700;color:${TEXT3};text-transform:uppercase;letter-spacing:0.06em;margin-top:4px;\">Completed</div>\n </td>\n\n <td style=\"text-align:center;padding:8px 4px;border-right:1px solid ${BORDER};\">\n <div style=\"font-size:20px;font-weight:700;color:#0052CC;line-height:1;\">${metrics.updated}</div>\n <div style=\"font-size:9px;font-weight:700;color:${TEXT3};text-transform:uppercase;letter-spacing:0.06em;margin-top:4px;\">Updated</div>\n </td>\n\n <td style=\"text-align:center;padding:8px 4px;border-right:1px solid ${BORDER};\">\n <div style=\"font-size:20px;font-weight:700;color:#6554C0;line-height:1;\">${metrics.created}</div>\n <div style=\"font-size:9px;font-weight:700;color:${TEXT3};text-transform:uppercase;letter-spacing:0.06em;margin-top:4px;\">Created</div>\n </td>\n\n <td style=\"text-align:center;padding:8px 4px;\">\n <div style=\"font-size:20px;font-weight:700;color:#6554C0;line-height:1;\">${kpis.avgLeadTime !== null ? kpis.avgLeadTime+'d' : '\u2014'}</div>\n <div style=\"font-size:9px;font-weight:700;color:${TEXT3};text-transform:uppercase;letter-spacing:0.06em;margin-top:4px;\">Avg Delivery</div>\n </td>\n\n </tr></table>\n\n</td></tr>`;\n\n// \u2500\u2500 5. Sprint Status donut \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst donutSection = `\n<tr><td style=\"background:${SURFACE};border-left:1px solid ${BORDER};border-right:1px solid ${BORDER};border-top:1px solid ${BORDER};padding:18px 30px;\">\n <div style=\"font-size:11px;font-weight:700;color:${TEXT3};text-transform:uppercase;letter-spacing:0.07em;margin-bottom:14px;\">Issue Breakdown</div>\n <table width=\"100%\" cellpadding=\"0\" cellspacing=\"0\"><tr valign=\"middle\">\n <td width=\"120\" style=\"text-align:center;\">${donutSvg}</td>\n <td style=\"padding-left:20px;\"><table width=\"100%\" cellpadding=\"0\" cellspacing=\"0\">${statusLegend}</table></td>\n </tr></table>\n</td></tr>`;\n\n// \u2500\u2500 6. Blocked detail (no duplication \u2014 replaces standalone card) \u2500\u2500\nconst blockedDetail = (kpis.blockedList||[]).length === 0 ? '' : `\n<tr><td style=\"background:#FFEBE6;border-left:4px solid #BF2600;border-right:1px solid ${BORDER};border-top:1px solid ${BORDER};padding:16px 30px;\">\n <div style=\"font-size:11px;font-weight:700;color:#BF2600;text-transform:uppercase;letter-spacing:0.07em;margin-bottom:12px;\">\n Blocked Items \u2014 Immediate Attention Required\n </div>\n ${(kpis.blockedList||[]).slice(0,3).map(b=>`\n <table width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" style=\"margin-bottom:8px;\"><tr valign=\"top\">\n <td style=\"width:1%;white-space:nowrap;padding-right:10px;\">\n <a href=\"${JIRA_BASE}/${b.key}\" style=\"font-size:11px;font-weight:700;color:${BRAND_ACC};text-decoration:none;\">${b.key}</a>\n </td>\n <td>\n <div style=\"font-size:12px;color:${TEXT1};font-weight:500;line-height:1.4;\">${b.summary}</div>\n <div style=\"font-size:10px;color:${TEXT3};margin-top:2px;\">\n ${b.assignee}${b.daysBlocked!==null?` \u00b7 Blocked for <strong style=\"color:#BF2600;\">${b.daysBlocked} days</strong>`:''}\n ${b.priority ? ` \u00b7 ${b.priority}` : ''}\n </div>\n </td>\n </tr></table>`).join('')}\n</td></tr>`;\n\n// \u2500\u2500 7. PDF CTA \u2014 prominent \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst ctaRow = `\n<tr><td style=\"background:${BRAND};border-left:1px solid ${BORDER};border-right:1px solid ${BORDER};border-top:1px solid ${BORDER};padding:16px 30px;\">\n <table width=\"100%\" cellpadding=\"0\" cellspacing=\"0\"><tr valign=\"middle\">\n <td>\n <div style=\"font-size:12px;font-weight:700;color:#FFFFFF;margin-bottom:3px;\">\ud83d\udcce Full Report Attached</div>\n <div style=\"font-size:10px;color:rgba(255,255,255,0.55);\">Includes activity log, theme progress, team breakdown & delivery time analysis</div>\n </td>\n <td style=\"text-align:right;white-space:nowrap;padding-left:16px;\">\n <div style=\"display:inline-block;background:rgba(255,255,255,0.15);border:1px solid rgba(255,255,255,0.3);border-radius:4px;padding:7px 16px;font-size:11px;font-weight:700;color:#FFFFFF;\">View PDF \u2192</div>\n </td>\n </tr></table>\n</td></tr>`;\n\n// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n// ASSEMBLE\n// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\nconst html = `<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"UTF-8\">\n<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n<title>${reportTitle}</title>\n</head>\n<body style=\"margin:0;padding:0;background:#E4E8EF;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','Helvetica Neue',Arial,sans-serif;\">\n\n<table width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" style=\"background:#E4E8EF;padding:28px 0;\">\n<tr><td align=\"center\">\n<table width=\"620\" cellpadding=\"0\" cellspacing=\"0\" style=\"max-width:620px;width:100%;\">\n\n<!-- 1. HEADER -->\n<tr><td style=\"background:${BRAND};border-radius:8px 8px 0 0;padding:26px 30px;\">\n <table width=\"100%\" cellpadding=\"0\" cellspacing=\"0\"><tr valign=\"top\">\n <td style=\"padding-right:16px;\">\n <div style=\"font-size:9px;font-weight:700;letter-spacing:0.1em;color:rgba(255,255,255,0.4);text-transform:uppercase;margin-bottom:8px;\">${projectKey} / Weekly Status Report</div>\n <div style=\"font-size:19px;font-weight:700;color:#FFFFFF;line-height:1.3;margin-bottom:5px;\">${projectShort}</div>\n <div style=\"font-size:11px;color:rgba(255,255,255,0.5);\">${dateRange}</div>\n ${sprintGoal?`<div style=\"font-size:11px;color:rgba(255,255,255,0.45);margin-top:8px;border-left:2px solid rgba(255,255,255,0.2);padding-left:8px;font-style:italic;\">Goal: ${sprintGoal}</div>`:''}\n </td>\n <td style=\"width:170px;vertical-align:top;\">\n <table width=\"170\" cellpadding=\"0\" cellspacing=\"0\" style=\"background:rgba(255,255,255,0.11);border:1px solid rgba(255,255,255,0.15);border-radius:6px;\">\n <tr><td style=\"padding:12px 14px;\">\n <div style=\"font-size:9px;font-weight:700;letter-spacing:0.09em;color:rgba(255,255,255,0.4);text-transform:uppercase;margin-bottom:5px;\">Active Sprint</div>\n <div style=\"font-size:13px;font-weight:700;color:#FFFFFF;margin-bottom:2px;\">${sprintName}</div>\n <div style=\"font-size:10px;color:rgba(255,255,255,0.5);margin-bottom:10px;\">${sprintDates}</div>\n <table width=\"100%\" cellpadding=\"0\" cellspacing=\"0\"><tr>\n <td style=\"font-size:9px;color:rgba(255,255,255,0.4);\">${sprintPct}% elapsed</td>\n <td style=\"text-align:right;font-size:9px;font-weight:700;color:${sprintDaysLeft===0?'#FF8B00':'rgba(255,255,255,0.6)'};\">${daysLabel}</td>\n </tr></table>\n </td></tr></table>\n </td>\n </tr></table>\n</td></tr>\n\n<!-- 2. ALERT BANNER (risks only) -->\n${alertBanner}\n\n<!-- 3. EXECUTIVE SUMMARY -->\n${execSummaryRow}\n\n<!-- 4. SPRINT HEALTH + TIMELINE combined -->\n${healthAndTimeline}\n\n<!-- 5. UNIFIED METRICS \u2014 all numbers in one section -->\n${unifiedMetrics}\n\n<!-- 6. ISSUE BREAKDOWN DONUT -->\n${donutSection}\n\n<!-- 7. BLOCKED DETAIL (only if blockers exist) -->\n${blockedDetail}\n\n<!-- 8. PDF CTA \u2014 prominent -->\n${ctaRow}\n\n<!-- BOTTOM PADDING -->\n<tr><td style=\"background:${SURFACE};border-left:1px solid ${BORDER};border-right:1px solid ${BORDER};padding-bottom:16px;\"></td></tr>\n\n<!-- 10. FOOTER -->\n<tr><td style=\"background:${BRAND};border-radius:0 0 8px 8px;padding:16px 30px;\">\n <table width=\"100%\" cellpadding=\"0\" cellspacing=\"0\"><tr valign=\"top\">\n <td>\n <div style=\"font-size:11px;color:rgba(255,255,255,0.85);\">Auto-generated by the <strong style=\"color:#FFFFFF;\">n8n Reporting Agent</strong> for <strong style=\"color:#FFFFFF;\">${projectName}</strong></div>\n <div style=\"font-size:10px;color:rgba(255,255,255,0.38);margin-top:4px;\">${sprintName} \u00b7 ${sprintDates} \u00b7 Jira \u2014 IG Green Team</div>\n </td>\n <td style=\"text-align:right;padding-left:16px;white-space:nowrap;\">\n <div style=\"font-size:10px;color:rgba(255,255,255,0.3);\">Do not reply \u00b7 Sent Mondays 08:00</div>\n </td>\n </tr></table>\n</td></tr>\n\n</table>\n</td></tr>\n</table>\n</body>\n</html>`;\n\nreturn [{ json: {\n html,\n subject: `\ud83d\udcca ${sprintName} \u2014 Weekly Report ${dateRange} \u2014 ${projectShort}`,\n reportTitle\n} }];"
},
"typeVersion": 2
},
{
"id": "018ab1c3-df37-4360-addf-72f4dac75551",
"name": "Build Full HTML",
"type": "n8n-nodes-base.code",
"position": [
2944,
6288
],
"parameters": {
"jsCode": "// ============================================================\n// BUILD HTML REPORT \u2014 Sprint Edition v8\n// ============================================================\n\nconst d = $input.first().json;\nconst {\n projectKey, projectName, projectShort,\n sprintName, sprintGoal, sprintDates, sprintStartFmt, sprintEndFmt,\n sprintPct, sprintDaysLeft,\n reportTitle, weekLabel, dateRange,\n metrics, kpis, statusOverview, priorityBreakdown,\n teamWorkload, epicProgress, recentActivity,\n dueSoonIssues\n} = d;\n\nconst JIRA_BASE = $('CONFIGURATION NODE').first().json.jiraBaseUrl + \"/browse\";\n\n// \u2500\u2500 Design tokens \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst BRAND = '#0F3D6E';\nconst BRAND_ACC = '#0052CC';\nconst SURFACE = '#FFFFFF';\nconst SURFACE2 = '#F4F5F7';\nconst BORDER = '#DFE1E6';\nconst TEXT1 = '#172B4D';\nconst TEXT2 = '#5E6C84';\nconst TEXT3 = '#8993A4';\n\n// \u2500\u2500 Status helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst statusColor = (s) => ({\n 'Done':'#006644','Termin\u00e9':'#006644',\n 'In Progress':'#0052CC','En cours':'#0052CC',\n 'In Review':'#6554C0','En r\u00e9vision':'#6554C0','Revue en cours':'#6554C0',\n 'To Do':'#42526E','\u00c0 faire':'#42526E','Nouveau':'#42526E','New':'#42526E',\n 'Blocked':'#BF2600'\n})[s] || '#42526E';\n\nconst statusBg = (s) => ({\n 'Done':'#E3FCEF','Termin\u00e9':'#E3FCEF',\n 'In Progress':'#DEEBFF','En cours':'#DEEBFF',\n 'In Review':'#EAE6FF','En r\u00e9vision':'#EAE6FF','Revue en cours':'#EAE6FF',\n 'To Do':'#F4F5F7','\u00c0 faire':'#F4F5F7','Nouveau':'#F4F5F7','New':'#F4F5F7',\n 'Blocked':'#FFEBE6'\n})[s] || '#F4F5F7';\n\nconst STATUS_COLOR_MAP = {\n 'Done':'#36B37E','Termin\u00e9':'#36B37E',\n 'In Progress':'#0052CC','En cours':'#0052CC',\n 'In Review':'#6554C0','En r\u00e9vision':'#6554C0','Revue en cours':'#6554C0',\n 'To Do':'#DFE1E6','\u00c0 faire':'#DFE1E6','Nouveau':'#DFE1E6','New':'#DFE1E6',\n 'Blocked':'#BF2600'\n};\n\nconst priorityColor = (p) => ({\n 'Highest':'#BF2600','High':'#FF5630',\n 'Medium':'#FF8B00','Low':'#36B37E','Lowest':'#8993A4'\n})[p] || '#8993A4';\n\nconst pDot = (p) =>\n `<span style=\"display:inline-block;width:8px;height:8px;border-radius:50%;background:${priorityColor(p)};margin-right:5px;vertical-align:middle;\"></span>`;\n\n// \u2500\u2500 Sprint labels \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst donePct = metrics.totalIssues > 0\n ? Math.round((statusOverview.find(s => s.status==='Done'||s.status==='Termin\u00e9')?.count||0) / metrics.totalIssues * 100)\n : 0;\nconst barColor = (sprintPct>=90 && donePct<60) ? '#BF2600'\n : (sprintPct>=70 && donePct<40) ? '#FF8B00' : '#36B37E';\nconst daysLabel = sprintDaysLeft===0 ? 'Sprint ended'\n : sprintDaysLeft===1 ? '1 day left'\n : sprintDaysLeft!==null ? `${sprintDaysLeft} days left` : '';\nconst daysColor = sprintDaysLeft===0 ? '#BF2600' : (sprintPct>=80 ? '#FF8B00' : TEXT2);\n\n// \u2500\u2500 Donut SVG \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst totalIssues = metrics.totalIssues || 1;\nconst R_OUT=54, R_IN=34, CX=64, CY=64, GAP=0.04;\nlet angle = -Math.PI/2;\nconst donutPaths = statusOverview.map((s,idx) => {\n const frac = s.count/totalIssues;\n const sweep = frac*2*Math.PI - GAP;\n if (sweep<=0) return '';\n const sA=angle+GAP/2, eA=sA+sweep;\n angle += frac*2*Math.PI;\n const x1=CX+R_OUT*Math.cos(sA), y1=CY+R_OUT*Math.sin(sA);\n const x2=CX+R_OUT*Math.cos(eA), y2=CY+R_OUT*Math.sin(eA);\n const x3=CX+R_IN*Math.cos(eA), y3=CY+R_IN*Math.sin(eA);\n const x4=CX+R_IN*Math.cos(sA), y4=CY+R_IN*Math.sin(sA);\n const lg=sweep>Math.PI?1:0;\n const col=STATUS_COLOR_MAP[s.status]||'#8993A4';\n return `<path d=\"M${x1.toFixed(2)},${y1.toFixed(2)} A${R_OUT},${R_OUT} 0 ${lg},1 ${x2.toFixed(2)},${y2.toFixed(2)} L${x3.toFixed(2)},${y3.toFixed(2)} A${R_IN},${R_IN} 0 ${lg},0 ${x4.toFixed(2)},${y4.toFixed(2)} Z\" fill=\"${col}\"/>`;\n}).join('');\n\nconst donutSvg = `<svg width=\"128\" height=\"128\" viewBox=\"0 0 128 128\" xmlns=\"http://www.w3.org/2000/svg\">\n ${donutPaths}\n <text x=\"64\" y=\"58\" text-anchor=\"middle\" font-size=\"22\" font-weight=\"700\" fill=\"${TEXT1}\" font-family=\"-apple-system,Arial,sans-serif\">${metrics.totalIssues}</text>\n <text x=\"64\" y=\"75\" text-anchor=\"middle\" font-size=\"10\" fill=\"${TEXT2}\" font-family=\"-apple-system,Arial,sans-serif\">issues</text>\n</svg>`;\n\nconst statusLegend = statusOverview.map(s => {\n const col = STATUS_COLOR_MAP[s.status]||'#8993A4';\n const pct = Math.round(s.count/totalIssues*100);\n return `<tr>\n <td style=\"padding:5px 10px 5px 0;width:14px;\">\n <div style=\"width:10px;height:10px;border-radius:2px;background:${col};\"></div>\n </td>\n <td style=\"padding:5px 0;font-size:12px;color:${TEXT1};\">${s.status}</td>\n <td style=\"padding:5px 0;text-align:right;white-space:nowrap;\">\n <span style=\"font-size:12px;font-weight:700;color:${TEXT1};\">${s.count}</span>\n <span style=\"font-size:11px;color:${TEXT3};margin-left:3px;\">(${pct}%)</span>\n </td>\n </tr>`;\n}).join('');\n\n// \u2500\u2500 Sprint progress band \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst sprintBandFill = Math.max(0, Math.min(100, sprintPct));\nconst sprintBand = `\n<table width=\"100%\" cellpadding=\"0\" cellspacing=\"0\">\n <tr>\n <td style=\"padding-bottom:7px;font-size:10px;font-weight:700;letter-spacing:0.07em;color:${TEXT3};text-transform:uppercase;\">Sprint Timeline</td>\n <td style=\"padding-bottom:7px;text-align:right;font-size:10px;font-weight:700;color:${daysColor};\">${daysLabel}</td>\n </tr>\n <tr>\n <td colspan=\"2\" style=\"padding:0;\">\n <table width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" style=\"background:#DFE1E6;border-radius:4px;height:10px;\">\n <tr>\n <td width=\"${sprintBandFill}%\" style=\"background:${barColor};height:10px;border-radius:4px;font-size:0;line-height:0;\"> </td>\n <td style=\"height:10px;font-size:0;line-height:0;\"> </td>\n </tr>\n </table>\n </td>\n </tr>\n <tr>\n <td style=\"padding-top:5px;font-size:10px;color:${TEXT3};\">${sprintStartFmt}</td>\n <td style=\"padding-top:5px;text-align:right;font-size:10px;color:${TEXT3};\">${sprintEndFmt}</td>\n </tr>\n</table>`;\n\n// \u2500\u2500 Unified metric card (both rows use same template) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// label top \u00b7 value big \u00b7 sub small \u2014 consistent structure = consistent height\nconst metricCard = (label, value, sub, borderCol, bgCol, valCol) =>\n `<table width=\"100%\" cellpadding=\"0\" cellspacing=\"0\"><tr>\n <td style=\"border-left:3px solid ${borderCol};background:${bgCol};border-radius:0 5px 5px 0;padding:13px 14px;vertical-align:top;\">\n <div style=\"font-size:9px;font-weight:700;color:${valCol};text-transform:uppercase;letter-spacing:0.08em;margin-bottom:6px;\">${label}</div>\n <div style=\"font-size:22px;font-weight:700;color:${valCol};line-height:1;\">${value}</div>\n <div style=\"font-size:10px;color:${TEXT3};margin-top:5px;line-height:1.35;\">${sub}</div>\n </td>\n </tr></table>`;\n\n// Aliases so rest of code stays unchanged\nconst kpi = (val, label, sub, border, bg, color) => metricCard(label, val, sub, border, bg, color);\nconst sprintKpi = metricCard;\n\n// \u2500\u2500 Total Issues prominent banner card \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Breakdown: done / in-progress / blocked counts from kpis\nconst tiDone = kpis.doneCount || 0;\nconst tiInProg = kpis.inProgressCount || 0;\nconst tiToDo = kpis.toDoCount || 0;\nconst tiBlocked = kpis.blockedCount || 0;\nconst tiOverdue = kpis.overdueCount || 0;\n\nconst totalIssuesBanner = `\n<table width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" style=\"margin-bottom:12px;\">\n <tr>\n <td style=\"background:${BRAND};border-radius:6px;padding:14px 20px;\">\n <table width=\"100%\" cellpadding=\"0\" cellspacing=\"0\"><tr valign=\"middle\">\n\n <!-- Big number -->\n <td style=\"padding-right:20px;border-right:1px solid rgba(255,255,255,0.15);white-space:nowrap;width:1%;\">\n <div style=\"font-size:9px;font-weight:700;letter-spacing:0.1em;color:rgba(255,255,255,0.4);text-transform:uppercase;margin-bottom:3px;\">Total Issues</div>\n <div style=\"font-size:36px;font-weight:700;color:#FFFFFF;line-height:1;\">${metrics.totalIssues}</div>\n <div style=\"font-size:9px;color:rgba(255,255,255,0.35);margin-top:3px;\">${sprintName}</div>\n </td>\n\n <!-- Breakdown pills \u2014 evenly spaced -->\n <td style=\"padding-left:20px;\">\n <table width=\"100%\" cellpadding=\"0\" cellspacing=\"0\"><tr>\n\n <td style=\"padding-right:8px;\">\n <div style=\"background:rgba(54,179,126,0.18);border:1px solid rgba(54,179,126,0.3);border-radius:4px;padding:7px 10px;text-align:center;\">\n <div style=\"font-size:16px;font-weight:700;color:#36B37E;line-height:1;\">${tiDone}</div>\n <div style=\"font-size:8px;font-weight:700;color:rgba(255,255,255,0.45);text-transform:uppercase;letter-spacing:0.07em;margin-top:3px;\">Done</div>\n </div>\n </td>\n\n <td style=\"padding-right:8px;\">\n <div style=\"background:rgba(0,82,204,0.2);border:1px solid rgba(0,82,204,0.3);border-radius:4px;padding:7px 10px;text-align:center;\">\n <div style=\"font-size:16px;font-weight:700;color:#579DFF;line-height:1;\">${tiInProg}</div>\n <div style=\"font-size:8px;font-weight:700;color:rgba(255,255,255,0.45);text-transform:uppercase;letter-spacing:0.07em;margin-top:3px;\">Active</div>\n </div>\n </td>\n\n <td style=\"padding-right:8px;\">\n <div style=\"background:rgba(255,255,255,0.07);border:1px solid rgba(255,255,255,0.13);border-radius:4px;padding:7px 10px;text-align:center;\">\n <div style=\"font-size:16px;font-weight:700;color:rgba(255,255,255,0.6);line-height:1;\">${tiToDo}</div>\n <div style=\"font-size:8px;font-weight:700;color:rgba(255,255,255,0.45);text-transform:uppercase;letter-spacing:0.07em;margin-top:3px;\">To Do</div>\n </div>\n </td>\n\n ${tiBlocked > 0 ? `\n <td style=\"padding-right:8px;\">\n <div style=\"background:rgba(191,38,0,0.18);border:1px solid rgba(191,38,0,0.35);border-radius:4px;padding:7px 10px;text-align:center;\">\n <div style=\"font-size:16px;font-weight:700;color:#FF7452;line-height:1;\">${tiBlocked}</div>\n <div style=\"font-size:8px;font-weight:700;color:rgba(255,255,255,0.45);text-transform:uppercase;letter-spacing:0.07em;margin-top:3px;\">Blocked
Credentials you'll need
Each integration node will prompt for credentials when you import. We strip credential IDs before publishing — you'll add your own.
gmailOAuth2htmlcsstopdfApijiraSoftwareCloudApipostgres
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
This workflow runs every Monday at 8 AM and automatically monitors your Jira project, measures progress against the active sprint, and delivers a structured report to stakeholders — with zero manual effort. Fetches updated, created, completed, and due-soon issues from Jira for…
Source: https://n8n.io/workflows/14783/ — original creator credit. Request a take-down →
Related workflows
Workflows that share integrations, category, or trigger type with this one. All free to copy and import.
This is an elite enterprise-grade solution for Talent Acquisition and HR Ops teams. It automates the high-volume task of resume screening by transforming unstructured PDF applications into structured
Fetches all open sprint tickets daily from your Jira project Analyzes each ticket for overdue days and blocked status Routes to the right escalation level: assignee email → team Google Chat alert → ma
This workflow sends an instant email alert when a task in a Google Sheet is marked as Urgent, and then sends a Telegram reminder notification after 2 hours if the task still hasn’t been updated. Then
This workflow is for contractors, freelancers, local service businesses, and small teams that receive leads and customer requests through Gmail but do not have a dedicated sales or admin team.
2025-12-03 fix JS code in node