From 78f1e5a1a7e187785bbfde86261f0f3f06e34fdc Mon Sep 17 00:00:00 2001 From: Sam-24-dev Date: Mon, 31 Aug 2026 21:03:05 +0100 Subject: [PATCH] feat(ui): show per-source data freshness --- frontend/lib/models/run_manifest_models.dart | 67 ++++++---- frontend/lib/screens/github_dashboard.dart | 2 +- frontend/lib/screens/reddit_dashboard.dart | 2 +- .../lib/screens/stackoverflow_dashboard.dart | 2 +- frontend/lib/screens/trends_tech_screen.dart | 4 +- frontend/lib/widgets/data_health_badge.dart | 21 ++-- .../test/models/run_manifest_models_test.dart | 115 +++++++----------- .../test/widgets/data_health_badge_test.dart | 29 ++++- 8 files changed, 135 insertions(+), 107 deletions(-) diff --git a/frontend/lib/models/run_manifest_models.dart b/frontend/lib/models/run_manifest_models.dart index a724992..90263b6 100644 --- a/frontend/lib/models/run_manifest_models.dart +++ b/frontend/lib/models/run_manifest_models.dart @@ -13,42 +13,67 @@ String buildAnalysisPeriodLabel(RunManifestPublic? manifest) { return 'Per\u00EDodo de an\u00E1lisis: ${start.year}-${end.year}'; } -String buildLastUpdatedLabel(RunManifestPublic? manifest) { - final DateTime? reference = _resolveLastUpdatedReference(manifest); - if (reference == null) { - return '\u00DAltima actualizaci\u00F3n (UTC): no disponible'; +const List kFreshnessSources = [ + 'github', + 'stackoverflow', + 'reddit', +]; + +const Map _sourceLabels = { + 'github': 'GitHub', + 'stackoverflow': 'Stack Overflow', + 'reddit': 'Reddit', +}; + +const Map _sourceDatasetPrefixes = { + 'github': 'github_', + 'stackoverflow': 'so_', + 'reddit': 'reddit_', +}; + +String buildSourceFreshnessLabel(RunManifestPublic? manifest, String source) { + final String label = _sourceLabels[source] ?? source; + final DateTime? updatedAt = _resolveSourceFreshness(manifest, source); + if (updatedAt == null) { + return '$label: no disponible'; } - final DateTime utc = reference.toUtc(); + final DateTime utc = updatedAt.toUtc(); final String day = utc.day.toString().padLeft(2, '0'); final String month = utc.month.toString().padLeft(2, '0'); - return '\u00DAltima actualizaci\u00F3n (UTC): $day/$month/${utc.year}'; + return '$label: $day/$month/${utc.year} UTC'; } -DateTime? _resolveLastUpdatedReference(RunManifestPublic? manifest) { - if (manifest == null) { +List buildSourceFreshnessLabels(RunManifestPublic? manifest) { + return kFreshnessSources + .map((String source) => buildSourceFreshnessLabel(manifest, source)) + .toList(growable: false); +} + +DateTime? _resolveSourceFreshness(RunManifestPublic? manifest, String source) { + final String? prefix = _sourceDatasetPrefixes[source]; + if (manifest == null || prefix == null) { return null; } - DateTime? latestDatasetUpdate; + DateTime? latestUpdate; for (final RunManifestDatasetSummary dataset in manifest.datasetSummaries) { - final DateTime? parsed = DateTime.tryParse(dataset.updatedAtUtc); - if (parsed == null) { + if (!dataset.dataset.startsWith(prefix)) { continue; } - if (latestDatasetUpdate == null || parsed.isAfter(latestDatasetUpdate)) { - latestDatasetUpdate = parsed; + final DateTime? parsed = _parseUtcTimestamp(dataset.updatedAtUtc); + if (parsed != null && + (latestUpdate == null || parsed.isAfter(latestUpdate))) { + latestUpdate = parsed; } } - if (latestDatasetUpdate != null) { - return latestDatasetUpdate; - } + return latestUpdate; +} - final DateTime? generatedAt = DateTime.tryParse(manifest.generatedAtUtc); - final DateTime? sourceEnd = DateTime.tryParse(manifest.sourceWindowEndUtc); - if (generatedAt != null && sourceEnd != null) { - return generatedAt.isAfter(sourceEnd) ? generatedAt : sourceEnd; +DateTime? _parseUtcTimestamp(String value) { + if (!value.endsWith('Z')) { + return null; } - return generatedAt ?? sourceEnd; + return DateTime.tryParse(value)?.toUtc(); } class RunManifestDatasetSummary { diff --git a/frontend/lib/screens/github_dashboard.dart b/frontend/lib/screens/github_dashboard.dart index c30eb10..a86c9ab 100644 --- a/frontend/lib/screens/github_dashboard.dart +++ b/frontend/lib/screens/github_dashboard.dart @@ -319,7 +319,7 @@ class _GithubDashboardState extends ConsumerState { ), const SizedBox(height: 4), Text( - buildLastUpdatedLabel(manifest), + buildSourceFreshnessLabel(manifest, 'github'), style: const TextStyle(fontSize: 13, color: Color(0xFF64748B)), ), if (isDegraded) diff --git a/frontend/lib/screens/reddit_dashboard.dart b/frontend/lib/screens/reddit_dashboard.dart index c34730c..d304d2f 100644 --- a/frontend/lib/screens/reddit_dashboard.dart +++ b/frontend/lib/screens/reddit_dashboard.dart @@ -179,7 +179,7 @@ class _RedditDashboardState extends ConsumerState { ), const SizedBox(height: 4), Text( - buildLastUpdatedLabel(manifest), + buildSourceFreshnessLabel(manifest, 'reddit'), style: const TextStyle( fontSize: 13, color: Color(0xFF64748B), diff --git a/frontend/lib/screens/stackoverflow_dashboard.dart b/frontend/lib/screens/stackoverflow_dashboard.dart index 5bf4e09..b53e4c6 100644 --- a/frontend/lib/screens/stackoverflow_dashboard.dart +++ b/frontend/lib/screens/stackoverflow_dashboard.dart @@ -290,7 +290,7 @@ class _StackOverflowDashboardState ), const SizedBox(height: 4), Text( - buildLastUpdatedLabel(manifest), + buildSourceFreshnessLabel(manifest, 'stackoverflow'), style: const TextStyle( fontSize: 13, color: Color(0xFF64748B), diff --git a/frontend/lib/screens/trends_tech_screen.dart b/frontend/lib/screens/trends_tech_screen.dart index 55c2d83..3e37a5c 100644 --- a/frontend/lib/screens/trends_tech_screen.dart +++ b/frontend/lib/screens/trends_tech_screen.dart @@ -254,7 +254,7 @@ class TrendsTechScreen extends ConsumerWidget { ), const SizedBox(height: 4), Text( - buildLastUpdatedLabel(manifest), + buildSourceFreshnessLabels(manifest).join('\n'), style: textTheme.bodySmall?.copyWith( color: const Color(0xFF64748B), ), @@ -409,7 +409,7 @@ class TrendsTechScreen extends ConsumerWidget { ), const SizedBox(height: 4), Text( - buildLastUpdatedLabel(manifest), + buildSourceFreshnessLabels(manifest).join('\n'), style: textTheme.bodySmall?.copyWith( color: const Color(0xFF64748B), ), diff --git a/frontend/lib/widgets/data_health_badge.dart b/frontend/lib/widgets/data_health_badge.dart index 13909b6..dde0abf 100644 --- a/frontend/lib/widgets/data_health_badge.dart +++ b/frontend/lib/widgets/data_health_badge.dart @@ -62,17 +62,17 @@ class DataHealthBadge extends ConsumerWidget { final FrontendHealthData health = healthState.data!; final RunManifestPublic? manifest = manifestState?.data; - final String generatedAt = manifest?.generatedAtUtc ?? '-'; + final List sourceFreshness = buildSourceFreshnessLabels(manifest); final int datasetCount = manifest?.datasetSummaries.length ?? 0; final String sources = manifest?.availableSources.join(', ') ?? 'sin fuentes'; - return 'quality: ${health.status}\n' - 'updated_at: $generatedAt\n' - 'sources: $sources (${health.availableSourcesCount}/3)\n' - 'datasets: $datasetCount\n' - 'degraded_mode: ${health.degradedMode}\n' - 'notes: ${health.message}'; + return 'Estado: ${health.status}\n' + '${sourceFreshness.join('\n')}\n' + 'Fuentes: $sources (${health.availableSourcesCount}/3)\n' + 'Datasets: $datasetCount\n' + 'Modo degradado: ${health.degradedMode}\n' + 'Notas: ${health.message}'; } @override @@ -103,13 +103,18 @@ class DataHealthBadge extends ConsumerWidget { final String label = _statusLabel(status); final DataLoadState? manifestState = manifestAsync.asData?.value; + final RunManifestPublic? manifest = manifestState?.data; + final List sourceFreshness = buildSourceFreshnessLabels( + manifest, + ); final String tooltip = _buildTooltipText(healthState, manifestState); return Tooltip( message: tooltip, child: Semantics( label: - 'Estado de datos $label, fuentes disponibles $sourcesCount de 3', + 'Estado de datos $label, fuentes disponibles $sourcesCount de 3. ' + '${sourceFreshness.join(', ')}', child: _buildChip( context, status: status, diff --git a/frontend/test/models/run_manifest_models_test.dart b/frontend/test/models/run_manifest_models_test.dart index 64560a6..8ebfd8b 100644 --- a/frontend/test/models/run_manifest_models_test.dart +++ b/frontend/test/models/run_manifest_models_test.dart @@ -65,7 +65,10 @@ void main() { 'so_languages_count': 0, }); - expect(buildAnalysisPeriodLabel(manifest), 'Per\u00edodo de an\u00e1lisis: 2025-2026'); + expect( + buildAnalysisPeriodLabel(manifest), + 'Per\u00edodo de an\u00e1lisis: 2025-2026', + ); }); test('buildAnalysisPeriodLabel falls back when window invalid', () { @@ -86,87 +89,55 @@ void main() { expect(buildAnalysisPeriodLabel(manifest), kAnalysisPeriodFallbackLabel); }); - test('buildLastUpdatedLabel returns latest dataset update date', () { + test('buildSourceFreshnessLabels uses only mapped dataset timestamps', () { final manifest = RunManifestPublic.fromMap({ - 'manifest_version': '1.0.0', - 'generated_at_utc': '2026-02-28T05:11:00Z', - 'source_window_start_utc': '2025-02-27T00:00:00Z', - 'source_window_end_utc': '2026-02-28T00:00:00Z', - 'quality_gate_status': 'pass', - 'degraded_mode': false, - 'available_sources': [], + 'generated_at_utc': '2026-09-01T12:00:00Z', 'dataset_summaries': [ { - 'dataset': 'trend_score', - 'row_count': 23, - 'quality_status': 'pass', - 'updated_at_utc': '2026-02-27T23:59:59Z', + 'dataset': 'github_lenguajes', + 'updated_at_utc': '2026-08-24T09:00:00Z', }, - ], - 'total_repos_extraidos': 0, - 'total_repos_clasificables': 0, - 'so_languages_count': 0, - }); - - expect( - buildLastUpdatedLabel(manifest), - '\u00daltima actualizaci\u00f3n (UTC): 27/02/2026', - ); - }); - - test('buildLastUpdatedLabel falls back when dataset timestamps are missing', () { - final manifest = RunManifestPublic.fromMap({ - 'manifest_version': '1.0.0', - 'generated_at_utc': '2026-03-01T05:11:00Z', - 'source_window_start_utc': '2025-03-01T00:00:00Z', - 'source_window_end_utc': '', - 'quality_gate_status': 'pass', - 'degraded_mode': false, - 'available_sources': [], - 'dataset_summaries': [ { - 'dataset': 'trend_score', - 'row_count': 23, - 'quality_status': 'pass', - 'updated_at_utc': '', + 'dataset': 'github_repos_2025', + 'updated_at_utc': '2026-08-23T09:00:00Z', }, - ], - 'total_repos_extraidos': 0, - 'total_repos_clasificables': 0, - 'so_languages_count': 0, - }); - - expect( - buildLastUpdatedLabel(manifest), - '\u00daltima actualizaci\u00f3n (UTC): 01/03/2026', - ); - }); - - test('buildLastUpdatedLabel uses newer fallback timestamp when datasets are missing', () { - final manifest = RunManifestPublic.fromMap({ - 'manifest_version': '1.0.0', - 'generated_at_utc': '2026-03-19T00:24:45Z', - 'source_window_start_utc': '2025-03-19T00:00:00Z', - 'source_window_end_utc': '2026-03-18T23:59:59Z', - 'quality_gate_status': 'pass', - 'degraded_mode': false, - 'available_sources': [], - 'dataset_summaries': [ { - 'dataset': 'trend_score', - 'row_count': 23, - 'quality_status': 'pass', - 'updated_at_utc': '', + 'dataset': 'so_volumen_preguntas', + 'updated_at_utc': '2026-08-24T10:00:00Z', + }, + { + 'dataset': 'reddit_temas_emergentes', + 'updated_at_utc': '2026-08-31T03:00:00Z', + }, + {'dataset': 'trend_score', 'updated_at_utc': '2026-09-01T12:00:00Z'}, + { + 'dataset': 'interseccion_github_reddit', + 'updated_at_utc': '2026-09-01T12:00:00Z', }, ], - 'total_repos_extraidos': 0, - 'total_repos_clasificables': 0, - 'so_languages_count': 0, }); - expect( - buildLastUpdatedLabel(manifest), - '\u00daltima actualizaci\u00f3n (UTC): 19/03/2026', - ); + expect(buildSourceFreshnessLabels(manifest), [ + 'GitHub: 24/08/2026 UTC', + 'Stack Overflow: 24/08/2026 UTC', + 'Reddit: 31/08/2026 UTC', + ]); }); + + test( + 'buildSourceFreshnessLabel never falls back to manifest generation time', + () { + final manifest = RunManifestPublic.fromMap({ + 'generated_at_utc': '2026-09-01T12:00:00Z', + 'dataset_summaries': [ + {'dataset': 'github_lenguajes', 'updated_at_utc': 'not-a-timestamp'}, + ], + }); + + expect( + buildSourceFreshnessLabel(manifest, 'github'), + 'GitHub: no disponible', + ); + }, + ); } diff --git a/frontend/test/widgets/data_health_badge_test.dart b/frontend/test/widgets/data_health_badge_test.dart index d119fc7..ef0d9b7 100644 --- a/frontend/test/widgets/data_health_badge_test.dart +++ b/frontend/test/widgets/data_health_badge_test.dart @@ -37,7 +37,26 @@ void main() { qualityGateStatus: 'pass', degradedMode: false, availableSources: const ['github', 'stackoverflow', 'reddit'], - datasetSummaries: const [], + datasetSummaries: const [ + RunManifestDatasetSummary( + dataset: 'github_lenguajes', + rowCount: 10, + qualityStatus: 'pass', + updatedAtUtc: '2026-08-24T09:00:00Z', + ), + RunManifestDatasetSummary( + dataset: 'so_volumen_preguntas', + rowCount: 10, + qualityStatus: 'pass', + updatedAtUtc: '2026-08-24T10:00:00Z', + ), + RunManifestDatasetSummary( + dataset: 'reddit_temas_emergentes', + rowCount: 10, + qualityStatus: 'pass', + updatedAtUtc: '2026-08-31T03:00:00Z', + ), + ], totalReposExtraidos: 1000, totalReposClasificables: 925, soLanguagesCount: 10, @@ -56,6 +75,14 @@ void main() { expect(find.byKey(const Key('data-health-badge')), findsOneWidget); expect(find.textContaining('pass'), findsOneWidget); expect(find.byType(Tooltip), findsOneWidget); + final Tooltip tooltip = tester.widget(find.byType(Tooltip)); + expect(tooltip.message, contains('GitHub: 24/08/2026 UTC')); + expect(tooltip.message, contains('Stack Overflow: 24/08/2026 UTC')); + expect(tooltip.message, contains('Reddit: 31/08/2026 UTC')); + expect( + find.bySemanticsLabel(RegExp('GitHub: 24/08/2026 UTC')), + findsOneWidget, + ); }); testWidgets('DataHealthBadge cae a unknown cuando no hay metadata', (