part of 'data_service.dart'; extension DataServiceTraction on DataService { Future fetchHadTraction({int offset = 0, int limit = 100}) async { await fetchTraction( hadOnly: true, offset: offset, limit: limit, append: offset > 0, ); } Future fetchTraction({ bool hadOnly = false, int offset = 0, int limit = 100, String? locoClass, String? locoNumber, bool mileageFirst = true, bool append = false, Map? filters, }) async { _isTractionLoading = true; try { final params = StringBuffer('?limit=$limit&offset=$offset'); if (hadOnly) params.write('&had_only=true'); if (!mileageFirst) params.write('&mileage_first=false'); final payload = {}; if (locoClass != null && locoClass.isNotEmpty) { payload['class'] = locoClass; } if (locoNumber != null && locoNumber.isNotEmpty) { payload['number'] = locoNumber; } if (filters != null) { filters.forEach((key, value) { if (value == null) return; if (value is String && value.trim().isEmpty) return; payload[key] = value; }); } final json = await api.post( '/locos/search/v2${params.toString()}', payload.isEmpty ? null : payload, ); if (json is List) { final newItems = json.map((e) => LocoSummary.fromJson(e)).toList(); _traction = append ? [..._traction, ...newItems] : newItems; _tractionHasMore = newItems.length >= limit - 1; } else { throw Exception('Unexpected traction response: $json'); } } catch (e) { debugPrint('Failed to fetch traction: $e'); if (!append) { _traction = []; } _tractionHasMore = false; } finally { _isTractionLoading = false; _notifyAsync(); } } Future> fetchLocoTimeline( int locoId, { bool includeAllPending = false, }) async { _isLocoTimelineLoading[locoId] = true; _notifyAsync(); try { final baseJson = await api.get('/loco/get-timeline/$locoId'); final timeline = LocoAttrVersion.fromGroupedJson(baseJson); final baseKeys = timeline .map(_entryKey) .where((key) => key.isNotEmpty) .toSet(); final pendingEntries = []; final pendingSeen = {}; void addPending(List entries) { for (final entry in entries) { final key = _entryKey(entry); if (pendingSeen.contains(key)) continue; if (baseKeys.contains(key)) continue; pendingSeen.add(key); pendingEntries.add(entry); baseKeys.add(key); } } try { final pendingJson = await api.get('/event/pending/user?loco_id=$locoId'); addPending( _parsePendingLocoEvents( pendingJson, locoId, canModerate: false, ), ); } catch (e) { debugPrint('Failed to fetch pending loco events for $locoId: $e'); } if (includeAllPending) { try { final pendingJson = await api.get('/event/pending?loco_id=$locoId'); addPending( _parsePendingLocoEvents( pendingJson, locoId, canModerate: true, ), ); } catch (e) { debugPrint('Failed to fetch all pending loco events for $locoId: $e'); } } final merged = [ ...timeline, ...pendingEntries, ]..sort(LocoAttrVersion.compareByStart); _locoTimelines[locoId] = merged; return merged; } catch (e) { debugPrint('Failed to fetch loco timeline for $locoId: $e'); _locoTimelines[locoId] = []; return []; } finally { _isLocoTimelineLoading[locoId] = false; _notifyAsync(); } } Future> fetchUserPendingEvents(int locoId) async { try { final pendingJson = await api.get('/event/pending/user?loco_id=$locoId'); return _parsePendingLocoEvents( pendingJson, locoId, canModerate: false, ); } catch (e) { debugPrint('Failed to fetch user pending events for $locoId: $e'); return const []; } } List _parsePendingLocoEvents( dynamic json, int fallbackLocoId, { bool canModerate = false, }) { if (json is! List) return const []; final entries = []; final seen = {}; for (final item in json) { if (item is! Map) continue; final map = _extractPendingEventMap(item); final locoId = (map['loco_id'] as num?)?.toInt() ?? fallbackLocoId; final maskedValidFrom = map['masked_valid_from']?.toString(); final precision = map['precision_level']?.toString(); final username = map['username']?.toString(); final sourceEventId = (map['loco_event_id'] as num?)?.toInt(); final startDate = _parsePendingDate(map); final status = map['moderation_status']?.toString().toLowerCase(); if (status != null && status != 'pending') continue; if (startDate == null && (maskedValidFrom == null || maskedValidFrom.trim().isEmpty)) { continue; } final valueMap = _decodeEventValues(map['loco_event_value']); if (valueMap.isEmpty) continue; valueMap.forEach((attr, rawValue) { final attrCode = attr.toString(); if (attrCode.isEmpty) return; final key = [ sourceEventId?.toString() ?? '', attrCode.toLowerCase(), maskedValidFrom ?? '', startDate?.toIso8601String() ?? '', ].join('|'); if (seen.contains(key)) return; seen.add(key); final parsedValue = _PendingTimelineValue.fromDynamic(rawValue); entries.add( LocoAttrVersion( attrCode: attrCode, locoId: locoId, valueStr: parsedValue.valueStr, valueInt: parsedValue.valueInt, valueBool: parsedValue.valueBool, valueDate: parsedValue.valueDate, valueNorm: parsedValue.valueNorm ?? rawValue, validFrom: startDate, maskedValidFrom: maskedValidFrom, precisionLevel: precision, suggestedBy: username, sourceEventId: sourceEventId, isPending: true, canModeratePending: canModerate, ), ); }); } return entries; } String _entryKey(LocoAttrVersion entry) { final attr = entry.attrCode.toLowerCase(); final masked = entry.maskedValidFrom?.trim() ?? ''; final start = entry.validFrom?.toIso8601String() ?? ''; final source = entry.sourceEventId?.toString() ?? ''; return '$attr|$masked|$start|$source'; } Map _extractPendingEventMap(Map raw) { if (raw['event_info'] is Map) { final eventInfo = Map.from(raw['event_info']); final merged = {...eventInfo}; for (final key in [ 'loco_id', 'masked_valid_from', 'precision_level', 'username', 'loco_event_id', 'earliest_date', 'valid_from', 'loco_event_date', 'event_year', 'event_month', 'event_day', 'loco_event_value', ]) { merged.putIfAbsent(key, () => raw[key]); } return merged; } return Map.from(raw); } Map _decodeEventValues(dynamic raw) { if (raw is Map) { return raw.map((key, value) => MapEntry(key.toString(), value)); } if (raw is String) { final trimmed = raw.trim(); if (trimmed.isEmpty) return const {}; try { final decoded = jsonDecode(trimmed); if (decoded is Map) { return decoded.map((key, value) => MapEntry(key.toString(), value)); } } catch (_) {} } return const {}; } DateTime? _parsePendingDate(Map json) { DateTime? parseDate(dynamic value) { if (value == null) return null; if (value is DateTime) return value; return DateTime.tryParse(value.toString()); } // Try masked date by normalising unknown parts to 01 so we can position on the axis. final masked = json['masked_valid_from']?.toString(); if (masked is String && masked.contains('-')) { final sanitized = masked .replaceAll(RegExp(r'[Xx?]{2}'), '01') .replaceAll(RegExp(r'[Xx?]'), '1'); final parsedMasked = DateTime.tryParse(sanitized); if (parsedMasked != null) return parsedMasked; } for (final key in ['earliest_date', 'valid_from', 'loco_event_date']) { final parsed = parseDate(json[key]); if (parsed != null) return parsed; } final year = _asNullableInt(json['event_year']); if (year != null && year > 0) { final monthValue = _asNullableInt(json['event_month']) ?? 1; final dayValue = _asNullableInt(json['event_day']) ?? 1; final month = monthValue.clamp(1, 12).toInt(); final day = dayValue.clamp(1, 31).toInt(); try { return DateTime(year, month, day); } catch (_) {} } return null; } int? _asNullableInt(dynamic value) { if (value == null) return null; if (value is int) return value; if (value is num) return value.toInt(); return int.tryParse(value.toString()); } Future createLoco(Map payload) async { try { final response = await api.put('/loco/new', payload); final locoClass = payload['class']?.toString(); if (locoClass != null && locoClass.isNotEmpty && !_locoClasses.contains(locoClass)) { _locoClasses = [..._locoClasses, locoClass]; } _notifyAsync(); return response; } catch (e) { debugPrint('Failed to create loco: $e'); rethrow; } } Future> fetchClassList({bool force = false}) async { if (!force && _locoClasses.isNotEmpty) return _locoClasses; if (force) _locoClasses = []; try { final json = await api.get('/loco/classlist'); if (json is List) { _locoClasses = json.map((e) => e.toString()).toList(); _notifyAsync(); } } catch (e) { debugPrint('Failed to fetch class list: $e'); } return _locoClasses; } Future fetchLatestLocoChanges({ int limit = 100, int offset = 0, bool append = false, }) async { _isLatestLocoChangesLoading = true; _notifyAsync(); try { final json = await api.get('/loco/changes/latest?limit=$limit&offset=$offset'); dynamic results = json; if (json is Map && json['data'] is List) { results = json['data']; } if (results is List) { final parsed = []; for (final item in results) { if (item is Map) { parsed.add(LocoChange.fromJson(item)); } else if (item is Map) { parsed.add( LocoChange.fromJson( item.map((key, value) => MapEntry(key.toString(), value)), ), ); } } if (append) { _latestLocoChanges = [..._latestLocoChanges, ...parsed]; } else { _latestLocoChanges = parsed; } final fetchedCount = parsed.length; _latestLocoChangesFetched = append ? offset + fetchedCount : fetchedCount; _latestLocoChangesHasMore = _latestLocoChangesFetched < 5000; } else { throw Exception('Unexpected latest loco changes response: $json'); } } catch (e) { debugPrint('Failed to fetch latest loco changes: $e'); _latestLocoChanges = []; _latestLocoChangesHasMore = false; _latestLocoChangesFetched = 0; } finally { _isLatestLocoChangesLoading = false; _notifyAsync(); } } Future?> fetchClassStats(String locoClass) async { try { final path = Uri.encodeComponent(locoClass); final json = await api.get('/loco/class/stats/$path/user'); if (json is Map) { return Map.from(json); } debugPrint('Unexpected class stats response for $locoClass: $json'); } catch (e) { debugPrint('Failed to fetch class stats for $locoClass: $e'); } return null; } Future> fetchClassLeaderboard( String locoClass, { bool friends = false, }) async { try { final path = Uri.encodeComponent(locoClass); final suffix = friends ? '/friends' : ''; final json = await api.get('/stats/class/$path/leaderboard$suffix'); List? list; if (json is List) { list = json; } else if (json is Map) { for (final key in ['leaderboard', 'data', 'items', 'results']) { final value = json[key]; if (value is List) { list = value; break; } } } return list ?.whereType() .map((e) => LeaderboardEntry.fromJson( e.map((k, v) => MapEntry(k.toString(), v)), )) .toList() ?? const []; } catch (e) { debugPrint( 'Failed to fetch class leaderboard for $locoClass (friends=$friends): $e', ); return const []; } } Future acceptPendingLoco({required int locoId}) async { try { await api.put('/loco/pending/approve/$locoId', null); } catch (e) { debugPrint('Failed to approve pending loco $locoId: $e'); rethrow; } } Future rejectPendingLoco({ required int locoId, int? replacementLocoId, String? rejectedReason, }) async { try { final body = {}; if (replacementLocoId != null) { body['replacement_loco_id'] = replacementLocoId; } if (rejectedReason != null && rejectedReason.trim().isNotEmpty) { body['rejected_reason'] = rejectedReason.trim(); } await api.put('/loco/pending/reject/$locoId', body.isEmpty ? null : body); } catch (e) { debugPrint('Failed to reject pending loco $locoId: $e'); rethrow; } } Future transferAllocations({ required int fromLocoId, required int toLocoId, }) async { try { await api.post('/loco/alloc/transfer', { 'loco_id': fromLocoId, 'to_loco_id': toLocoId, }); } catch (e) { debugPrint('Failed to transfer allocations $fromLocoId -> $toLocoId: $e'); rethrow; } } Future adminDeleteLoco({required int locoId}) async { try { await api.delete('/loco/admin/delete/$locoId'); } catch (e) { debugPrint('Failed to delete loco $locoId as admin: $e'); rethrow; } } } class _PendingTimelineValue { final String? valueStr; final int? valueInt; final bool? valueBool; final DateTime? valueDate; final dynamic valueNorm; const _PendingTimelineValue({ this.valueStr, this.valueInt, this.valueBool, this.valueDate, this.valueNorm, }); factory _PendingTimelineValue.fromDynamic(dynamic raw) { if (raw is Map || raw is List) { return _PendingTimelineValue(valueStr: jsonEncode(raw)); } if (raw is bool) return _PendingTimelineValue(valueBool: raw); if (raw is int) return _PendingTimelineValue(valueInt: raw); if (raw is num) return _PendingTimelineValue(valueNorm: raw); if (raw is String) { final trimmed = raw.trim(); if (trimmed.isEmpty) return const _PendingTimelineValue(valueStr: ''); final lower = trimmed.toLowerCase(); if (['true', 'yes', 'y'].contains(lower)) { return const _PendingTimelineValue(valueBool: true); } if (['false', 'no', 'n'].contains(lower)) { return const _PendingTimelineValue(valueBool: false); } final intVal = int.tryParse(trimmed); if (intVal != null) return _PendingTimelineValue(valueInt: intVal); final doubleVal = double.tryParse(trimmed); if (doubleVal != null) { return _PendingTimelineValue(valueNorm: doubleVal); } final dateVal = DateTime.tryParse(trimmed); if (dateVal != null) { return _PendingTimelineValue(valueDate: dateVal); } return _PendingTimelineValue(valueStr: trimmed); } return _PendingTimelineValue(valueNorm: raw); } }