mangleframes 0.3.2__tar.gz → 0.3.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. {mangleframes-0.3.2 → mangleframes-0.3.4}/PKG-INFO +1 -1
  2. {mangleframes-0.3.2 → mangleframes-0.3.4}/pyproject.toml +1 -1
  3. {mangleframes-0.3.2 → mangleframes-0.3.4}/python/mangleframes/__init__.py +1 -1
  4. mangleframes-0.3.4/viewer/frontend/src/components/analysis/JoinAnalyzer.tsx +442 -0
  5. mangleframes-0.3.4/viewer/frontend/src/components/analysis/__tests__/JoinAnalyzer.test.tsx +307 -0
  6. {mangleframes-0.3.2 → mangleframes-0.3.4}/viewer/frontend/src/lib/api.ts +91 -0
  7. mangleframes-0.3.4/viewer/src/history_handlers.rs +513 -0
  8. {mangleframes-0.3.2 → mangleframes-0.3.4}/viewer/src/join_handlers.rs +268 -44
  9. {mangleframes-0.3.2 → mangleframes-0.3.4}/viewer/src/reconcile_handlers.rs +86 -98
  10. {mangleframes-0.3.2 → mangleframes-0.3.4}/viewer/src/sql_builder.rs +211 -42
  11. mangleframes-0.3.2/viewer/frontend/src/components/analysis/JoinAnalyzer.tsx +0 -165
  12. mangleframes-0.3.2/viewer/src/history_handlers.rs +0 -341
  13. {mangleframes-0.3.2 → mangleframes-0.3.4}/Cargo.lock +0 -0
  14. {mangleframes-0.3.2 → mangleframes-0.3.4}/Cargo.toml +0 -0
  15. {mangleframes-0.3.2 → mangleframes-0.3.4}/python/mangleframes/alerts.py +0 -0
  16. {mangleframes-0.3.2 → mangleframes-0.3.4}/python/mangleframes/launcher.py +0 -0
  17. {mangleframes-0.3.2 → mangleframes-0.3.4}/python/mangleframes/session.py +0 -0
  18. {mangleframes-0.3.2 → mangleframes-0.3.4}/spark-connect/Cargo.toml +0 -0
  19. {mangleframes-0.3.2 → mangleframes-0.3.4}/spark-connect/build.rs +0 -0
  20. {mangleframes-0.3.2 → mangleframes-0.3.4}/spark-connect/proto/spark/connect/base.proto +0 -0
  21. {mangleframes-0.3.2 → mangleframes-0.3.4}/spark-connect/proto/spark/connect/catalog.proto +0 -0
  22. {mangleframes-0.3.2 → mangleframes-0.3.4}/spark-connect/proto/spark/connect/commands.proto +0 -0
  23. {mangleframes-0.3.2 → mangleframes-0.3.4}/spark-connect/proto/spark/connect/common.proto +0 -0
  24. {mangleframes-0.3.2 → mangleframes-0.3.4}/spark-connect/proto/spark/connect/expressions.proto +0 -0
  25. {mangleframes-0.3.2 → mangleframes-0.3.4}/spark-connect/proto/spark/connect/ml.proto +0 -0
  26. {mangleframes-0.3.2 → mangleframes-0.3.4}/spark-connect/proto/spark/connect/ml_common.proto +0 -0
  27. {mangleframes-0.3.2 → mangleframes-0.3.4}/spark-connect/proto/spark/connect/relations.proto +0 -0
  28. {mangleframes-0.3.2 → mangleframes-0.3.4}/spark-connect/proto/spark/connect/types.proto +0 -0
  29. {mangleframes-0.3.2 → mangleframes-0.3.4}/spark-connect/src/client.rs +0 -0
  30. {mangleframes-0.3.2 → mangleframes-0.3.4}/spark-connect/src/error.rs +0 -0
  31. {mangleframes-0.3.2 → mangleframes-0.3.4}/spark-connect/src/lib.rs +0 -0
  32. {mangleframes-0.3.2 → mangleframes-0.3.4}/spark-connect/src/proto/spark.connect.rs +0 -0
  33. {mangleframes-0.3.2 → mangleframes-0.3.4}/viewer/Cargo.toml +0 -0
  34. {mangleframes-0.3.2 → mangleframes-0.3.4}/viewer/frontend/index.html +0 -0
  35. {mangleframes-0.3.2 → mangleframes-0.3.4}/viewer/frontend/package-lock.json +0 -0
  36. {mangleframes-0.3.2 → mangleframes-0.3.4}/viewer/frontend/package.json +0 -0
  37. {mangleframes-0.3.2 → mangleframes-0.3.4}/viewer/frontend/postcss.config.js +0 -0
  38. {mangleframes-0.3.2 → mangleframes-0.3.4}/viewer/frontend/src/App.tsx +0 -0
  39. {mangleframes-0.3.2 → mangleframes-0.3.4}/viewer/frontend/src/components/analysis/Reconciliation.tsx +0 -0
  40. {mangleframes-0.3.2 → mangleframes-0.3.4}/viewer/frontend/src/components/analysis/SQLEditor.tsx +0 -0
  41. {mangleframes-0.3.2 → mangleframes-0.3.4}/viewer/frontend/src/components/data/ColumnDropdown.tsx +0 -0
  42. {mangleframes-0.3.2 → mangleframes-0.3.4}/viewer/frontend/src/components/data/ColumnStats.tsx +0 -0
  43. {mangleframes-0.3.2 → mangleframes-0.3.4}/viewer/frontend/src/components/data/DataGrid.tsx +0 -0
  44. {mangleframes-0.3.2 → mangleframes-0.3.4}/viewer/frontend/src/components/data/SchemaView.tsx +0 -0
  45. {mangleframes-0.3.2 → mangleframes-0.3.4}/viewer/frontend/src/components/erd/ERDBuilder.tsx +0 -0
  46. {mangleframes-0.3.2 → mangleframes-0.3.4}/viewer/frontend/src/components/erd/ERDCanvas.tsx +0 -0
  47. {mangleframes-0.3.2 → mangleframes-0.3.4}/viewer/frontend/src/components/erd/ERDConfigModal.tsx +0 -0
  48. {mangleframes-0.3.2 → mangleframes-0.3.4}/viewer/frontend/src/components/erd/ERDTableList.tsx +0 -0
  49. {mangleframes-0.3.2 → mangleframes-0.3.4}/viewer/frontend/src/components/erd/ERDToolbar.tsx +0 -0
  50. {mangleframes-0.3.2 → mangleframes-0.3.4}/viewer/frontend/src/components/erd/ERDValidationPanel.tsx +0 -0
  51. {mangleframes-0.3.2 → mangleframes-0.3.4}/viewer/frontend/src/components/erd/TableNode.tsx +0 -0
  52. {mangleframes-0.3.2 → mangleframes-0.3.4}/viewer/frontend/src/components/erd/__tests__/ERDDragDrop.test.tsx +0 -0
  53. {mangleframes-0.3.2 → mangleframes-0.3.4}/viewer/frontend/src/components/erd/index.ts +0 -0
  54. {mangleframes-0.3.2 → mangleframes-0.3.4}/viewer/frontend/src/components/layout/ContextPanel.tsx +0 -0
  55. {mangleframes-0.3.2 → mangleframes-0.3.4}/viewer/frontend/src/components/layout/Layout.tsx +0 -0
  56. {mangleframes-0.3.2 → mangleframes-0.3.4}/viewer/frontend/src/components/layout/MainContent.tsx +0 -0
  57. {mangleframes-0.3.2 → mangleframes-0.3.4}/viewer/frontend/src/components/layout/Sidebar.tsx +0 -0
  58. {mangleframes-0.3.2 → mangleframes-0.3.4}/viewer/frontend/src/components/layout/StatusBar.tsx +0 -0
  59. {mangleframes-0.3.2 → mangleframes-0.3.4}/viewer/frontend/src/components/layout/TabBar.tsx +0 -0
  60. {mangleframes-0.3.2 → mangleframes-0.3.4}/viewer/frontend/src/components/layout/TopBar.tsx +0 -0
  61. {mangleframes-0.3.2 → mangleframes-0.3.4}/viewer/frontend/src/components/quality/AlertBuilder.tsx +0 -0
  62. {mangleframes-0.3.2 → mangleframes-0.3.4}/viewer/frontend/src/components/quality/QualityDashboard.tsx +0 -0
  63. {mangleframes-0.3.2 → mangleframes-0.3.4}/viewer/frontend/src/index.css +0 -0
  64. {mangleframes-0.3.2 → mangleframes-0.3.4}/viewer/frontend/src/lib/erdValidation.ts +0 -0
  65. {mangleframes-0.3.2 → mangleframes-0.3.4}/viewer/frontend/src/main.tsx +0 -0
  66. {mangleframes-0.3.2 → mangleframes-0.3.4}/viewer/frontend/src/stores/dataStore.ts +0 -0
  67. {mangleframes-0.3.2 → mangleframes-0.3.4}/viewer/frontend/src/stores/erdStore.ts +0 -0
  68. {mangleframes-0.3.2 → mangleframes-0.3.4}/viewer/frontend/src/stores/uiStore.ts +0 -0
  69. {mangleframes-0.3.2 → mangleframes-0.3.4}/viewer/frontend/src/test/setup.ts +0 -0
  70. {mangleframes-0.3.2 → mangleframes-0.3.4}/viewer/frontend/tailwind.config.js +0 -0
  71. {mangleframes-0.3.2 → mangleframes-0.3.4}/viewer/frontend/tsconfig.json +0 -0
  72. {mangleframes-0.3.2 → mangleframes-0.3.4}/viewer/frontend/tsconfig.node.json +0 -0
  73. {mangleframes-0.3.2 → mangleframes-0.3.4}/viewer/frontend/vite.config.ts +0 -0
  74. {mangleframes-0.3.2 → mangleframes-0.3.4}/viewer/src/alert_handlers.rs +0 -0
  75. {mangleframes-0.3.2 → mangleframes-0.3.4}/viewer/src/arrow_reader.rs +0 -0
  76. {mangleframes-0.3.2 → mangleframes-0.3.4}/viewer/src/dashboard.rs +0 -0
  77. {mangleframes-0.3.2 → mangleframes-0.3.4}/viewer/src/export.rs +0 -0
  78. {mangleframes-0.3.2 → mangleframes-0.3.4}/viewer/src/handlers.rs +0 -0
  79. {mangleframes-0.3.2 → mangleframes-0.3.4}/viewer/src/history_analysis.rs +0 -0
  80. {mangleframes-0.3.2 → mangleframes-0.3.4}/viewer/src/main.rs +0 -0
  81. {mangleframes-0.3.2 → mangleframes-0.3.4}/viewer/src/perf.rs +0 -0
  82. {mangleframes-0.3.2 → mangleframes-0.3.4}/viewer/src/spark_client.rs +0 -0
  83. {mangleframes-0.3.2 → mangleframes-0.3.4}/viewer/src/stats.rs +0 -0
  84. {mangleframes-0.3.2 → mangleframes-0.3.4}/viewer/src/test_helpers.rs +0 -0
  85. {mangleframes-0.3.2 → mangleframes-0.3.4}/viewer/src/web_server.rs +0 -0
  86. {mangleframes-0.3.2 → mangleframes-0.3.4}/viewer/src/websocket.rs +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mangleframes
