Fixed horizontal scrollbar, limit per page by 10 rows on desktop

Fixed duplicate entries in activity logs
This commit is contained in:
2026-03-02 20:11:49 +08:00
parent b9722106ff
commit 43d2bd4f95
3 changed files with 421 additions and 88 deletions
+151 -22
View File
@@ -1,6 +1,5 @@
import 'dart:async';
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'dart:convert';
@@ -19,6 +18,82 @@ import 'stream_recovery.dart';
import 'realtime_controller.dart';
import '../utils/app_time.dart';
String _stableJson(dynamic value) {
if (value is Map) {
final keys = value.keys.map((k) => k.toString()).toList()..sort();
final entries = <String, dynamic>{};
for (final key in keys) {
entries[key] = _stableJson(value[key]);
}
return jsonEncode(entries);
}
if (value is List) {
return jsonEncode(value.map(_stableJson).toList(growable: false));
}
return jsonEncode(value);
}
String _activityFingerprint(Map<String, dynamic> row) {
final taskId = row['task_id']?.toString() ?? '';
final actorId = row['actor_id']?.toString() ?? '';
final actionType = row['action_type']?.toString() ?? '';
final meta = row['meta'];
return '$taskId|$actorId|$actionType|${_stableJson(meta)}';
}
/// Build a UI-display fingerprint for a log entry so that entries that would
/// render identically are collapsed to a single row.
///
/// For auto-save fields (filled_*), the actual text value changes every millisecond
/// as the user types, creating hundreds of entries. By ignoring the text value and
/// grouping strictly by "Task + Actor + Action + Visual Minute", all auto-save
/// logs within the exact same minute collapse into a single display row.
String _uiFingerprint(TaskActivityLog log) {
const oncePerTask = {'created', 'started', 'completed'};
if (oncePerTask.contains(log.actionType)) {
return '${log.taskId}|${log.actionType}';
}
// The UI displays time as "MMM dd, yyyy hh:mm AA". Grouping by year, month,
// day, hour, and minute ensures we collapse duplicates that look identical on screen.
final visualMinute =
'${log.createdAt.year}-${log.createdAt.month}-${log.createdAt.day}-${log.createdAt.hour}-${log.createdAt.minute}';
if (log.actionType.startsWith('filled_')) {
// DO NOT include the meta/value here! If the user types "A", "AB", "ABC" in the
// same minute, including the value would make them mathematically "unique" and
// bypass the deduplication. Grouping by time+action solves the auto-save spam.
return '${log.taskId}|${log.actorId ?? ''}|${log.actionType}|$visualMinute';
}
// For other actions, we also include the normalized meta to be safe.
final metaStr = _normaliseMeta(log.meta);
return '${log.taskId}|${log.actorId ?? ''}|${log.actionType}|$metaStr|$visualMinute';
}
List<TaskActivityLog> _dedupeActivityLogs(List<TaskActivityLog> logs) {
if (logs.length < 2) return logs;
// Sort newest first so we always keep the most recent copy of a duplicate.
final sorted = [...logs]..sort((a, b) => b.createdAt.compareTo(a.createdAt));
final seen = <String>{};
final deduped = <TaskActivityLog>[];
for (final log in sorted) {
final fp = _uiFingerprint(log);
if (seen.add(fp)) {
deduped.add(log);
}
}
return deduped;
}
/// Normalise a meta value for comparison: treat null and empty-map
/// identically, and sort map keys for stability.
String _normaliseMeta(dynamic meta) {
if (meta == null) return '';
final s = _stableJson(meta);
return (s == 'null' || s == '{}') ? '' : s;
}
// Helper to insert activity log rows while sanitizing nulls and
// avoiding exceptions from malformed payloads. Accepts either a Map
// or a List<Map>.
@@ -38,7 +113,18 @@ Future<void> _insertActivityRows(dynamic client, dynamic rows) async {
.whereType<Map<String, dynamic>>()
.toList();
if (sanitized.isEmpty) return;
await client.from('task_activity_logs').insert(sanitized);
final seen = <String>{};
final dedupedBatch = <Map<String, dynamic>>[];
for (final row in sanitized) {
final fingerprint = _activityFingerprint(row);
if (seen.add(fingerprint)) {
dedupedBatch.add(row);
}
}
if (dedupedBatch.isEmpty) return;
await client.from('task_activity_logs').insert(dedupedBatch);
} else if (rows is Map) {
final m = Map<String, dynamic>.from(rows);
m.removeWhere((k, v) => v == null);
@@ -487,7 +573,7 @@ final taskActivityLogsProvider =
);
ref.onDispose(wrapper.dispose);
return wrapper.stream.map((result) => result.data);
return wrapper.stream.map((result) => _dedupeActivityLogs(result.data));
});
final taskAssignmentsControllerProvider = Provider<TaskAssignmentsController>((
@@ -952,31 +1038,74 @@ class TasksController {
required String status,
String? reason,
}) async {
Map<String, dynamic>? taskRow;
try {
final row = await _client
.from('tasks')
.select('status, request_type, request_category, action_taken')
.eq('id', taskId)
.maybeSingle();
if (row is! Map<String, dynamic>) {
throw Exception('Task not found');
}
taskRow = row;
} catch (e) {
rethrow;
}
final currentStatus = (taskRow['status'] as String?)?.trim() ?? '';
if (currentStatus == 'closed' ||
currentStatus == 'cancelled' ||
currentStatus == 'completed') {
if (currentStatus != status) {
throw Exception(
'Status cannot be changed after a task is $currentStatus.',
);
}
return;
}
if (status == 'cancelled') {
if (reason == null || reason.trim().isEmpty) {
throw Exception('Cancellation requires a reason.');
}
}
if (status == 'completed') {
// fetch current metadata to validate several required fields
try {
final row = await _client
.from('tasks')
// include all columns that must be non-null/empty before completing
.select(
// signatories are not needed for validation; action_taken is still
// required so we include it alongside the type/category fields.
'request_type, request_category, action_taken',
)
.eq('id', taskId)
.maybeSingle();
if (row is! Map<String, dynamic>) {
throw Exception('Task not found');
}
final rt = row['request_type'];
final rc = row['request_category'];
final action = row['action_taken'];
if (status == 'in_progress') {
final assignmentRows = await _client
.from('task_assignments')
.select('user_id')
.eq('task_id', taskId);
final assignedUserIds = (assignmentRows as List)
.map((row) => row['user_id']?.toString())
.whereType<String>()
.where((id) => id.isNotEmpty)
.toSet()
.toList();
if (assignedUserIds.isEmpty) {
throw Exception(
'Assign at least one IT Staff before starting this task.',
);
}
final itStaffRows = await _client
.from('profiles')
.select('id')
.inFilter('id', assignedUserIds)
.eq('role', 'it_staff');
final hasItStaff = (itStaffRows as List).isNotEmpty;
if (!hasItStaff) {
throw Exception(
'Assign at least one IT Staff before starting this task.',
);
}
}
if (status == 'completed') {
try {
final rt = taskRow['request_type'];
final rc = taskRow['request_category'];
final action = taskRow['action_taken'];
final missing = <String>[];
if (rt == null || (rt is String && rt.trim().isEmpty)) {