feat(programmer): day activity data + run-state provider
Implements DayActivityData, fetchDayActivityData, computeRunStates, clockTickerProvider, todayKeyProvider, programmerActivitySignalProvider, programmerDayDataProvider, and programmerRunStatesProvider in lib/providers/programmer_day_data_provider.dart. Includes four passing tests in test/programmer_day_data_test.dart covering run-state computation and day-scoped activity fetching. Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
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());
|
||||
});
|
||||
|
||||
/// 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);
|
||||
});
|
||||
Reference in New Issue
Block a user