coderuday21 Cursor commited on
Commit
70d9e6c
·
1 Parent(s): 66006d5

Add Phase 4 reports: PDF export, browser report page, and notification bell.

Browse files

ReportLab PDF download, standalone /dda/reports view, email deep links, and in-app job notifications on dev Space.

Co-authored-by: Cursor <cursoragent@cursor.com>

Dockerfile CHANGED
@@ -21,7 +21,7 @@ WORKDIR /app
21
 
22
  # Build-time info + cache-bust:
23
  # Changing APP_BUILD forces Docker to re-run subsequent layers (including pip install).
24
- ARG APP_BUILD=33
25
  ENV MAX_GEOTIFF_MB=5120
26
  ENV APP_BUILD=${APP_BUILD}
27
  ENV GDAL_CONFIG=/usr/bin/gdal-config
 
21
 
22
  # Build-time info + cache-bust:
23
  # Changing APP_BUILD forces Docker to re-run subsequent layers (including pip install).
24
+ ARG APP_BUILD=34
25
  ENV MAX_GEOTIFF_MB=5120
26
  ENV APP_BUILD=${APP_BUILD}
27
  ENV GDAL_CONFIG=/usr/bin/gdal-config
app/dda/bootstrap.py CHANGED
@@ -8,6 +8,7 @@ from .config import IS_DDA_MODE, ensure_library_dirs, ensure_local_year_folders,
8
  from .jobs_routes import router as jobs_router
9
  from .library_routes import router as library_router
10
  from .local_routes import router as local_router
 
11
  from .seed import seed_delhi_hierarchy
12
 
13
  logger = logging.getLogger(__name__)
@@ -55,5 +56,6 @@ def setup_dda(app: FastAPI) -> None:
55
  return
56
  app.include_router(library_router, prefix="/api/dda", tags=["dda"])
57
  app.include_router(jobs_router, prefix="/api/dda", tags=["dda-jobs"])
 
58
  app.include_router(local_router, prefix="/api/dda", tags=["dda-local"])
59
- logger.info("APP_MODE=dda — DDA routes enabled (library, jobs, local folder)")
 
8
  from .jobs_routes import router as jobs_router
9
  from .library_routes import router as library_router
10
  from .local_routes import router as local_router
11
+ from .reports_routes import router as reports_router
12
  from .seed import seed_delhi_hierarchy
13
 
14
  logger = logging.getLogger(__name__)
 
56
  return
57
  app.include_router(library_router, prefix="/api/dda", tags=["dda"])
58
  app.include_router(jobs_router, prefix="/api/dda", tags=["dda-jobs"])
59
+ app.include_router(reports_router, prefix="/api/dda", tags=["dda-reports"])
60
  app.include_router(local_router, prefix="/api/dda", tags=["dda-local"])
61
+ logger.info("APP_MODE=dda — DDA routes enabled (library, jobs, reports, local folder)")
app/dda/config.py CHANGED
@@ -21,9 +21,20 @@ def _is_dda_mode() -> bool:
21
  return _SPACE_ID.endswith("/satdetect-dev")
22
 
23
 
 
 
 
 
 
 
 
 
 
 
24
  IS_DDA_MODE = _is_dda_mode()
25
  APP_MODE = APP_MODE_RAW or ("dda" if IS_DDA_MODE else "legacy")
26
 
 
27
  # Project root: change_detection_webapp/
28
  PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
29
 
 
21
  return _SPACE_ID.endswith("/satdetect-dev")
22
 
23
 
24
+ def get_public_base_url() -> str:
25
+ """Public URL for deep links (email, share). Override with PUBLIC_BASE_URL."""
26
+ explicit = os.environ.get("PUBLIC_BASE_URL", "").strip().rstrip("/")
27
+ if explicit:
28
+ return explicit
29
+ if _SPACE_ID:
30
+ return f"https://{_SPACE_ID.replace('/', '-')}.hf.space"
31
+ return "http://localhost:7860"
32
+
33
+
34
  IS_DDA_MODE = _is_dda_mode()
35
  APP_MODE = APP_MODE_RAW or ("dda" if IS_DDA_MODE else "legacy")
36
 
37
+
38
  # Project root: change_detection_webapp/
39
  PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
40
 
