Files
tasq/lib/providers/programmer_day_data_provider.dart
redz1029 2946945ce3 fix(programmer-tasks): tab switching + help-given live data
Tab switching: _buildTabs was recreating TabController on every build
because `_tabs == tabs` compares List by reference identity, not content.
programmerTasksTabsFor() always returns a new list, so the guard was
always false, resetting the controller to initialIndex 0 on every tap.
Fixed by using listEquals from flutter/foundation.

Help given: computeProgrammerStats accumulated helpGivenThisMonth only
from approvedSnapshot rows, which only exist after admin approval.
Programmers who helped others but had pending sheets saw 0m indefinitely.
Fixed by adding helpGivenThisMonthProvider (FutureProvider.family) that
queries programmer_task_work_logs directly for the current Manila month,
and passing it as liveHelpThisMonthSec to computeProgrammerStats.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-09-28 22:25:25 +08:00

239 lines
7.6 KiB
Dart

import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../models/programmer_task.model.dart';
import '../models/programmer_task_activity_log.model.dart';
import '../models/programmer_task_work_log.model.dart';
import '../utils/app_time.dart';
import '../utils/programmer_daily_time.dart';
import 'programmer_tasks_provider.dart';
import 'supabase_provider.dart';
enum ProgrammerRunState { running, paused, notStarted, completed, cancelled }
typedef ProgrammerRunInfo = ({ProgrammerRunState state, DateTime? since});
/// Pure function — no providers.
Map<String, ProgrammerRunInfo> computeRunStates(
List<ProgrammerTask> tasks,
Map<String, List<ProgrammerTaskActivityLog>> logsByTask,
) {
return {for (final t in tasks) t.id: _runInfoFor(t, logsByTask[t.id] ?? [])};
}
ProgrammerRunInfo _runInfoFor(
ProgrammerTask task,
List<ProgrammerTaskActivityLog> logs,
) {
switch (task.status) {
case ProgrammerTaskStatus.queued:
return (state: ProgrammerRunState.notStarted, since: null);
case ProgrammerTaskStatus.completed:
return (state: ProgrammerRunState.completed, since: null);
case ProgrammerTaskStatus.cancelled:
return (state: ProgrammerRunState.cancelled, since: null);
default:
return _inProgressInfo(task, logs);
}
}
ProgrammerRunInfo _inProgressInfo(
ProgrammerTask task,
List<ProgrammerTaskActivityLog> logs,
) {
const types = ['started', 'paused', 'resumed'];
final relevant = logs.where((l) => types.contains(l.actionType)).toList()
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
if (relevant.isEmpty) {
return (state: ProgrammerRunState.running, since: task.startedAt);
}
final latest = relevant.first;
if (latest.actionType == 'paused') {
return (state: ProgrammerRunState.paused, since: latest.createdAt);
}
return (state: ProgrammerRunState.running, since: latest.createdAt);
}
class DayActivityData {
const DayActivityData({
required this.day,
required this.tasks,
required this.logsByTask,
required this.workLogs,
});
final DateTime day;
final List<ProgrammerTask> tasks;
final Map<String, List<ProgrammerTaskActivityLog>> logsByTask;
final List<ProgrammerTaskWorkLog> workLogs;
}
bool _isTaskActiveOnDay(
ProgrammerTask t,
DateTime dayStart,
DateTime dayEnd,
DateTime now,
) {
if (t.status == ProgrammerTaskStatus.inProgress) return true;
if (t.startedAt == null) return false;
final terminal = t.completedAt ?? t.cancelledAt ?? now;
return t.startedAt!.isBefore(dayEnd) && !terminal.isBefore(dayStart);
}
Future<List<ProgrammerTaskWorkLog>> _fetchWorkLogs(
dynamic client,
DateTime day,
) async {
final raw = await client
.from('programmer_task_work_logs')
.select()
.eq('work_date', formatDayKey(day)) as List;
return raw
.map((r) => ProgrammerTaskWorkLog.fromMap(r as Map<String, dynamic>))
.toList();
}
Future<Map<String, List<ProgrammerTaskActivityLog>>> _fetchActivityLogs(
dynamic client,
List<String> ids,
) async {
final raw = await client
.from('programmer_task_activity_logs')
.select()
.inFilter('task_id', ids)
.order('created_at', ascending: false) as List;
final map = <String, List<ProgrammerTaskActivityLog>>{};
for (final r in raw) {
final log = ProgrammerTaskActivityLog.fromMap(r as Map<String, dynamic>);
map.putIfAbsent(log.taskId, () => []).add(log);
}
return map;
}
Future<DayActivityData> fetchDayActivityData(
dynamic client, {
required DateTime day,
required List<ProgrammerTask> tasks,
required DateTime now,
}) async {
final dayStart = manilaDayStart(day);
final dayEnd = dayStart.add(const Duration(days: 1));
final candidates =
tasks.where((t) => _isTaskActiveOnDay(t, dayStart, dayEnd, now)).toList();
final workLogs = await _fetchWorkLogs(client, day);
final cIds = {for (final t in candidates) t.id};
candidates.addAll(
tasks.where((t) => workLogs.any((l) => l.taskId == t.id) && !cIds.contains(t.id)),
);
if (candidates.isEmpty) {
return DayActivityData(day: day, tasks: [], logsByTask: {}, workLogs: workLogs);
}
final logsByTask =
await _fetchActivityLogs(client, candidates.map((t) => t.id).toList());
return DayActivityData(
day: day,
tasks: candidates,
logsByTask: logsByTask,
workLogs: workLogs,
);
}
/// Emits [AppTime.now()] immediately then every 30 seconds.
final clockTickerProvider = StreamProvider<DateTime>((ref) async* {
yield AppTime.now();
yield* Stream.periodic(const Duration(seconds: 30), (_) => AppTime.now());
});
/// Manila calendar day derived from the clock ticker.
final todayKeyProvider = Provider<DateTime>((ref) {
final tick = ref.watch(clockTickerProvider).valueOrNull;
return manilaDay(tick ?? AppTime.now());
});
/// Increments by 1 for each INSERT on either log table.
final programmerActivitySignalProvider = StreamProvider<int>((ref) {
final client = ref.watch(supabaseClientProvider);
final controller = StreamController<int>();
var count = 0;
void onInsert(PostgresChangePayload _) {
count++;
if (!controller.isClosed) controller.add(count);
}
final channel = client
.channel('programmer_activity_signal')
.onPostgresChanges(
event: PostgresChangeEvent.insert,
schema: 'public',
table: 'programmer_task_work_logs',
callback: onInsert,
)
.onPostgresChanges(
event: PostgresChangeEvent.insert,
schema: 'public',
table: 'programmer_task_activity_logs',
callback: onInsert,
);
channel.subscribe();
ref.onDispose(() {
client.removeChannel(channel);
controller.close();
});
return controller.stream;
});
/// Fetches activity data for a given day key. Invalidated by the activity signal.
final programmerDayDataProvider =
FutureProvider.family<DayActivityData, DateTime>((ref, day) async {
ref.watch(programmerActivitySignalProvider);
final tasks = ref.watch(programmerTasksProvider).valueOrNull ?? [];
final client = ref.watch(supabaseClientProvider);
return fetchDayActivityData(client, day: day, tasks: tasks, now: AppTime.now());
});
/// Total help-given seconds for [userId] in the current Manila calendar month.
///
/// Queries work logs with minutes > 0 authored by [userId] this month.
/// Re-fetches whenever [programmerActivitySignalProvider] fires.
final helpGivenThisMonthProvider =
FutureProvider.family<int, String>((ref, userId) async {
ref.watch(programmerActivitySignalProvider);
final client = ref.watch(supabaseClientProvider);
final today = ref.watch(todayKeyProvider);
final monthStart = DateTime.utc(today.year, today.month, 1);
final nextMonthStart = today.month < 12
? DateTime.utc(today.year, today.month + 1, 1)
: DateTime.utc(today.year + 1, 1, 1);
final raw = await client
.from('programmer_task_work_logs')
.select('minutes')
.eq('author_id', userId)
.gte('work_date', formatDayKey(monthStart))
.lt('work_date', formatDayKey(nextMonthStart)) as List;
int totalSec = 0;
for (final r in raw) {
final minutes = r['minutes'] as int?;
if (minutes != null && minutes > 0) totalSec += minutes * 60;
}
return totalSec;
});
/// Run states for today's tasks, derived from today's [programmerDayDataProvider].
final programmerRunStatesProvider = Provider<Map<String, ProgrammerRunInfo>>((ref) {
final today = ref.watch(todayKeyProvider);
final data = ref.watch(programmerDayDataProvider(today)).valueOrNull;
if (data == null) return {};
return computeRunStates(data.tasks, data.logsByTask);
});