mangleframes 0.1.6__tar.gz → 0.1.7__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.
- {mangleframes-0.1.6 → mangleframes-0.1.7}/Cargo.lock +3 -0
- {mangleframes-0.1.6 → mangleframes-0.1.7}/PKG-INFO +2 -1
- {mangleframes-0.1.6 → mangleframes-0.1.7}/pyproject.toml +2 -1
- {mangleframes-0.1.6 → mangleframes-0.1.7}/python/mangleframes/__init__.py +1 -1
- {mangleframes-0.1.6 → mangleframes-0.1.7}/python/mangleframes/protocol.py +180 -0
- {mangleframes-0.1.6 → mangleframes-0.1.7}/viewer/Cargo.toml +1 -0
- {mangleframes-0.1.6 → mangleframes-0.1.7}/viewer/src/history_analysis.rs +145 -0
- mangleframes-0.1.7/viewer/src/history_handlers.rs +288 -0
- {mangleframes-0.1.6 → mangleframes-0.1.7}/viewer/src/socket_client.rs +25 -0
- {mangleframes-0.1.6 → mangleframes-0.1.7}/viewer/static/app.js +294 -79
- {mangleframes-0.1.6 → mangleframes-0.1.7}/viewer/static/index.html +29 -4
- {mangleframes-0.1.6 → mangleframes-0.1.7}/viewer/static/style.css +216 -14
- mangleframes-0.1.6/viewer/src/history_handlers.rs +0 -148
- {mangleframes-0.1.6 → mangleframes-0.1.7}/Cargo.toml +0 -0
- {mangleframes-0.1.6 → mangleframes-0.1.7}/python/mangleframes/launcher.py +0 -0
- {mangleframes-0.1.6 → mangleframes-0.1.7}/python/mangleframes/server.py +0 -0
- {mangleframes-0.1.6 → mangleframes-0.1.7}/viewer/src/arrow_reader.rs +0 -0
- {mangleframes-0.1.6 → mangleframes-0.1.7}/viewer/src/export.rs +0 -0
- {mangleframes-0.1.6 → mangleframes-0.1.7}/viewer/src/handlers.rs +0 -0
- {mangleframes-0.1.6 → mangleframes-0.1.7}/viewer/src/join_analysis.rs +0 -0
- {mangleframes-0.1.6 → mangleframes-0.1.7}/viewer/src/join_handlers.rs +0 -0
- {mangleframes-0.1.6 → mangleframes-0.1.7}/viewer/src/main.rs +0 -0
- {mangleframes-0.1.6 → mangleframes-0.1.7}/viewer/src/perf.rs +0 -0
- {mangleframes-0.1.6 → mangleframes-0.1.7}/viewer/src/query_engine.rs +0 -0
- {mangleframes-0.1.6 → mangleframes-0.1.7}/viewer/src/stats.rs +0 -0
- {mangleframes-0.1.6 → mangleframes-0.1.7}/viewer/src/web_server.rs +0 -0
- {mangleframes-0.1.6 → mangleframes-0.1.7}/viewer/src/websocket.rs +0 -0
|
@@ -604,7 +604,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
|
604
604
|
checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2"
|
|
605
605
|
dependencies = [
|
|
606
606
|
"iana-time-zone",
|
|
607
|
+
"js-sys",
|
|
607
608
|
"num-traits",
|
|
609
|
+
"wasm-bindgen",
|
|
608
610
|
"windows-link",
|
|
609
611
|
]
|
|
610
612
|
|
|
@@ -1955,6 +1957,7 @@ dependencies = [
|
|
|
1955
1957
|
"arrow-ipc",
|
|
1956
1958
|
"arrow-json",
|
|
1957
1959
|
"axum",
|
|
1960
|
+
"chrono",
|
|
1958
1961
|
"clap",
|
|
1959
1962
|
"datafusion",
|
|
1960
1963
|
"futures-util",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mangleframes
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.7
|
|
4
4
|
Classifier: Programming Language :: Python :: 3
|
|
5
5
|
Classifier: Programming Language :: Rust
|
|
6
6
|
Classifier: License :: OSI Approved :: MIT License
|
|
@@ -8,6 +8,7 @@ Requires-Dist: databricks-connect>=16.1.7
|
|
|
8
8
|
Requires-Dist: loguru>=0.7.3
|
|
9
9
|
Requires-Dist: maturin>=1.11.2
|
|
10
10
|
Requires-Dist: pyarrow>=11.0.0
|
|
11
|
+
Requires-Dist: python-dateutil>=2.8.0
|
|
11
12
|
Requires-Dist: pytest>=7.0 ; extra == 'dev'
|
|
12
13
|
Requires-Dist: maturin>=1.4 ; extra == 'dev'
|
|
13
14
|
Requires-Dist: pyspark>=3.4.0 ; extra == 'spark'
|
|
@@ -4,7 +4,7 @@ build-backend = "maturin"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "mangleframes"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.7"
|
|
8
8
|
description = "PySpark DataFrame viewer with modern web UI"
|
|
9
9
|
requires-python = ">=3.11"
|
|
10
10
|
license = { text = "MIT" }
|
|
@@ -18,6 +18,7 @@ dependencies = [
|
|
|
18
18
|
"loguru>=0.7.3",
|
|
19
19
|
"maturin>=1.11.2",
|
|
20
20
|
"pyarrow>=11.0.0",
|
|
21
|
+
"python-dateutil>=2.8.0",
|
|
21
22
|
]
|
|
22
23
|
|
|
23
24
|
[project.optional-dependencies]
|
|
@@ -298,6 +298,172 @@ def handle_join_temporal(
|
|
|
298
298
|
})
|
|
299
299
|
|
|
300
300
|
|
|
301
|
+
def _generate_expected_buckets(min_date: str, max_date: str, granularity: str) -> list[str]:
|
|
302
|
+
"""Generate all expected bucket keys between min and max dates."""
|
|
303
|
+
from datetime import datetime, timedelta
|
|
304
|
+
from dateutil.relativedelta import relativedelta
|
|
305
|
+
|
|
306
|
+
start = datetime.strptime(min_date[:10], "%Y-%m-%d")
|
|
307
|
+
end = datetime.strptime(max_date[:10], "%Y-%m-%d")
|
|
308
|
+
buckets = []
|
|
309
|
+
|
|
310
|
+
if granularity == "day":
|
|
311
|
+
current = start
|
|
312
|
+
while current <= end:
|
|
313
|
+
buckets.append(current.strftime("%Y-%m-%d"))
|
|
314
|
+
current += timedelta(days=1)
|
|
315
|
+
elif granularity == "week":
|
|
316
|
+
current = start - timedelta(days=start.weekday())
|
|
317
|
+
end_aligned = end - timedelta(days=end.weekday())
|
|
318
|
+
while current <= end_aligned:
|
|
319
|
+
buckets.append(current.strftime("%Y-%m-%d"))
|
|
320
|
+
current += timedelta(weeks=1)
|
|
321
|
+
elif granularity == "month":
|
|
322
|
+
current = start.replace(day=1)
|
|
323
|
+
end_aligned = end.replace(day=1)
|
|
324
|
+
while current <= end_aligned:
|
|
325
|
+
buckets.append(current.strftime("%Y-%m-%d"))
|
|
326
|
+
current += relativedelta(months=1)
|
|
327
|
+
|
|
328
|
+
return buckets
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _find_gaps(expected: list[str], actual: set[str]) -> list[dict]:
|
|
332
|
+
"""Find contiguous ranges of missing buckets."""
|
|
333
|
+
gaps = []
|
|
334
|
+
gap_start = None
|
|
335
|
+
gap_count = 0
|
|
336
|
+
|
|
337
|
+
for bucket in expected:
|
|
338
|
+
if bucket not in actual:
|
|
339
|
+
if gap_start is None:
|
|
340
|
+
gap_start = bucket
|
|
341
|
+
gap_count = 1
|
|
342
|
+
else:
|
|
343
|
+
gap_count += 1
|
|
344
|
+
else:
|
|
345
|
+
if gap_start is not None:
|
|
346
|
+
gaps.append({
|
|
347
|
+
"start": gap_start,
|
|
348
|
+
"end": expected[expected.index(gap_start) + gap_count - 1],
|
|
349
|
+
"periods": gap_count
|
|
350
|
+
})
|
|
351
|
+
gap_start = None
|
|
352
|
+
gap_count = 0
|
|
353
|
+
|
|
354
|
+
if gap_start is not None:
|
|
355
|
+
gaps.append({
|
|
356
|
+
"start": gap_start,
|
|
357
|
+
"end": expected[expected.index(gap_start) + gap_count - 1],
|
|
358
|
+
"periods": gap_count
|
|
359
|
+
})
|
|
360
|
+
|
|
361
|
+
return gaps
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def handle_temporal_range(
|
|
365
|
+
registry: dict[str, DataFrame], name: str, column: str, granularity: str
|
|
366
|
+
) -> bytes:
|
|
367
|
+
"""Return detailed temporal range statistics for a frame."""
|
|
368
|
+
if name not in registry:
|
|
369
|
+
return encode_error(f"DataFrame '{name}' not found")
|
|
370
|
+
|
|
371
|
+
df = registry[name]
|
|
372
|
+
if column not in [f.name for f in df.schema.fields]:
|
|
373
|
+
return encode_error(f"Column '{column}' not found in '{name}'")
|
|
374
|
+
|
|
375
|
+
field = next(f for f in df.schema.fields if f.name == column)
|
|
376
|
+
if not _is_temporal_type(str(field.dataType)):
|
|
377
|
+
return encode_error(f"Column '{column}' is not a temporal type")
|
|
378
|
+
|
|
379
|
+
if granularity not in ("day", "week", "month"):
|
|
380
|
+
return encode_error(f"Invalid granularity: {granularity}")
|
|
381
|
+
|
|
382
|
+
start_time = time.perf_counter()
|
|
383
|
+
|
|
384
|
+
agg_result = df.agg(
|
|
385
|
+
F.min(column).alias("min_date"),
|
|
386
|
+
F.max(column).alias("max_date"),
|
|
387
|
+
F.count(F.lit(1)).alias("total_rows"),
|
|
388
|
+
F.sum(F.when(F.col(column).isNull(), 1).otherwise(0)).alias("null_dates"),
|
|
389
|
+
F.countDistinct(column).alias("distinct_dates")
|
|
390
|
+
).collect()[0]
|
|
391
|
+
|
|
392
|
+
min_date = agg_result["min_date"]
|
|
393
|
+
max_date = agg_result["max_date"]
|
|
394
|
+
total_rows = agg_result["total_rows"]
|
|
395
|
+
null_dates = agg_result["null_dates"] or 0
|
|
396
|
+
distinct_dates = agg_result["distinct_dates"]
|
|
397
|
+
|
|
398
|
+
internal_gaps = []
|
|
399
|
+
if min_date and max_date:
|
|
400
|
+
truncated = df.withColumn("__bucket", F.date_trunc(granularity, F.col(column)))
|
|
401
|
+
bucket_rows = truncated.select("__bucket").distinct().collect()
|
|
402
|
+
actual_buckets = {
|
|
403
|
+
str(row["__bucket"])[:10] for row in bucket_rows if row["__bucket"]
|
|
404
|
+
}
|
|
405
|
+
expected = _generate_expected_buckets(str(min_date), str(max_date), granularity)
|
|
406
|
+
internal_gaps = _find_gaps(expected, actual_buckets)
|
|
407
|
+
|
|
408
|
+
compute_ms = int((time.perf_counter() - start_time) * 1000)
|
|
409
|
+
|
|
410
|
+
return encode_json_response({
|
|
411
|
+
"frame": name,
|
|
412
|
+
"column": column,
|
|
413
|
+
"granularity": granularity,
|
|
414
|
+
"min_date": str(min_date)[:10] if min_date else None,
|
|
415
|
+
"max_date": str(max_date)[:10] if max_date else None,
|
|
416
|
+
"total_rows": total_rows,
|
|
417
|
+
"null_dates": null_dates,
|
|
418
|
+
"distinct_dates": distinct_dates,
|
|
419
|
+
"internal_gaps": internal_gaps,
|
|
420
|
+
"compute_ms": compute_ms
|
|
421
|
+
})
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def handle_temporal_loss(
|
|
425
|
+
registry: dict[str, DataFrame],
|
|
426
|
+
name: str, column: str,
|
|
427
|
+
overlap_start: str, overlap_end: str
|
|
428
|
+
) -> bytes:
|
|
429
|
+
"""Return row counts outside the overlap zone."""
|
|
430
|
+
if name not in registry:
|
|
431
|
+
return encode_error(f"DataFrame '{name}' not found")
|
|
432
|
+
|
|
433
|
+
df = registry[name]
|
|
434
|
+
if column not in [f.name for f in df.schema.fields]:
|
|
435
|
+
return encode_error(f"Column '{column}' not found in '{name}'")
|
|
436
|
+
|
|
437
|
+
field = next(f for f in df.schema.fields if f.name == column)
|
|
438
|
+
if not _is_temporal_type(str(field.dataType)):
|
|
439
|
+
return encode_error(f"Column '{column}' is not a temporal type")
|
|
440
|
+
|
|
441
|
+
start_time = time.perf_counter()
|
|
442
|
+
|
|
443
|
+
result = df.agg(
|
|
444
|
+
F.count(F.lit(1)).alias("total"),
|
|
445
|
+
F.sum(F.when(F.col(column) < overlap_start, 1).otherwise(0)).alias("before"),
|
|
446
|
+
F.sum(F.when(F.col(column) > overlap_end, 1).otherwise(0)).alias("after")
|
|
447
|
+
).collect()[0]
|
|
448
|
+
|
|
449
|
+
total = result["total"]
|
|
450
|
+
rows_before = result["before"] or 0
|
|
451
|
+
rows_after = result["after"] or 0
|
|
452
|
+
total_lost = rows_before + rows_after
|
|
453
|
+
pct_lost = (total_lost / total * 100) if total > 0 else 0.0
|
|
454
|
+
|
|
455
|
+
compute_ms = int((time.perf_counter() - start_time) * 1000)
|
|
456
|
+
|
|
457
|
+
return encode_json_response({
|
|
458
|
+
"frame": name,
|
|
459
|
+
"rows_before": rows_before,
|
|
460
|
+
"rows_after": rows_after,
|
|
461
|
+
"total_lost": total_lost,
|
|
462
|
+
"pct_lost": round(pct_lost, 2),
|
|
463
|
+
"compute_ms": compute_ms
|
|
464
|
+
})
|
|
465
|
+
|
|
466
|
+
|
|
301
467
|
def handle_join_overlap(
|
|
302
468
|
registry: dict[str, DataFrame],
|
|
303
469
|
frame1: str, frame2: str,
|
|
@@ -418,4 +584,18 @@ def dispatch_command(
|
|
|
418
584
|
return encode_error("At least one column required per frame")
|
|
419
585
|
return handle_join_overlap(registry, frame1, frame2, cols1, cols2)
|
|
420
586
|
|
|
587
|
+
if command.startswith("TEMPORAL_RANGE:"):
|
|
588
|
+
parts = command[15:].split(":")
|
|
589
|
+
if len(parts) != 3:
|
|
590
|
+
return encode_error("Invalid TEMPORAL_RANGE format. Use TEMPORAL_RANGE:name:column:granularity")
|
|
591
|
+
name, column, granularity = parts
|
|
592
|
+
return handle_temporal_range(registry, name, column, granularity)
|
|
593
|
+
|
|
594
|
+
if command.startswith("TEMPORAL_LOSS:"):
|
|
595
|
+
parts = command[14:].split(":")
|
|
596
|
+
if len(parts) != 4:
|
|
597
|
+
return encode_error("Invalid TEMPORAL_LOSS format. Use TEMPORAL_LOSS:name:column:start:end")
|
|
598
|
+
name, column, overlap_start, overlap_end = parts
|
|
599
|
+
return handle_temporal_loss(registry, name, column, overlap_start, overlap_end)
|
|
600
|
+
|
|
421
601
|
return encode_error(f"Unknown command: {command}")
|
|
@@ -61,6 +61,46 @@ pub struct JoinPrediction {
|
|
|
61
61
|
pub coverage_pct: f64,
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
65
|
+
pub struct DateGap {
|
|
66
|
+
pub start: String,
|
|
67
|
+
pub end: String,
|
|
68
|
+
pub periods: usize,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
72
|
+
pub struct TemporalRangeStats {
|
|
73
|
+
pub frame: String,
|
|
74
|
+
pub column: String,
|
|
75
|
+
pub granularity: String,
|
|
76
|
+
pub min_date: Option<String>,
|
|
77
|
+
pub max_date: Option<String>,
|
|
78
|
+
pub total_rows: usize,
|
|
79
|
+
pub null_dates: usize,
|
|
80
|
+
pub distinct_dates: usize,
|
|
81
|
+
pub internal_gaps: Vec<DateGap>,
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
85
|
+
pub struct OverlapZone {
|
|
86
|
+
pub start: String,
|
|
87
|
+
pub end: String,
|
|
88
|
+
pub span: String,
|
|
89
|
+
pub days: i64,
|
|
90
|
+
pub valid: bool,
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
94
|
+
pub struct FrameDataLoss {
|
|
95
|
+
pub frame: String,
|
|
96
|
+
pub rows_before_overlap: usize,
|
|
97
|
+
pub rows_after_overlap: usize,
|
|
98
|
+
pub total_lost: usize,
|
|
99
|
+
pub pct_lost: f64,
|
|
100
|
+
pub range_lost_before: Option<String>,
|
|
101
|
+
pub range_lost_after: Option<String>,
|
|
102
|
+
}
|
|
103
|
+
|
|
64
104
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
65
105
|
pub struct CoverageResult {
|
|
66
106
|
pub frames: Vec<String>,
|
|
@@ -69,12 +109,17 @@ pub struct CoverageResult {
|
|
|
69
109
|
pub pairwise_overlaps: Vec<PairwiseOverlap>,
|
|
70
110
|
pub timeline: Vec<TimelineBucket>,
|
|
71
111
|
pub predictions: Vec<JoinPrediction>,
|
|
112
|
+
pub temporal_ranges: Vec<TemporalRangeStats>,
|
|
113
|
+
pub overlap_zone: Option<OverlapZone>,
|
|
114
|
+
pub data_loss: Vec<FrameDataLoss>,
|
|
72
115
|
}
|
|
73
116
|
|
|
74
117
|
pub struct HistoryAnalyzer {
|
|
75
118
|
key_stats: HashMap<String, FrameKeyStats>,
|
|
76
119
|
temporal_stats: HashMap<String, FrameTemporalStats>,
|
|
77
120
|
overlaps: Vec<PairwiseOverlap>,
|
|
121
|
+
temporal_ranges: HashMap<String, TemporalRangeStats>,
|
|
122
|
+
data_loss: HashMap<String, FrameDataLoss>,
|
|
78
123
|
}
|
|
79
124
|
|
|
80
125
|
impl HistoryAnalyzer {
|
|
@@ -83,6 +128,8 @@ impl HistoryAnalyzer {
|
|
|
83
128
|
key_stats: HashMap::new(),
|
|
84
129
|
temporal_stats: HashMap::new(),
|
|
85
130
|
overlaps: Vec::new(),
|
|
131
|
+
temporal_ranges: HashMap::new(),
|
|
132
|
+
data_loss: HashMap::new(),
|
|
86
133
|
}
|
|
87
134
|
}
|
|
88
135
|
|
|
@@ -98,6 +145,14 @@ impl HistoryAnalyzer {
|
|
|
98
145
|
self.overlaps.push(overlap);
|
|
99
146
|
}
|
|
100
147
|
|
|
148
|
+
pub fn add_temporal_range(&mut self, stats: TemporalRangeStats) {
|
|
149
|
+
self.temporal_ranges.insert(stats.frame.clone(), stats);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
pub fn add_data_loss(&mut self, loss: FrameDataLoss) {
|
|
153
|
+
self.data_loss.insert(loss.frame.clone(), loss);
|
|
154
|
+
}
|
|
155
|
+
|
|
101
156
|
pub fn compute_coverage(&self, frames: &[String]) -> Result<CoverageResult, HistoryError> {
|
|
102
157
|
if frames.is_empty() {
|
|
103
158
|
return Err(HistoryError::NoFrames);
|
|
@@ -123,6 +178,18 @@ impl HistoryAnalyzer {
|
|
|
123
178
|
let timeline = self.build_timeline(frames);
|
|
124
179
|
let predictions = self.compute_predictions(frames, &pairwise_overlaps);
|
|
125
180
|
|
|
181
|
+
let temporal_ranges: Vec<TemporalRangeStats> = frames
|
|
182
|
+
.iter()
|
|
183
|
+
.filter_map(|f| self.temporal_ranges.get(f).cloned())
|
|
184
|
+
.collect();
|
|
185
|
+
|
|
186
|
+
let overlap_zone = self.compute_overlap_zone(&temporal_ranges);
|
|
187
|
+
|
|
188
|
+
let data_loss: Vec<FrameDataLoss> = frames
|
|
189
|
+
.iter()
|
|
190
|
+
.filter_map(|f| self.data_loss.get(f).cloned())
|
|
191
|
+
.collect();
|
|
192
|
+
|
|
126
193
|
Ok(CoverageResult {
|
|
127
194
|
frames: frames.to_vec(),
|
|
128
195
|
key_stats,
|
|
@@ -130,9 +197,87 @@ impl HistoryAnalyzer {
|
|
|
130
197
|
pairwise_overlaps,
|
|
131
198
|
timeline,
|
|
132
199
|
predictions,
|
|
200
|
+
temporal_ranges,
|
|
201
|
+
overlap_zone,
|
|
202
|
+
data_loss,
|
|
133
203
|
})
|
|
134
204
|
}
|
|
135
205
|
|
|
206
|
+
fn compute_overlap_zone(&self, ranges: &[TemporalRangeStats]) -> Option<OverlapZone> {
|
|
207
|
+
if ranges.is_empty() {
|
|
208
|
+
return None;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
let min_dates: Vec<&str> = ranges
|
|
212
|
+
.iter()
|
|
213
|
+
.filter_map(|r| r.min_date.as_deref())
|
|
214
|
+
.collect();
|
|
215
|
+
|
|
216
|
+
let max_dates: Vec<&str> = ranges
|
|
217
|
+
.iter()
|
|
218
|
+
.filter_map(|r| r.max_date.as_deref())
|
|
219
|
+
.collect();
|
|
220
|
+
|
|
221
|
+
if min_dates.is_empty() || max_dates.is_empty() {
|
|
222
|
+
return None;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
let overlap_start = min_dates.iter().max()?.to_string();
|
|
226
|
+
let overlap_end = max_dates.iter().min()?.to_string();
|
|
227
|
+
|
|
228
|
+
if overlap_start > overlap_end {
|
|
229
|
+
return Some(OverlapZone {
|
|
230
|
+
start: overlap_start,
|
|
231
|
+
end: overlap_end,
|
|
232
|
+
span: "No overlap".to_string(),
|
|
233
|
+
days: 0,
|
|
234
|
+
valid: false,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
let days = Self::compute_days_between(&overlap_start, &overlap_end);
|
|
239
|
+
let span = Self::format_date_span(days);
|
|
240
|
+
|
|
241
|
+
Some(OverlapZone {
|
|
242
|
+
start: overlap_start,
|
|
243
|
+
end: overlap_end,
|
|
244
|
+
span,
|
|
245
|
+
days,
|
|
246
|
+
valid: true,
|
|
247
|
+
})
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
fn compute_days_between(start: &str, end: &str) -> i64 {
|
|
251
|
+
use chrono::NaiveDate;
|
|
252
|
+
let start_date = NaiveDate::parse_from_str(start, "%Y-%m-%d").ok();
|
|
253
|
+
let end_date = NaiveDate::parse_from_str(end, "%Y-%m-%d").ok();
|
|
254
|
+
match (start_date, end_date) {
|
|
255
|
+
(Some(s), Some(e)) => (e - s).num_days(),
|
|
256
|
+
_ => 0,
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
fn format_date_span(days: i64) -> String {
|
|
261
|
+
if days < 0 {
|
|
262
|
+
return "No overlap".to_string();
|
|
263
|
+
}
|
|
264
|
+
let years = days / 365;
|
|
265
|
+
let months = (days % 365) / 30;
|
|
266
|
+
let remaining_days = days % 30;
|
|
267
|
+
|
|
268
|
+
let mut parts = Vec::new();
|
|
269
|
+
if years > 0 {
|
|
270
|
+
parts.push(format!("{}y", years));
|
|
271
|
+
}
|
|
272
|
+
if months > 0 {
|
|
273
|
+
parts.push(format!("{}m", months));
|
|
274
|
+
}
|
|
275
|
+
if remaining_days > 0 || parts.is_empty() {
|
|
276
|
+
parts.push(format!("{}d", remaining_days));
|
|
277
|
+
}
|
|
278
|
+
parts.join(" ")
|
|
279
|
+
}
|
|
280
|
+
|
|
136
281
|
fn build_timeline(&self, frames: &[String]) -> Vec<TimelineBucket> {
|
|
137
282
|
let mut all_buckets: HashMap<String, HashMap<String, usize>> = HashMap::new();
|
|
138
283
|
|