3
- Version: 0.3.2
3
+ Version: 0.3.4
4
4
  Classifier: Programming Language :: Python :: 3
5
5
  Classifier: Programming Language :: Rust
6
6
  Classifier: License :: OSI Approved :: MIT License
@@ -4,7 +4,7 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "mangleframes"
7
- version = "0.3.2"
7
+ version = "0.3.4"
8
8
  description = "PySpark DataFrame viewer with modern web UI"
9
9
  requires-python = ">=3.12"
10
10
  license = { text = "MIT" }
@@ -32,7 +32,7 @@ from .session import SparkSession, get_proxy_port, get_spark_session
32
32
  if TYPE_CHECKING:
33
33
  from pyspark.sql import DataFrame
34
34
 
35
- __version__ = "0.3.2"
35
+ __version__ = "0.3.4"
36
36
 
37
37
  # Import alert classes for convenience (optional dependency)
38
38
  try:
@@ -0,0 +1,442 @@
1
+ import { useState } from 'react'
2
+ import { useMutation } from '@tanstack/react-query'
3
+ import { useDataStore } from '@/stores/dataStore'
4
+ import { Play, Loader2, Calendar } from 'lucide-react'
5
+ import { api, CoverageResult } from '@/lib/api'
6
+
7
+ interface JoinStatistics {
8
+ left_total: number
9
+ right_total: number
10
+ matched_left: number
11
+ matched_right: number
12
+ match_rate_left: number
13
+ match_rate_right: number
14
+ cardinality: string
15
+ left_null_keys: number
16
+ right_null_keys: number
17
+ left_duplicate_keys: number
18
+ right_duplicate_keys: number
19
+ }
20
+
21
+ interface UnmatchedData {
22
+ rows: Record<string, unknown>[]
23
+ total: number
24
+ columns_limited: boolean
25
+ }
26
+
27
+ interface JoinResult {
28
+ statistics: JoinStatistics
29
+ left_unmatched: UnmatchedData
30
+ right_unmatched: UnmatchedData
31
+ }
32
+
33
+ export function JoinAnalyzer() {
34
+ const frames = useDataStore((s) => s.frames)
35
+ const [leftTable, setLeftTable] = useState('')
36
+ const [rightTable, setRightTable] = useState('')
37
+ const [leftKey, setLeftKey] = useState('')
38
+ const [rightKey, setRightKey] = useState('')
39
+ const [result, setResult] = useState<JoinResult | null>(null)
40
+ const [bucketSize, setBucketSize] = useState<'day' | 'week' | 'month'>('month')
41
+ const [coverageResult, setCoverageResult] = useState<CoverageResult | null>(null)
42
+
43
+ const mutation = useMutation({
44
+ mutationFn: async () => {
45
+ const response = await fetch('/api/join/analyze', {
46
+ method: 'POST',
47
+ headers: { 'Content-Type': 'application/json' },
48
+ body: JSON.stringify({
49
+ left_table: leftTable,
50
+ right_table: rightTable,
51
+ left_keys: [leftKey],
52
+ right_keys: [rightKey],
53
+ }),
54
+ })
55
+ if (!response.ok) {
56
+ const error = await response.json()
57
+ throw new Error(error.error || 'Analysis failed')
58
+ }
59
+ return response.json()
60
+ },
61
+ onSuccess: (data) => setResult(data),
62
+ })
63
+
64
+ const coverageMutation = useMutation({
65
+ mutationFn: async () => {
66
+ console.log('[JoinAnalyzer] Analyzing date coverage...')
67
+ return api.analyzeHistory({
68
+ frames: [
69
+ { frame: leftTable, columns: [leftKey] },
70
+ { frame: rightTable, columns: [rightKey] },
71
+ ],
72
+ join_pairs: [{
73
+ source_frame: leftTable,
74
+ target_frame: rightTable,
75
+ source_keys: [leftKey],
76
+ target_keys: [rightKey],
77
+ }],
78
+ bucket_size: bucketSize,
79
+ })
80
+ },
81
+ onSuccess: (data) => {
82
+ console.log('[JoinAnalyzer] Coverage analysis complete')
83
+ setCoverageResult(data)
84
+ },
85
+ })
86
+
87
+ const handleAnalyze = () => {
88
+ if (leftTable && rightTable && leftKey && rightKey) {
89
+ mutation.mutate()
90
+ }
91
+ }
92
+
93
+ return (
94
+ <div className="p-3">
95
+ <h3 className="text-sm font-semibold text-mf-text mb-4">Join Analysis</h3>
96
+
97
+ <div className="space-y-3">
98
+ <div>
99
+ <label className="block text-xs text-mf-muted mb-1">Left Table</label>
100
+ <select
101
+ value={leftTable}
102
+ onChange={(e) => setLeftTable(e.target.value)}
103
+ className="w-full px-2 py-1 text-xs bg-mf-bg border border-mf-border rounded text-mf-text"
104
+ >
105
+ <option value="">Select table...</option>
106
+ {frames.map((f) => (
107
+ <option key={f} value={f}>{f}</option>
108
+ ))}
109
+ </select>
110
+ </div>
111
+
112
+ <div>
113
+ <label className="block text-xs text-mf-muted mb-1">Left Key</label>
114
+ <input
115
+ type="text"
116
+ value={leftKey}
117
+ onChange={(e) => setLeftKey(e.target.value)}
118
+ placeholder="Column name"
119
+ className="w-full px-2 py-1 text-xs bg-mf-bg border border-mf-border rounded text-mf-text"
120
+ />
121
+ </div>
122
+
123
+ <div>
124
+ <label className="block text-xs text-mf-muted mb-1">Right Table</label>
125
+ <select
126
+ value={rightTable}
127
+ onChange={(e) => setRightTable(e.target.value)}
128
+ className="w-full px-2 py-1 text-xs bg-mf-bg border border-mf-border rounded text-mf-text"
129
+ >
130
+ <option value="">Select table...</option>
131
+ {frames.map((f) => (
132
+ <option key={f} value={f}>{f}</option>
133
+ ))}
134
+ </select>
135
+ </div>
136
+
137
+ <div>
138
+ <label className="block text-xs text-mf-muted mb-1">Right Key</label>
139
+ <input
140
+ type="text"
141
+ value={rightKey}
142
+ onChange={(e) => setRightKey(e.target.value)}
143
+ placeholder="Column name"
144
+ className="w-full px-2 py-1 text-xs bg-mf-bg border border-mf-border rounded text-mf-text"
145
+ />
146
+ </div>
147
+
148
+ <button
149
+ onClick={handleAnalyze}
150
+ disabled={mutation.isPending || !leftTable || !rightTable || !leftKey || !rightKey}
151
+ className="w-full flex items-center justify-center gap-2 px-3 py-2 text-xs bg-mf-accent hover:bg-mf-accent/80 text-white rounded disabled:opacity-50"
152
+ >
153
+ {mutation.isPending ? (
154
+ <Loader2 size={12} className="animate-spin" />
155
+ ) : (
156
+ <Play size={12} />
157
+ )}
158
+ Analyze Join
159
+ </button>
160
+
161
+ {mutation.isError && (
162
+ <div className="p-2 text-xs text-red-400 bg-red-500/10 rounded">
163
+ {mutation.error instanceof Error ? mutation.error.message : 'Analysis failed'}
164
+ </div>
165
+ )}
166
+
167
+ {result && (
168
+ <div className="mt-4 p-3 bg-mf-panel rounded border border-mf-border">
169
+ <div className="text-xs font-medium text-mf-text mb-3">Results</div>
170
+ <div className="space-y-2 text-xs">
171
+ <div className="flex justify-between">
172
+ <span className="text-mf-muted">Left rows:</span>
173
+ <span className="text-mf-text">{result.statistics.left_total.toLocaleString()}</span>
174
+ </div>
175
+ <div className="flex justify-between">
176
+ <span className="text-mf-muted">Right rows:</span>
177
+ <span className="text-mf-text">{result.statistics.right_total.toLocaleString()}</span>
178
+ </div>
179
+ <div className="flex justify-between">
180
+ <span className="text-mf-muted">Matched (left):</span>
181
+ <span className="text-green-400">{result.statistics.matched_left.toLocaleString()}</span>
182
+ </div>
183
+ <div className="flex justify-between">
184
+ <span className="text-mf-muted">Matched (right):</span>
185
+ <span className="text-green-400">{result.statistics.matched_right.toLocaleString()}</span>
186
+ </div>
187
+ <div className="flex justify-between">
188
+ <span className="text-mf-muted">Left only:</span>
189
+ <span className="text-yellow-400">{result.left_unmatched.total.toLocaleString()}</span>
190
+ </div>
191
+ <div className="flex justify-between">
192
+ <span className="text-mf-muted">Right only:</span>
193
+ <span className="text-yellow-400">{result.right_unmatched.total.toLocaleString()}</span>
194
+ </div>
195
+ <div className="border-t border-mf-border pt-2 mt-2">
196
+ <div className="flex justify-between">
197
+ <span className="text-mf-muted">Cardinality:</span>
198
+ <span className="text-mf-accent font-mono">{result.statistics.cardinality}</span>
199
+ </div>
200
+ <div className="flex justify-between">
201
+ <span className="text-mf-muted">Left match rate:</span>
202
+ <span className="text-mf-accent">{(result.statistics.match_rate_left * 100).toFixed(1)}%</span>
203
+ </div>
204
+ <div className="flex justify-between">
205
+ <span className="text-mf-muted">Right match rate:</span>
206
+ <span className="text-mf-accent">{(result.statistics.match_rate_right * 100).toFixed(1)}%</span>
207
+ </div>
208
+ </div>
209
+ {(result.statistics.left_null_keys > 0 || result.statistics.right_null_keys > 0 ||
210
+ result.statistics.left_duplicate_keys > 0 || result.statistics.right_duplicate_keys > 0) && (
211
+ <div className="border-t border-mf-border pt-2 mt-2">
212
+ <div className="text-xs font-medium text-mf-muted mb-1">Key Quality</div>
213
+ {result.statistics.left_null_keys > 0 && (
214
+ <div className="flex justify-between">
215
+ <span className="text-mf-muted">Left null keys:</span>
216
+ <span className="text-red-400">{result.statistics.left_null_keys.toLocaleString()}</span>
217
+ </div>
218
+ )}
219
+ {result.statistics.right_null_keys > 0 && (
220
+ <div className="flex justify-between">
221
+ <span className="text-mf-muted">Right null keys:</span>
222
+ <span className="text-red-400">{result.statistics.right_null_keys.toLocaleString()}</span>
223
+ </div>
224
+ )}
225
+ {result.statistics.left_duplicate_keys > 0 && (
226
+ <div className="flex justify-between">
227
+ <span className="text-mf-muted">Left duplicate keys:</span>
228
+ <span className="text-orange-400">{result.statistics.left_duplicate_keys.toLocaleString()}</span>
229
+ </div>
230
+ )}
231
+ {result.statistics.right_duplicate_keys > 0 && (
232
+ <div className="flex justify-between">
233
+ <span className="text-mf-muted">Right duplicate keys:</span>
234
+ <span className="text-orange-400">{result.statistics.right_duplicate_keys.toLocaleString()}</span>
235
+ </div>
236
+ )}
237
+ </div>
238
+ )}
239
+ </div>
240
+ </div>
241
+ )}
242
+
243
+ {/* Date Coverage Analysis */}
244
+ <div className="mt-4 pt-4 border-t border-mf-border">
245
+ <h4 className="text-xs font-medium text-mf-text mb-3 flex items-center gap-2">
246
+ <Calendar size={12} />
247
+ Date Coverage
248
+ </h4>
249
+
250
+ <div className="flex gap-1 mb-3">
251
+ {(['day', 'week', 'month'] as const).map((size) => (
252
+ <button
253
+ key={size}
254
+ onClick={() => setBucketSize(size)}
255
+ className={`px-2 py-1 text-xs rounded ${
256
+ bucketSize === size
257
+ ? 'bg-mf-accent text-white'
258
+ : 'bg-mf-panel text-mf-muted hover:text-mf-text'
259
+ }`}
260
+ >
261
+ {size.charAt(0).toUpperCase() + size.slice(1)}
262
+ </button>
263
+ ))}
264
+ </div>
265
+
266
+ <button
267
+ onClick={() => coverageMutation.mutate()}
268
+ disabled={coverageMutation.isPending || !leftTable || !rightTable || !leftKey || !rightKey}
269
+ className="w-full flex items-center justify-center gap-2 px-3 py-2 text-xs bg-mf-panel hover:bg-mf-border text-mf-text rounded disabled:opacity-50"
270
+ >
271
+ {coverageMutation.isPending ? (
272
+ <Loader2 size={12} className="animate-spin" />
273
+ ) : (
274
+ <Calendar size={12} />
275
+ )}
276
+ Analyze Coverage
277
+ </button>
278
+
279
+ {coverageMutation.isError && (
280
+ <div className="mt-2 p-2 text-xs text-red-400 bg-red-500/10 rounded">
281
+ {coverageMutation.error instanceof Error ? coverageMutation.error.message : 'Coverage analysis failed'}
282
+ </div>
283
+ )}
284
+
285
+ {coverageResult && (
286
+ <CoverageResults coverage={coverageResult} />
287
+ )}
288
+ </div>
289
+ </div>
290
+ </div>
291
+ )
292
+ }
293
+
294
+ function CoverageResults({ coverage }: { coverage: CoverageResult }) {
295
+ return (
296
+ <div className="mt-3 space-y-3">
297
+ {/* Overlap Zone Banner */}
298
+ {coverage.overlap_zone && (
299
+ <div className={`p-3 rounded text-xs ${
300
+ coverage.overlap_zone.valid
301
+ ? 'bg-green-500/10 border border-green-500/50 text-green-400'
302
+ : 'bg-red-500/10 border border-red-500/50 text-red-400'
303
+ }`}>
304
+ {coverage.overlap_zone.valid
305
+ ? `Overlap: ${coverage.overlap_zone.start} → ${coverage.overlap_zone.end} (${coverage.overlap_zone.span})`
306
+ : 'No date overlap - INNER join would return 0 rows'}
307
+ </div>
308
+ )}
309
+
310
+ {/* Temporal Range Table */}
311
+ {coverage.temporal_ranges.length > 0 && (
312
+ <div className="p-2 bg-mf-panel rounded">
313
+ <div className="text-xs font-medium text-mf-text mb-2">Date Ranges</div>
314
+ <table className="w-full text-xs">
315
+ <thead>
316
+ <tr className="text-mf-muted text-left">
317
+ <th className="pb-1">Frame</th>
318
+ <th className="pb-1">Min</th>
319
+ <th className="pb-1">Max</th>
320
+ <th className="pb-1 text-right">Rows</th>
321
+ <th className="pb-1 text-right">Nulls</th>
322
+ <th className="pb-1 text-right">Gaps</th>
323
+ </tr>
324
+ </thead>
325
+ <tbody className="text-mf-text">
326
+ {coverage.temporal_ranges.map(r => (
327
+ <tr key={`${r.frame}-${r.column}`}>
328
+ <td className="py-0.5 truncate max-w-[80px]" title={r.frame}>
329
+ {r.frame.split('.').pop()}
330
+ </td>
331
+ <td className="py-0.5">{r.min_date || '-'}</td>
332
+ <td className="py-0.5">{r.max_date || '-'}</td>
333
+ <td className="py-0.5 text-right">{r.total_rows.toLocaleString()}</td>
334
+ <td className={`py-0.5 text-right ${r.null_dates > 0 ? 'text-yellow-400' : ''}`}>
335
+ {r.null_dates}
336
+ </td>
337
+ <td className={`py-0.5 text-right ${r.internal_gaps.length > 0 ? 'text-yellow-400' : ''}`}>
338
+ {r.internal_gaps.length}
339
+ </td>
340
+ </tr>
341
+ ))}
342
+ </tbody>
343
+ </table>
344
+ </div>
345
+ )}
346
+
347
+ {coverage.temporal_ranges.length === 0 && (
348
+ <div className="p-2 text-xs text-mf-muted bg-mf-panel rounded">
349
+ No date columns detected
350
+ </div>
351
+ )}
352
+
353
+ {/* Coverage Timeline */}
354
+ {coverage.timeline.length > 0 && (
355
+ <div className="p-2 bg-mf-panel rounded">
356
+ <div className="text-xs font-medium text-mf-text mb-2">Timeline</div>
357
+ <div className="space-y-1">
358
+ {coverage.frames.map(frame => (
359
+ <div key={frame} className="flex items-center gap-2">
360
+ <span className="text-xs text-mf-muted w-20 truncate" title={frame}>
361
+ {frame.split('.').pop()}
362
+ </span>
363
+ <div className="flex flex-1 gap-px">
364
+ {coverage.timeline.map(bucket => (
365
+ <div
366
+ key={bucket.bucket}
367
+ className={`h-3 flex-1 rounded-sm ${
368
+ bucket.frame_counts[frame] > 0 ? 'bg-mf-accent' : 'bg-mf-border'
369
+ }`}
370
+ title={`${bucket.bucket}: ${bucket.frame_counts[frame] || 0} rows`}
371
+ />
372
+ ))}
373
+ </div>
374
+ </div>
375
+ ))}
376
+ </div>
377
+ <div className="flex justify-between text-[10px] text-mf-muted mt-1">
378
+ <span>{coverage.timeline[0]?.bucket}</span>
379
+ <span>{coverage.timeline[coverage.timeline.length - 1]?.bucket}</span>
380
+ </div>
381
+ </div>
382
+ )}
383
+
384
+ {/* Data Loss Section */}
385
+ {coverage.data_loss.filter(d => d.total_lost > 0).length > 0 && (
386
+ <div className="p-2 bg-mf-panel rounded">
387
+ <div className="text-xs font-medium text-mf-text mb-2">Data Loss (INNER Join)</div>
388
+ <div className="space-y-1">
389
+ {coverage.data_loss.filter(d => d.total_lost > 0).map(loss => (
390
+ <div key={loss.frame} className="p-2 bg-red-500/10 rounded text-xs">
391
+ <span className="text-red-400">
392
+ {loss.frame.split('.').pop()}: {loss.total_lost.toLocaleString()} rows ({loss.pct_lost.toFixed(1)}%)
393
+ </span>
394
+ </div>
395
+ ))}
396
+ </div>
397
+ </div>
398
+ )}
399
+
400
+ {/* Join Predictions */}
401
+ {coverage.predictions.length > 0 && (
402
+ <div className="p-2 bg-mf-panel rounded">
403
+ <div className="text-xs font-medium text-mf-text mb-2">Join Predictions</div>
404
+ <div className="space-y-1">
405
+ {coverage.predictions.map(pred => (
406
+ <div key={pred.join_type} className="flex justify-between text-xs">
407
+ <span className="text-mf-muted">{pred.join_type}</span>
408
+ <span className="text-mf-text">
409
+ {pred.estimated_rows.toLocaleString()} rows ({pred.coverage_pct.toFixed(1)}%)
410
+ </span>
411
+ </div>
412
+ ))}
413
+ </div>
414
+ </div>
415
+ )}
416
+
417
+ {/* Pairwise Overlaps */}
418
+ {coverage.pairwise_overlaps.length > 0 && (
419
+ <div className="p-2 bg-mf-panel rounded">
420
+ <div className="text-xs font-medium text-mf-text mb-2">Key Overlap</div>
421
+ <div className="space-y-2">
422
+ {coverage.pairwise_overlaps.map(o => (
423
+ <div key={`${o.frame1}-${o.frame2}`} className="text-xs">
424
+ <div className="flex justify-between">
425
+ <span className="text-mf-muted">
426
+ {o.frame1.split('.').pop()} ↔ {o.frame2.split('.').pop()}
427
+ </span>
428
+ <span className="text-mf-accent">{o.overlap_pct.toFixed(1)}% overlap</span>
429
+ </div>
430
+ <div className="flex gap-3 text-mf-muted mt-0.5">
431
+ <span>Common: {o.both.toLocaleString()}</span>
432
+ <span>Left only: {o.left_only.toLocaleString()}</span>
433
+ <span>Right only: {o.right_only.toLocaleString()}</span>
434
+ </div>
435
+ </div>
436
+ ))}
437
+ </div>
438
+ </div>
439
+ )}
440
+ </div>
441
+ )
442
+ }