app/dda/detect_service.py CHANGED
@@ -167,6 +167,8 @@ def run_detection_and_save(
167
  notification_sent = False
168
  notification_error = None
169
  if notify_email and notify_email.strip():
 
 
170
  notification_sent, notification_error = send_notification(
171
  recipient=notify_email.strip(),
172
  title=title,
@@ -177,6 +179,7 @@ def run_detection_and_save(
177
  changed_px=changed_px,
178
  total_px=total_px,
179
  regions=regions_serializable,
 
180
  )
181
 
182
  return {
 
167
  notification_sent = False
168
  notification_error = None
169
  if notify_email and notify_email.strip():
170
+ from .config import IS_DDA_MODE, get_public_base_url
171
+ report_url = f"{get_public_base_url()}/dda/reports/{run.id}" if IS_DDA_MODE else ""
172
  notification_sent, notification_error = send_notification(
173
  recipient=notify_email.strip(),
174
  title=title,
 
179
  changed_px=changed_px,
180
  total_px=total_px,
181
  regions=regions_serializable,
182
+ report_url=report_url,
183
  )
184
 
185
  return {
app/dda/report_pdf.py ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """PDF export for DDA detection reports (FR-05)."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from io import BytesIO
6
+ from pathlib import Path
7
+ from typing import Any, Dict, List, Optional
8
+
9
+ from ..database import DATA_DIR
10
+ from ..models import DetectionRun
11
+ from .detect_service import _isoformat_ist
12
+
13
+
14
+ def _safe_filename(title: str, run_id: int) -> str:
15
+ base = "".join(c if c.isalnum() or c in " -_" else "_" for c in (title or "report"))
16
+ base = base.strip().replace(" ", "_")[:60] or "report"
17
+ return f"DDA_Report_{run_id}_{base}.pdf"
18
+
19
+
20
+ def build_report_dict(run: DetectionRun, *, include_overlay_b64: bool = False) -> Dict[str, Any]:
21
+ regions: List[dict] = json.loads(run.regions_json or "[]")
22
+ payload: Dict[str, Any] = {
23
+ "id": run.id,
24
+ "title": run.title,
25
+ "method": run.method,
26
+ "zone": run.zone or "",
27
+ "village": run.village or "",
28
+ "statistics": {
29
+ "totalPixels": run.total_pixels,
30
+ "changedPixels": run.changed_pixels,
31
+ "unchangedPixels": run.total_pixels - run.changed_pixels,
32
+ "changePercentage": run.change_percentage,
33
+ },
34
+ "regions": regions,
35
+ "regionsCount": run.regions_count,
36
+ "overlayUrl": f"/api/overlay/{run.overlay_path}" if run.overlay_path else None,
37
+ "beforeFullUrl": f"/api/overlay/{run.before_full_path}" if run.before_full_path else None,
38
+ "beforeThumbUrl": f"/api/overlay/{run.before_thumb_path}" if run.before_thumb_path else None,
39
+ "afterThumbUrl": f"/api/overlay/{run.after_thumb_path}" if run.after_thumb_path else None,
40
+ "createdAt": _isoformat_ist(run.created_at),
41
+ "pdfUrl": f"/api/dda/reports/{run.id}/pdf",
42
+ }
43
+ if include_overlay_b64 and run.overlay_path:
44
+ overlay_file = DATA_DIR / run.overlay_path
45
+ if overlay_file.exists():
46
+ import base64
47
+ payload["overlayBase64Png"] = base64.b64encode(overlay_file.read_bytes()).decode("utf-8")
48
+ return payload
49
+
50
+
51
+ def generate_report_pdf(run: DetectionRun) -> tuple[bytes, str]:
52
+ """Build PDF bytes and suggested download filename."""
53
+ from reportlab.lib import colors
54
+ from reportlab.lib.pagesizes import A4
55
+ from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
56
+ from reportlab.lib.units import mm
57
+ from reportlab.platypus import Image as RLImage
58
+ from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle
59
+
60
+ regions: List[dict] = json.loads(run.regions_json or "[]")
61
+ buf = BytesIO()
62
+ doc = SimpleDocTemplate(buf, pagesize=A4, leftMargin=18 * mm, rightMargin=18 * mm, topMargin=16 * mm, bottomMargin=16 * mm)
63
+ styles = getSampleStyleSheet()
64
+ title_style = ParagraphStyle("ReportTitle", parent=styles["Heading1"], fontSize=16, spaceAfter=8)
65
+ sub_style = ParagraphStyle("ReportSub", parent=styles["Normal"], fontSize=10, textColor=colors.grey)
66
+ body = styles["Normal"]
67
+
68
+ location = ", ".join(filter(None, [run.village, run.zone])) or "—"
69
+ story = [
70
+ Paragraph("DDA Change Detection Report", title_style),
71
+ Paragraph(run.title or f"Run #{run.id}", styles["Heading2"]),
72
+ Paragraph(f"Generated {_isoformat_ist(run.created_at)} IST", sub_style),
73
+ Spacer(1, 8),
74
+ Paragraph(f"<b>Method:</b> {run.method or '—'}", body),
75
+ Paragraph(f"<b>Location:</b> {location}", body),
76
+ Paragraph(f"<b>Change:</b> {run.change_percentage:.2f}% ({run.changed_pixels:,} / {run.total_pixels:,} px)", body),
77
+ Paragraph(f"<b>Regions detected:</b> {run.regions_count}", body),
78
+ Spacer(1, 10),
79
+ ]
80
+
81
+ overlay_path = run.overlay_path
82
+ if overlay_path:
83
+ overlay_file = DATA_DIR / overlay_path
84
+ if overlay_file.exists():
85
+ try:
86
+ from PIL import Image as PILImage
87
+ with PILImage.open(overlay_file) as im:
88
+ im = im.convert("RGB")
89
+ max_w = 160 * mm
90
+ ratio = min(1.0, max_w / im.width)
91
+ w, h = int(im.width * ratio), int(im.height * ratio)
92
+ thumb = im.resize((w, h), PILImage.Resampling.LANCZOS)
93
+ img_buf = BytesIO()
94
+ thumb.save(img_buf, format="JPEG", quality=85)
95
+ img_buf.seek(0)
96
+ story.append(Paragraph("Change overlay", styles["Heading3"]))
97
+ story.append(RLImage(img_buf, width=w, height=h))
98
+ story.append(Spacer(1, 10))
99
+ except Exception:
100
+ pass
101
+
102
+ story.append(Paragraph("Detected regions", styles["Heading3"]))
103
+ if regions:
104
+ table_data = [["#", "DDA type", "Internal type", "Conf.", "Area (px)", "Lat", "Lng"]]
105
+ for r in regions[:50]:
106
+ lat = r.get("latitude") or r.get("lat")
107
+ lng = r.get("longitude") or r.get("lng")
108
+ table_data.append([
109
+ str(r.get("id", "")),
110
+ r.get("ddaChangeType") or r.get("objectType") or "—",
111
+ r.get("internalObjectType") or r.get("objectType") or "—",
112
+ f'{(r.get("confidence", 0) or 0) * 100:.0f}%',
113
+ f'{r.get("area", 0):,}',
114
+ f"{lat:.5f}" if lat is not None else "—",
115
+ f"{lng:.5f}" if lng is not None else "—",
116
+ ])
117
+ tbl = Table(table_data, repeatRows=1, colWidths=[22, 72, 72, 36, 52, 48, 48])
118
+ tbl.setStyle(TableStyle([
119
+ ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#2e33c5")),
120
+ ("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
121
+ ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
122
+ ("FONTSIZE", (0, 0), (-1, -1), 8),
123
+ ("GRID", (0, 0), (-1, -1), 0.25, colors.lightgrey),
124
+ ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#f9f9fb")]),
125
+ ("VALIGN", (0, 0), (-1, -1), "TOP"),
126
+ ]))
127
+ story.append(tbl)
128
+ if len(regions) > 50:
129
+ story.append(Spacer(1, 6))
130
+ story.append(Paragraph(f"Showing first 50 of {len(regions)} regions.", sub_style))
131
+ else:
132
+ story.append(Paragraph("No change regions detected.", body))
133
+
134
+ doc.build(story)
135
+ return buf.getvalue(), _safe_filename(run.title or "", run.id)
app/dda/reports_routes.py ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """DDA report browser view, PDF export, and email notify (FR-05)."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ from typing import Optional
6
+
7
+ from fastapi import APIRouter, Depends, HTTPException
8
+ from fastapi.responses import Response
9
+ from pydantic import BaseModel, EmailStr
10
+ from sqlalchemy.orm import Session
11
+
12
+ from ..auth import get_or_create_guest_user
13
+ from ..database import get_db
14
+ from ..models import DetectionRun
15
+ from ..notifier import send_notification
16
+ from .config import get_public_base_url
17
+ from .report_pdf import build_report_dict, generate_report_pdf
18
+
19
+ logger = logging.getLogger(__name__)
20
+ router = APIRouter()
21
+
22
+
23
+ def _require_dda():
24
+ from .config import IS_DDA_MODE
25
+ if not IS_DDA_MODE:
26
+ raise HTTPException(status_code=404, detail="DDA mode is not enabled")
27
+
28
+
29
+ def _get_user_run(db: Session, run_id: int, user_id: int) -> DetectionRun:
30
+ run = db.query(DetectionRun).filter(
31
+ DetectionRun.id == run_id,
32
+ DetectionRun.user_id == user_id,
33
+ ).first()
34
+ if not run:
35
+ raise HTTPException(status_code=404, detail="Report not found")
36
+ return run
37
+
38
+
39
+ @router.get("/reports/{run_id}")
40
+ def get_report(run_id: int, db: Session = Depends(get_db)):
41
+ """JSON payload for the standalone report page and API clients."""
42
+ _require_dda()
43
+ user = get_or_create_guest_user(db)
44
+ run = _get_user_run(db, run_id, user.id)
45
+ data = build_report_dict(run, include_overlay_b64=True)
46
+ data["reportUrl"] = f"{get_public_base_url()}/dda/reports/{run.id}"
47
+ return data
48
+
49
+
50
+ @router.get("/reports/{run_id}/pdf")
51
+ def download_report_pdf(run_id: int, db: Session = Depends(get_db)):
52
+ """Download detection report as PDF."""
53
+ _require_dda()
54
+ user = get_or_create_guest_user(db)
55
+ run = _get_user_run(db, run_id, user.id)
56
+ try:
57
+ pdf_bytes, filename = generate_report_pdf(run)
58
+ except ImportError as exc:
59
+ raise HTTPException(status_code=503, detail="PDF export is not available (reportlab missing)") from exc
60
+ except Exception as exc:
61
+ logger.exception("PDF generation failed for run %s", run_id)
62
+ raise HTTPException(status_code=500, detail=f"PDF generation failed: {exc}") from exc
63
+ return Response(
64
+ content=pdf_bytes,
65
+ media_type="application/pdf",
66
+ headers={"Content-Disposition": f'attachment; filename="{filename}"'},
67
+ )
68
+
69
+
70
+ class ReportNotifyBody(BaseModel):
71
+ email: EmailStr
72
+
73
+
74
+ @router.post("/reports/{run_id}/notify")
75
+ def notify_report(run_id: int, body: ReportNotifyBody, db: Session = Depends(get_db)):
76
+ """Email report summary with link to the browser report page."""
77
+ _require_dda()
78
+ user = get_or_create_guest_user(db)
79
+ run = _get_user_run(db, run_id, user.id)
80
+ import json
81
+ regions = json.loads(run.regions_json or "[]")
82
+ report_url = f"{get_public_base_url()}/dda/reports/{run.id}"
83
+ sent, error = send_notification(
84
+ recipient=body.email.strip(),
85
+ title=run.title,
86
+ method=run.method,
87
+ zone=run.zone or "",
88
+ village=run.village or "",
89
+ change_pct=float(run.change_percentage),
90
+ changed_px=int(run.changed_pixels),
91
+ total_px=int(run.total_pixels),
92
+ regions=regions,
93
+ report_url=report_url,
94
+ )
95
+ if not sent:
96
+ raise HTTPException(status_code=400, detail=error or "Failed to send report email")
97
+ return {"ok": True, "message": f"Report link sent to {body.email.strip()}.", "reportUrl": report_url}
app/main.py CHANGED
@@ -555,6 +555,17 @@ def delete_run(
555
 
556
 
557
  # --- Serve SPA ---
 
 
 
 
 
 
 
 
 
 
 
558
  @app.get("/", response_class=HTMLResponse)
559
  def index():
560
  if IS_DDA_MODE:
 
555
 
556
 
557
  # --- Serve SPA ---
558
+ @app.get("/dda/reports/{run_id}", response_class=HTMLResponse)
559
+ def dda_report_page(run_id: int):
560
+ """Standalone browser report (FR-05)."""
561
+ if not IS_DDA_MODE:
562
+ raise HTTPException(status_code=404, detail="Not found")
563
+ report_file = TEMPLATES_DIR / "report_dda.html"
564
+ if not report_file.exists():
565
+ raise HTTPException(status_code=404, detail="Report template missing")
566
+ return FileResponse(report_file)
567
+
568
+
569
  @app.get("/", response_class=HTMLResponse)
570
  def index():
571
  if IS_DDA_MODE:
app/notifier.py CHANGED
@@ -71,6 +71,18 @@ def _build_region_rows(regions: list) -> str:
71
  return "\n".join(rows)
72
 
73
 
 
 
 
 
 
 
 
 
 
 
 
 
74
  def build_email_body(
75
  title: str,
76
  method: str,
@@ -80,6 +92,7 @@ def build_email_body(
80
  changed_px: int,
81
  total_px: int,
82
  regions: list,
 
83
  ) -> str:
84
  """Populate the HTML template with detection results."""
85
  html = _load_template()
@@ -97,6 +110,7 @@ def build_email_body(
97
  "{{regions_count}}": str(len(regions)),
98
  "{{region_rows}}": region_rows,
99
  "{{timestamp}}": now,
 
100
  }
101
  for key, val in replacements.items():
102
  html = html.replace(key, val)
@@ -208,10 +222,12 @@ def send_notification(
208
  changed_px: int,
209
  total_px: int,
210
  regions: list,
 
211
  ):
212
  """Send a detection report email and return (success, error_message)."""
213
  html_body = build_email_body(
214
- title, method, zone, village, change_pct, changed_px, total_px, regions
 
215
  )
216
  subject = f"Change Detection Report — {title or 'Untitled run'}"
217
  return _send_html_email(recipient, subject, html_body)
 
71
  return "\n".join(rows)
72
 
73
 
74
+ def _report_link_section(report_url: str) -> str:
75
+ if not report_url:
76
+ return ""
77
+ safe = report_url.replace('"', "%22")
78
+ return (
79
+ '<p style="margin:0 0 20px; text-align:center;">'
80
+ f'<a href="{safe}" style="display:inline-block; background:#2e33c5; color:#fff; '
81
+ 'text-decoration:none; padding:12px 24px; border-radius:8px; font-size:14px; font-weight:600;">'
82
+ "View full report online</a></p>"
83
+ )
84
+
85
+
86
  def build_email_body(
87
  title: str,
88
  method: str,
 
92
  changed_px: int,
93
  total_px: int,
94
  regions: list,
95
+ report_url: str = "",
96
  ) -> str:
97
  """Populate the HTML template with detection results."""
98
  html = _load_template()
 
110
  "{{regions_count}}": str(len(regions)),
111
  "{{region_rows}}": region_rows,
112
  "{{timestamp}}": now,
113
+ "{{report_link_section}}": _report_link_section(report_url),
114
  }
115
  for key, val in replacements.items():
116
  html = html.replace(key, val)
 
222
  changed_px: int,
223
  total_px: int,
224
  regions: list,
225
+ report_url: str = "",
226
  ):
227
  """Send a detection report email and return (success, error_message)."""
228
  html_body = build_email_body(
229
+ title, method, zone, village, change_pct, changed_px, total_px, regions,
230
+ report_url=report_url,
231
  )
232
  subject = f"Change Detection Report — {title or 'Untitled run'}"
233
  return _send_html_email(recipient, subject, html_body)
requirements.txt CHANGED
@@ -18,3 +18,5 @@ requests>=2.28.0
18
  protobuf>=5.28.0,<6
19
  rasterio>=1.3.0,<1.5
20
  pyproj>=3.6.0
 
 
 
18
  protobuf>=5.28.0,<6
19
  rasterio>=1.3.0,<1.5
20
  pyproj>=3.6.0
21
+ reportlab>=4.0.0
22
+ email-validator>=2.0.0
static/css/dda.css CHANGED
@@ -347,3 +347,75 @@
347
 
348
  .regions-table tr.region-selected { background: rgba(16, 185, 129, 0.12); }
349
  .regions-table tr { cursor: pointer; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
347
 
348
  .regions-table tr.region-selected { background: rgba(16, 185, 129, 0.12); }
349
  .regions-table tr { cursor: pointer; }
350
+
351
+ .dda-notify-wrap { position: relative; margin-left: auto; }
352
+ .dda-notify-btn {
353
+ position: relative;
354
+ background: transparent;
355
+ border: 1px solid var(--border);
356
+ border-radius: 8px;
357
+ padding: 0.4rem 0.55rem;
358
+ cursor: pointer;
359
+ color: inherit;
360
+ display: flex;
361
+ align-items: center;
362
+ }
363
+ .dda-notify-badge {
364
+ position: absolute;
365
+ top: -4px;
366
+ right: -4px;
367
+ min-width: 16px;
368
+ height: 16px;
369
+ padding: 0 4px;
370
+ border-radius: 999px;
371
+ background: var(--danger, #dc2626);
372
+ color: #fff;
373
+ font-size: 10px;
374
+ font-weight: 700;
375
+ line-height: 16px;
376
+ text-align: center;
377
+ }
378
+ .dda-notify-panel {
379
+ position: absolute;
380
+ right: 0;
381
+ top: calc(100% + 6px);
382
+ width: min(320px, 90vw);
383
+ max-height: 360px;
384
+ overflow-y: auto;
385
+ background: var(--card, #fff);
386
+ border: 1px solid var(--border);
387
+ border-radius: 10px;
388
+ box-shadow: 0 8px 24px rgba(0,0,0,0.12);
389
+ z-index: 200;
390
+ padding: 0.35rem;
391
+ }
392
+ .dda-notify-item {
393
+ display: block;
394
+ width: 100%;
395
+ text-align: left;
396
+ border: none;
397
+ background: transparent;
398
+ padding: 0.55rem 0.65rem;
399
+ border-radius: 6px;
400
+ cursor: pointer;
401
+ color: inherit;
402
+ }
403
+ .dda-notify-item:hover { background: rgba(0,0,0,0.04); }
404
+ .dda-notify-item.unread { background: rgba(46, 51, 197, 0.06); }
405
+ .dda-notify-item-title { display: block; font-size: 0.85rem; font-weight: 600; }
406
+ .dda-notify-item-meta { display: block; font-size: 0.75rem; color: var(--text-muted, #888); margin-top: 0.15rem; }
407
+ .dda-notify-empty { padding: 0.75rem; margin: 0; font-size: 0.85rem; }
408
+
409
+ .dda-notify-email { margin-top: 0.35rem; width: 100%; max-width: 280px; }
410
+ .dda-report-actions-cell { white-space: nowrap; }
411
+ .dda-report-actions-cell .btn, .dda-report-actions-cell a.btn { margin-right: 0.25rem; margin-bottom: 0.25rem; }
412
+
413
+ .dda-report-page { max-width: 960px; margin: 0 auto; padding: 1rem; }
414
+ .dda-report-header { justify-content: space-between; }
415
+ .dda-report-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; }
416
+ .dda-report-stats { display: flex; flex-wrap: wrap; gap: 1rem 2rem; margin-top: 0.75rem; }
417
+ .dda-stat .label { display: block; font-size: 0.75rem; color: var(--text-muted, #888); }
418
+ .dda-stat .value { font-size: 1.25rem; font-weight: 700; }
419
+ .dda-report-overlay { max-width: 100%; height: auto; border-radius: 8px; border: 1px solid var(--border); }
420
+ .dda-report-email-row { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 0.5rem; }
421
+ .dda-report-email-row input { flex: 1; min-width: 200px; }
static/js/dda/compare.js CHANGED
@@ -279,6 +279,12 @@ function setupCompareInteractions() {
279
  });
280
  document.getElementById('btn-run-job')?.addEventListener('click', runLibraryDetection);
281
 
 
 
 
 
 
 
282
  document.addEventListener('keydown', (e) => {
283
  if (e.key !== 'Escape') return;
284
  const picker = document.getElementById('dda-picker-modal');
@@ -304,7 +310,7 @@ async function runDetectionWithFallback(form, loadingEl) {
304
  loadingEl.textContent = 'Running detection (sync fallback)…';
305
  }
306
  }
307
- return ddaApi('POST', '/api/dda/detect/from-library', { body: form });
308
  }
309
 
310
  async function pollJobUntilDone(jobId, loadingEl) {
@@ -315,7 +321,10 @@ async function pollJobUntilDone(jobId, loadingEl) {
315
  if (loadingEl) {
316
  loadingEl.textContent = `Detection job #${jobId} — ${status}… (${i + 1})`;
317
  }
318
- if (status === 'completed' && job.result) return job.result;
 
 
 
319
  if (status === 'failed') throw new Error(job.errorMessage || 'Detection job failed');
320
  await new Promise((r) => setTimeout(r, 2000));
321
  }
@@ -344,11 +353,21 @@ async function runLibraryDetection() {
344
  form.append('detection_sensitivity', String(Math.max(0, Math.min(1, Number(document.getElementById('dda-detect-sensitivity')?.value ?? 0.45)))));
345
  const minArea = Number(document.getElementById('dda-detect-min-area')?.value ?? 150);
346
  if (!Number.isNaN(minArea) && minArea >= 50) form.append('min_region_area', String(Math.round(minArea)));
 
 
 
 
 
347
 
348
  try {
349
- const data = await runDetectionWithFallback(form, loading);
 
350
  showDetectResult(data);
351
- if (typeof showDdaSuccess === 'function') showDdaSuccess('Detection complete.');
 
 
 
 
352
  if (typeof loadReportsList === 'function') loadReportsList();
353
  } catch (err) {
354
  if (typeof showDdaError === 'function') showDdaError(err.message || 'Detection failed');
 
279
  });
280
  document.getElementById('btn-run-job')?.addEventListener('click', runLibraryDetection);
281
 
282
+ const notifyCb = document.getElementById('dda-detect-notify');
283
+ const notifyEmail = document.getElementById('dda-detect-notify-email');
284
+ notifyCb?.addEventListener('change', () => {
285
+ if (notifyEmail) notifyEmail.classList.toggle('hidden', !notifyCb.checked);
286
+ });
287
+
288
  document.addEventListener('keydown', (e) => {
289
  if (e.key !== 'Escape') return;
290
  const picker = document.getElementById('dda-picker-modal');
 
310
  loadingEl.textContent = 'Running detection (sync fallback)…';
311
  }
312
  }
313
+ return ddaApi('POST', '/api/dda/detect/from-library', { body: form }).then((result) => ({ result, jobId: null }));
314
  }
315
 
316
  async function pollJobUntilDone(jobId, loadingEl) {
 
321
  if (loadingEl) {
322
  loadingEl.textContent = `Detection job #${jobId} — ${status}… (${i + 1})`;
323
  }
324
+ if (status === 'completed' && job.result) {
325
+ if (typeof window.refreshDdaNotifications === 'function') window.refreshDdaNotifications();
326
+ return { result: job.result, jobId };
327
+ }
328
  if (status === 'failed') throw new Error(job.errorMessage || 'Detection job failed');
329
  await new Promise((r) => setTimeout(r, 2000));
330
  }
 
353
  form.append('detection_sensitivity', String(Math.max(0, Math.min(1, Number(document.getElementById('dda-detect-sensitivity')?.value ?? 0.45)))));
354
  const minArea = Number(document.getElementById('dda-detect-min-area')?.value ?? 150);
355
  if (!Number.isNaN(minArea) && minArea >= 50) form.append('min_region_area', String(Math.round(minArea)));
356
+ const notifyCb = document.getElementById('dda-detect-notify');
357
+ const notifyEmail = document.getElementById('dda-detect-notify-email');
358
+ if (notifyCb?.checked && notifyEmail?.value?.trim()) {
359
+ form.append('notify_email', notifyEmail.value.trim());
360
+ }
361
 
362
  try {
363
+ const { result: data, jobId } = await runDetectionWithFallback(form, loading);
364
+ if (jobId && typeof window.markDdaJobSeen === 'function') window.markDdaJobSeen(jobId);
365
  showDetectResult(data);
366
+ if (typeof showDdaSuccess === 'function') {
367
+ let msg = 'Detection complete.';
368
+ if (data.notificationSent) msg += ' Report email sent.';
369
+ showDdaSuccess(msg);
370
+ }
371
  if (typeof loadReportsList === 'function') loadReportsList();
372
  } catch (err) {
373
  if (typeof showDdaError === 'function') showDdaError(err.message || 'Detection failed');
static/js/dda/notifications.js ADDED
@@ -0,0 +1,149 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** In-app notification bell — polls completed detection jobs (FR-05). */
2
+
3
+ const SEEN_JOBS_KEY = 'dda_seen_job_ids';
4
+ const POLL_MS = 30000;
5
+
6
+ function getSeenJobIds() {
7
+ try {
8
+ return new Set(JSON.parse(localStorage.getItem(SEEN_JOBS_KEY) || '[]'));
9
+ } catch (_) {
10
+ return new Set();
11
+ }
12
+ }
13
+
14
+ function saveSeenJobIds(ids) {
15
+ try {
16
+ localStorage.setItem(SEEN_JOBS_KEY, JSON.stringify([...ids].slice(-200)));
17
+ } catch (_) {}
18
+ }
19
+
20
+ function markJobSeen(jobId) {
21
+ const seen = getSeenJobIds();
22
+ seen.add(String(jobId));
23
+ saveSeenJobIds(seen);
24
+ refreshNotifications();
25
+ }
26
+
27
+ function formatNotifyTime(iso) {
28
+ if (!iso) return '';
29
+ try {
30
+ return new Date(iso).toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' });
31
+ } catch (_) {
32
+ return iso;
33
+ }
34
+ }
35
+
36
+ async function fetchNotifyJobs() {
37
+ try {
38
+ const data = await ddaApi('GET', '/api/dda/jobs?limit=25');
39
+ return data.jobs || [];
40
+ } catch (_) {
41
+ return [];
42
+ }
43
+ }
44
+
45
+ function updateNotifyBadge(jobs) {
46
+ const badge = document.getElementById('dda-notify-badge');
47
+ if (!badge) return;
48
+ const seen = getSeenJobIds();
49
+ const unread = jobs.filter((j) => j.status === 'completed' && j.runId && !seen.has(String(j.id)));
50
+ if (unread.length) {
51
+ badge.textContent = unread.length > 9 ? '9+' : String(unread.length);
52
+ badge.classList.remove('hidden');
53
+ } else {
54
+ badge.classList.add('hidden');
55
+ }
56
+ return unread;
57
+ }
58
+
59
+ function renderNotifyPanel(jobs) {
60
+ const panel = document.getElementById('dda-notify-panel');
61
+ if (!panel) return;
62
+ const seen = getSeenJobIds();
63
+ const items = jobs.filter((j) => ['completed', 'failed', 'running', 'queued'].includes(j.status));
64
+ if (!items.length) {
65
+ panel.innerHTML = '<p class="dim dda-notify-empty">No recent jobs.</p>';
66
+ return;
67
+ }
68
+ panel.innerHTML = items.slice(0, 12).map((j) => {
69
+ const unread = j.status === 'completed' && !seen.has(String(j.id));
70
+ const title = j.title || `${j.basePath || 'Base'} vs ${j.comparisonPath || 'Comparison'}`;
71
+ const pct = j.report?.changePercentage;
72
+ const meta = j.status === 'completed' && pct != null ? `${pct.toFixed(2)}% change` : j.status;
73
+ return `
74
+ <button type="button" class="dda-notify-item${unread ? ' unread' : ''}" data-job-id="${j.id}" data-run-id="${j.runId || ''}" data-status="${j.status}">
75
+ <span class="dda-notify-item-title">${title}</span>
76
+ <span class="dda-notify-item-meta">${formatNotifyTime(j.completedAt || j.createdAt)} · ${meta}</span>
77
+ </button>`;
78
+ }).join('');
79
+
80
+ panel.querySelectorAll('.dda-notify-item').forEach((btn) => {
81
+ btn.addEventListener('click', async () => {
82
+ markJobSeen(btn.dataset.jobId);
83
+ const status = btn.dataset.status;
84
+ const runId = btn.dataset.runId;
85
+ closeNotifyPanel();
86
+ if (status === 'completed' && runId) {
87
+ if (typeof showDdaResult === 'function') {
88
+ try {
89
+ const data = await ddaApi('GET', `/api/history/${runId}`);
90
+ showDdaResult(data);
91
+ } catch (_) {
92
+ window.open(`/dda/reports/${runId}`, '_blank');
93
+ }
94
+ } else {
95
+ window.open(`/dda/reports/${runId}`, '_blank');
96
+ }
97
+ } else if (status === 'failed') {
98
+ if (typeof showDdaError === 'function') showDdaError('Detection job failed. See Reports tab for details.');
99
+ document.querySelector('.dda-tab[data-tab="reports"]')?.click();
100
+ } else {
101
+ document.querySelector('.dda-tab[data-tab="reports"]')?.click();
102
+ }
103
+ });
104
+ });
105
+ }
106
+
107
+ function closeNotifyPanel() {
108
+ document.getElementById('dda-notify-panel')?.classList.add('hidden');
109
+ }
110
+
111
+ async function refreshNotifications() {
112
+ const jobs = await fetchNotifyJobs();
113
+ updateNotifyBadge(jobs);
114
+ const panel = document.getElementById('dda-notify-panel');
115
+ if (panel && !panel.classList.contains('hidden')) {
116
+ renderNotifyPanel(jobs);
117
+ }
118
+ return jobs;
119
+ }
120
+
121
+ function initNotifications() {
122
+ const btn = document.getElementById('dda-notify-btn');
123
+ if (!btn) return;
124
+
125
+ btn.addEventListener('click', async (e) => {
126
+ e.stopPropagation();
127
+ const panel = document.getElementById('dda-notify-panel');
128
+ const wasHidden = panel?.classList.contains('hidden');
129
+ if (wasHidden) {
130
+ const jobs = await fetchNotifyJobs();
131
+ renderNotifyPanel(jobs);
132
+ panel?.classList.remove('hidden');
133
+ } else {
134
+ closeNotifyPanel();
135
+ }
136
+ });
137
+
138
+ document.addEventListener('click', (e) => {
139
+ if (e.target.closest('.dda-notify-wrap')) return;
140
+ closeNotifyPanel();
141
+ });
142
+
143
+ refreshNotifications();
144
+ setInterval(refreshNotifications, POLL_MS);
145
+ }
146
+
147
+ document.addEventListener('DOMContentLoaded', initNotifications);
148
+ window.markDdaJobSeen = markJobSeen;
149
+ window.refreshDdaNotifications = refreshNotifications;
static/js/dda/report_page.js ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Standalone report page at /dda/reports/{runId} (FR-05). */
2
+
3
+ function parseReportRunId() {
4
+ const m = window.location.pathname.match(/\/dda\/reports\/(\d+)/);
5
+ return m ? parseInt(m[1], 10) : null;
6
+ }
7
+
8
+ function showReportError(msg) {
9
+ const el = document.getElementById('report-error');
10
+ if (!el) return;
11
+ el.textContent = msg;
12
+ el.classList.remove('hidden');
13
+ }
14
+
15
+ function formatCoord(v) {
16
+ if (v == null || v === '') return '—';
17
+ const n = Number(v);
18
+ return Number.isFinite(n) ? n.toFixed(5) : '—';
19
+ }
20
+
21
+ async function loadReportPage() {
22
+ const runId = parseReportRunId();
23
+ const loading = document.getElementById('report-loading');
24
+ const content = document.getElementById('report-content');
25
+ if (!runId) {
26
+ if (loading) loading.innerHTML = '<p class="dim">Invalid report URL.</p>';
27
+ return;
28
+ }
29
+
30
+ try {
31
+ const data = await ddaApi('GET', `/api/dda/reports/${runId}`);
32
+ if (loading) loading.classList.add('hidden');
33
+ if (content) content.classList.remove('hidden');
34
+
35
+ document.getElementById('report-title').textContent = data.title || `Run #${runId}`;
36
+ const loc = [data.village, data.zone].filter(Boolean).join(', ') || '—';
37
+ document.getElementById('report-meta').textContent =
38
+ `${data.method || '—'} · ${loc} · ${data.createdAt || ''}`;
39
+
40
+ const stats = data.statistics || {};
41
+ document.getElementById('report-stats').innerHTML = `
42
+ <div class="dda-stat"><span class="label">Change</span><span class="value">${(stats.changePercentage ?? 0).toFixed(2)}%</span></div>
43
+ <div class="dda-stat"><span class="label">Changed px</span><span class="value">${(stats.changedPixels ?? 0).toLocaleString()}</span></div>
44
+ <div class="dda-stat"><span class="label">Regions</span><span class="value">${data.regionsCount ?? (data.regions || []).length}</span></div>`;
45
+
46
+ const overlay = document.getElementById('report-overlay');
47
+ if (overlay) {
48
+ if (data.overlayBase64Png) {
49
+ overlay.src = `data:image/png;base64,${data.overlayBase64Png}`;
50
+ } else if (data.overlayUrl) {
51
+ overlay.src = data.overlayUrl;
52
+ } else {
53
+ overlay.alt = 'No overlay available';
54
+ }
55
+ }
56
+
57
+ const tbody = document.getElementById('report-regions-body');
58
+ const regions = data.regions || [];
59
+ if (tbody) {
60
+ tbody.innerHTML = regions.length
61
+ ? regions.map((r) => `
62
+ <tr>
63
+ <td>${r.id ?? ''}</td>
64
+ <td>${r.ddaChangeType || r.objectType || '—'}</td>
65
+ <td>${r.internalObjectType || r.objectType || '—'}</td>
66
+ <td>${((r.confidence ?? 0) * 100).toFixed(0)}%</td>
67
+ <td>${(r.area ?? 0).toLocaleString()}</td>
68
+ <td>${formatCoord(r.latitude ?? r.lat)}</td>
69
+ <td>${formatCoord(r.longitude ?? r.lng)}</td>
70
+ </tr>`).join('')
71
+ : '<tr><td colspan="7" class="dim">No regions detected.</td></tr>';
72
+ }
73
+
74
+ const pdfBtn = document.getElementById('report-pdf-btn');
75
+ if (pdfBtn) {
76
+ pdfBtn.disabled = false;
77
+ pdfBtn.onclick = () => { window.location.href = `/api/dda/reports/${runId}/pdf`; };
78
+ }
79
+
80
+ const viewBtn = document.getElementById('report-view-btn');
81
+ if (viewBtn) {
82
+ viewBtn.disabled = false;
83
+ viewBtn.onclick = () => {
84
+ window.location.href = '/?tab=reports';
85
+ try { sessionStorage.setItem('dda_open_run', String(runId)); } catch (_) {}
86
+ };
87
+ }
88
+
89
+ window._reportPageData = data;
90
+ } catch (err) {
91
+ if (loading) loading.classList.add('hidden');
92
+ showReportError(err.message || 'Could not load report.');
93
+ }
94
+ }
95
+
96
+ document.getElementById('report-email-btn')?.addEventListener('click', async () => {
97
+ const runId = parseReportRunId();
98
+ const input = document.getElementById('report-email');
99
+ const msg = document.getElementById('report-email-msg');
100
+ const email = (input?.value || '').trim();
101
+ if (!email) {
102
+ if (msg) msg.textContent = 'Enter an email address.';
103
+ return;
104
+ }
105
+ try {
106
+ const res = await ddaApi('POST', `/api/dda/reports/${runId}/notify`, {
107
+ body: JSON.stringify({ email }),
108
+ });
109
+ if (msg) msg.textContent = res.message || 'Sent.';
110
+ } catch (err) {
111
+ if (msg) msg.textContent = err.message || 'Send failed.';
112
+ }
113
+ });
114
+
115
+ loadReportPage();
static/js/dda/reports.js CHANGED
@@ -1,4 +1,4 @@
1
- /** Reports tab — detection jobs and history (Phase 4 foundation). */
2
 
3
  function formatReportDate(iso) {
4
  if (!iso) return '—';
@@ -68,7 +68,7 @@ async function loadReportsList() {
68
  <th>Status</th>
69
  <th>Change %</th>
70
  <th>Regions</th>
71
- <th></th>
72
  </tr>
73
  </thead>
74
  <tbody>
@@ -79,10 +79,12 @@ async function loadReportsList() {
79
  <td><span class="dda-status dda-status-${r.status}">${r.status}</span></td>
80
  <td>${r.changePct != null ? r.changePct.toFixed(2) + '%' : '—'}</td>
81
  <td>${r.regions ?? '—'}</td>
82
- <td>
83
  ${r.status === 'completed' && r.runId
84
- ? `<button type="button" class="btn btn-secondary btn-sm" data-view-run="${r.runId}">View</button>`
85
- : (r.error ? `<span class="dim" title="${r.error.replace(/"/g, '')}">Error</span>` : '—')}
 
 
86
  </td>
87
  </tr>`).join('')}
88
  </tbody>
@@ -114,6 +116,22 @@ document.querySelectorAll('.dda-tab').forEach((btn) => {
114
  }
115
  });
116
 
117
- if (document.getElementById('tab-reports')?.classList.contains('active')) {
118
- loadReportsList();
119
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Reports tab — detection jobs, history, PDF export (FR-05). */
2
 
3
  function formatReportDate(iso) {
4
  if (!iso) return '—';
 
68
  <th>Status</th>
69
  <th>Change %</th>
70
  <th>Regions</th>
71
+ <th>Actions</th>
72
  </tr>
73
  </thead>
74
  <tbody>
 
79
  <td><span class="dda-status dda-status-${r.status}">${r.status}</span></td>
80
  <td>${r.changePct != null ? r.changePct.toFixed(2) + '%' : '—'}</td>
81
  <td>${r.regions ?? '—'}</td>
82
+ <td class="dda-report-actions-cell">
83
  ${r.status === 'completed' && r.runId
84
+ ? `<button type="button" class="btn btn-secondary btn-sm" data-view-run="${r.runId}">View</button>
85
+ <a class="btn btn-secondary btn-sm" href="/dda/reports/${r.runId}" target="_blank" rel="noopener">Report</a>
86
+ <a class="btn btn-secondary btn-sm" href="/api/dda/reports/${r.runId}/pdf" download>PDF</a>`
87
+ : (r.error ? `<span class="dim" title="${String(r.error).replace(/"/g, '')}">Error</span>` : '—')}
88
  </td>
89
  </tr>`).join('')}
90
  </tbody>
 
116
  }
117
  });
118
 
119
+ document.addEventListener('DOMContentLoaded', () => {
120
+ try {
121
+ const openRun = sessionStorage.getItem('dda_open_run');
122
+ if (openRun) {
123
+ sessionStorage.removeItem('dda_open_run');
124
+ document.querySelector('.dda-tab[data-tab="reports"]')?.click();
125
+ setTimeout(async () => {
126
+ try {
127
+ const data = await ddaApi('GET', `/api/history/${openRun}`);
128
+ if (typeof showDdaResult === 'function') showDdaResult(data);
129
+ } catch (_) {}
130
+ }, 300);
131
+ }
132
+ } catch (_) {}
133
+
134
+ if (document.getElementById('tab-reports')?.classList.contains('active')) {
135
+ loadReportsList();
136
+ }
137
+ });
templates/ChangeDetection.html CHANGED
@@ -80,8 +80,10 @@
80
  </table>
81
  {{/regions}}
82
 
 
 
83
  <p style="margin:24px 0 0; color:#333; font-size:15px; line-height:1.6;">
84
- You can view the full overlay image and detailed results by logging in to the application.
85
  </p>
86
  </td>
87
  </tr>
 
80
  </table>
81
  {{/regions}}
82
 
83
+ {{report_link_section}}
84
+
85
  <p style="margin:24px 0 0; color:#333; font-size:15px; line-height:1.6;">
86
+ You can view the full overlay image and detailed results in the application.
87
  </p>
88
  </td>
89
  </tr>
templates/index_dda.html CHANGED
@@ -5,7 +5,7 @@
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
  <title>DDA Change Detection</title>
7
  <link rel="stylesheet" href="/static/css/style.css?v=30" />
8
- <link rel="stylesheet" href="/static/css/dda.css?v=8" />
9
  </head>
10
  <body>
11
  <div class="app dda-app">
@@ -20,6 +20,13 @@
20
  <button type="button" class="dda-tab" data-tab="detect" role="tab">Change Detection</button>
21
  <button type="button" class="dda-tab" data-tab="reports" role="tab">Reports</button>
22
  </nav>
 
 
 
 
 
 
 
23
  </header>
24
 
25
  <div id="dda-error" class="alert alert-error hidden"></div>
@@ -140,6 +147,8 @@
140
  </div>
141
  <label class="dda-check"><input type="checkbox" id="dda-detect-registration" checked /> Image registration</label>
142
  <label class="dda-check"><input type="checkbox" id="dda-detect-normalization" checked /> Normalization</label>
 
 
143
  </div>
144
  <p class="dim" id="dda-detect-res-hint"></p>
145
  <button type="button" class="btn btn-primary" id="btn-run-job" disabled>Run Detection</button>
@@ -154,7 +163,7 @@
154
  <h3>Detection Reports</h3>
155
  <button type="button" class="btn btn-secondary btn-sm" id="btn-reports-refresh">Refresh</button>
156
  </div>
157
- <p class="sub dim">Completed jobs and detection history. PDF export coming in Phase 4.</p>
158
  <div id="reports-list"><p class="dim">Open this tab to load reports.</p></div>
159
  </div>
160
  </section>
@@ -232,10 +241,11 @@
232
  </div>
233
  </div>
234
 
235
- <script src="/static/js/dda/app.js?v=10"></script>
236
  <script src="/static/js/dda/library.js?v=6"></script>
237
  <script src="/static/js/dda/result.js?v=4"></script>
238
- <script src="/static/js/dda/compare.js?v=7"></script>
239
- <script src="/static/js/dda/reports.js?v=2"></script>
 
240
  </body>
241
  </html>
 
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
  <title>DDA Change Detection</title>
7
  <link rel="stylesheet" href="/static/css/style.css?v=30" />
8
+ <link rel="stylesheet" href="/static/css/dda.css?v=9" />
9
  </head>
10
  <body>
11
  <div class="app dda-app">
 
20
  <button type="button" class="dda-tab" data-tab="detect" role="tab">Change Detection</button>
21
  <button type="button" class="dda-tab" data-tab="reports" role="tab">Reports</button>
22
  </nav>
23
+ <div class="dda-notify-wrap">
24
+ <button type="button" id="dda-notify-btn" class="dda-notify-btn" title="Job notifications" aria-label="Notifications">
25
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M18 8A6 6 0 006 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 01-3.46 0"/></svg>
26
+ <span id="dda-notify-badge" class="dda-notify-badge hidden">0</span>
27
+ </button>
28
+ <div id="dda-notify-panel" class="dda-notify-panel hidden" role="menu"></div>
29
+ </div>
30
  </header>
31
 
32
  <div id="dda-error" class="alert alert-error hidden"></div>
 
147
  </div>
148
  <label class="dda-check"><input type="checkbox" id="dda-detect-registration" checked /> Image registration</label>
149
  <label class="dda-check"><input type="checkbox" id="dda-detect-normalization" checked /> Normalization</label>
150
+ <label class="dda-check"><input type="checkbox" id="dda-detect-notify" /> Email report when done</label>
151
+ <input type="email" id="dda-detect-notify-email" class="dda-notify-email hidden" placeholder="recipient@example.com" />
152
  </div>
153
  <p class="dim" id="dda-detect-res-hint"></p>
154
  <button type="button" class="btn btn-primary" id="btn-run-job" disabled>Run Detection</button>
 
163
  <h3>Detection Reports</h3>
164
  <button type="button" class="btn btn-secondary btn-sm" id="btn-reports-refresh">Refresh</button>
165
  </div>
166
+ <p class="sub dim">Completed jobs and detection history. Download PDF or open the browser report.</p>
167
  <div id="reports-list"><p class="dim">Open this tab to load reports.</p></div>
168
  </div>
169
  </section>
 
241
  </div>
242
  </div>
243
 
244
+ <script src="/static/js/dda/app.js?v=11"></script>
245
  <script src="/static/js/dda/library.js?v=6"></script>
246
  <script src="/static/js/dda/result.js?v=4"></script>
247
+ <script src="/static/js/dda/compare.js?v=8"></script>
248
+ <script src="/static/js/dda/reports.js?v=3"></script>
249
+ <script src="/static/js/dda/notifications.js?v=1"></script>
250
  </body>
251
  </html>
templates/report_dda.html ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>DDA Detection Report</title>
7
+ <link rel="stylesheet" href="/static/css/style.css?v=30" />
8
+ <link rel="stylesheet" href="/static/css/dda.css?v=9" />
9
+ </head>
10
+ <body>
11
+ <div class="app dda-app dda-report-page">
12
+ <header class="dda-header dda-report-header">
13
+ <div class="app-brand">
14
+ <span>DDA Change Detection — Report</span>
15
+ </div>
16
+ <div class="dda-report-actions">
17
+ <a href="/" class="btn btn-secondary btn-sm">← Back to app</a>
18
+ <button type="button" class="btn btn-secondary btn-sm" id="report-pdf-btn" disabled>Download PDF</button>
19
+ <button type="button" class="btn btn-primary btn-sm" id="report-view-btn" disabled>Interactive view</button>
20
+ </div>
21
+ </header>
22
+
23
+ <div id="report-error" class="alert alert-error hidden"></div>
24
+ <div id="report-loading" class="card"><p class="dim">Loading report…</p></div>
25
+ <div id="report-content" class="hidden">
26
+ <div class="card dda-report-summary">
27
+ <h1 id="report-title"></h1>
28
+ <p class="dim" id="report-meta"></p>
29
+ <div class="dda-report-stats" id="report-stats"></div>
30
+ </div>
31
+ <div class="card dda-report-overlay-wrap">
32
+ <h3>Change overlay</h3>
33
+ <img id="report-overlay" alt="Change overlay" class="dda-report-overlay" />
34
+ </div>
35
+ <div class="card">
36
+ <h3>Detected regions</h3>
37
+ <div class="table-wrap">
38
+ <table class="dda-reports-table dda-report-regions" id="report-regions-table">
39
+ <thead>
40
+ <tr>
41
+ <th>#</th>
42
+ <th>DDA type</th>
43
+ <th>Internal type</th>
44
+ <th>Confidence</th>
45
+ <th>Area (px)</th>
46
+ <th>Lat</th>
47
+ <th>Lng</th>
48
+ </tr>
49
+ </thead>
50
+ <tbody id="report-regions-body"></tbody>
51
+ </table>
52
+ </div>
53
+ </div>
54
+ <div class="card dda-report-email">
55
+ <h3>Email this report</h3>
56
+ <div class="dda-report-email-row">
57
+ <input type="email" id="report-email" placeholder="recipient@example.com" />
58
+ <button type="button" class="btn btn-secondary" id="report-email-btn">Send link</button>
59
+ </div>
60
+ <p class="dim" id="report-email-msg"></p>
61
+ </div>
62
+ </div>
63
+ </div>
64
+ <script src="/static/js/dda/app.js?v=11"></script>
65
+ <script src="/static/js/dda/report_page.js?v=1"></script>
66
+ </body>
67
+ </html>