Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 46 additions & 21 deletions frontend/lib/models/run_manifest_models.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> kFreshnessSources = <String>[
'github',
'stackoverflow',
'reddit',
];

const Map<String, String> _sourceLabels = <String, String>{
'github': 'GitHub',
'stackoverflow': 'Stack Overflow',
'reddit': 'Reddit',
};

const Map<String, String> _sourceDatasetPrefixes = <String, String>{
'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<String> 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 {
Expand Down
2 changes: 1 addition & 1 deletion frontend/lib/screens/github_dashboard.dart
Original file line number Diff line number Diff line change
Expand Up @@ -319,7 +319,7 @@ class _GithubDashboardState extends ConsumerState<GithubDashboard> {
),
const SizedBox(height: 4),
Text(
buildLastUpdatedLabel(manifest),
buildSourceFreshnessLabel(manifest, 'github'),
style: const TextStyle(fontSize: 13, color: Color(0xFF64748B)),
),
if (isDegraded)
Expand Down
2 changes: 1 addition & 1 deletion frontend/lib/screens/reddit_dashboard.dart
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ class _RedditDashboardState extends ConsumerState<RedditDashboard> {
),
const SizedBox(height: 4),
Text(
buildLastUpdatedLabel(manifest),
buildSourceFreshnessLabel(manifest, 'reddit'),
style: const TextStyle(
fontSize: 13,
color: Color(0xFF64748B),
Expand Down
2 changes: 1 addition & 1 deletion frontend/lib/screens/stackoverflow_dashboard.dart
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,7 @@ class _StackOverflowDashboardState
),
const SizedBox(height: 4),
Text(
buildLastUpdatedLabel(manifest),
buildSourceFreshnessLabel(manifest, 'stackoverflow'),
style: const TextStyle(
fontSize: 13,
color: Color(0xFF64748B),
Expand Down
4 changes: 2 additions & 2 deletions frontend/lib/screens/trends_tech_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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),
),
Expand Down Expand Up @@ -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),
),
Expand Down
21 changes: 13 additions & 8 deletions frontend/lib/widgets/data_health_badge.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> 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
Expand Down Expand Up @@ -103,13 +103,18 @@ class DataHealthBadge extends ConsumerWidget {
final String label = _statusLabel(status);
final DataLoadState<RunManifestPublic>? manifestState =
manifestAsync.asData?.value;
final RunManifestPublic? manifest = manifestState?.data;
final List<String> 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,
Expand Down
115 changes: 43 additions & 72 deletions frontend/test/models/run_manifest_models_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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', () {
Expand All @@ -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',
);
},
);
}
29 changes: 28 additions & 1 deletion frontend/test/widgets/data_health_badge_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,26 @@ void main() {
qualityGateStatus: 'pass',
degradedMode: false,
availableSources: const ['github', 'stackoverflow', 'reddit'],
datasetSummaries: const [],
datasetSummaries: const <RunManifestDatasetSummary>[
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,
Expand All @@ -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<Tooltip>(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', (
Expand Down