Add PM extension (Projects, Work Logs, Comments) + Quill rich-text UI
Projects: Brick-backed model with list/detail screens and nav entry. Work Logs: per-contributor time ledger, assignee running-clock + helper logged minutes with Hours/Minutes input; pause-after-save and helper-deduct prompts. Comments: threaded comments with file attachments and Quill composer. Detail screen: tabbed layout (Work Log / Comments / Activity), editable title + description via edit dialog, Quill-rendered descriptions with legacy plain-text fallback. Create dialog: fixed-width (480px max), Quill description editor. Shared QuillFieldEditor + QuillReadOnly widgets extracted for reuse. RLS: hardened activity-log actor binding, immutable creator_id/ created_at triggers, SECURITY INVOKER visibility helper, append-only work-log and scoped comment policies. Brick migration for project_id FK on programmer_tasks. Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
import '../models/programmer_task_work_log.model.dart';
|
||||
|
||||
/// Formats a worked duration compactly, e.g. `2h 15m` or `45m`.
|
||||
String formatDurationHm(Duration d) {
|
||||
final h = d.inHours;
|
||||
final m = d.inMinutes.remainder(60);
|
||||
if (h > 0) return '${h}h ${m}m';
|
||||
return '${m}m';
|
||||
}
|
||||
|
||||
/// One contributor's share of a task's time.
|
||||
class LedgerEntry {
|
||||
final String userId;
|
||||
final Duration duration;
|
||||
final bool isAssignee;
|
||||
|
||||
const LedgerEntry({
|
||||
required this.userId,
|
||||
required this.duration,
|
||||
required this.isAssignee,
|
||||
});
|
||||
}
|
||||
|
||||
/// Per-contributor time breakdown for a task: the assignee's effective running
|
||||
/// duration plus each helper's summed logged minutes.
|
||||
class TaskTimeLedger {
|
||||
final LedgerEntry? assignee;
|
||||
final List<LedgerEntry> helpers;
|
||||
|
||||
const TaskTimeLedger({required this.assignee, required this.helpers});
|
||||
|
||||
Duration get total =>
|
||||
(assignee?.duration ?? Duration.zero) +
|
||||
helpers.fold(Duration.zero, (a, e) => a + e.duration);
|
||||
|
||||
List<LedgerEntry> get all => [?assignee, ...helpers];
|
||||
}
|
||||
|
||||
/// Builds the per-contributor ledger for a task.
|
||||
///
|
||||
/// [assigneeDuration] is the assignee's effective running duration (already
|
||||
/// computed from the activity log, including any adjustments). Helper entries
|
||||
/// sum the `minutes` of work logs authored by someone other than the assignee;
|
||||
/// assignee work logs (minutes == null progress notes) are excluded because the
|
||||
/// assignee's time comes from the running clock.
|
||||
TaskTimeLedger computeTaskLedger({
|
||||
required String? assigneeId,
|
||||
required Duration assigneeDuration,
|
||||
required List<ProgrammerTaskWorkLog> workLogs,
|
||||
}) {
|
||||
final byHelper = <String, int>{};
|
||||
for (final log in workLogs) {
|
||||
final minutes = log.minutes;
|
||||
if (minutes == null || minutes <= 0) continue;
|
||||
if (log.authorId == assigneeId) continue;
|
||||
byHelper[log.authorId] = (byHelper[log.authorId] ?? 0) + minutes;
|
||||
}
|
||||
|
||||
final helpers = byHelper.entries
|
||||
.map((e) => LedgerEntry(
|
||||
userId: e.key,
|
||||
duration: Duration(minutes: e.value),
|
||||
isAssignee: false,
|
||||
))
|
||||
.toList()
|
||||
..sort((a, b) => b.duration.compareTo(a.duration));
|
||||
|
||||
return TaskTimeLedger(
|
||||
assignee: assigneeId == null
|
||||
? null
|
||||
: LedgerEntry(
|
||||
userId: assigneeId,
|
||||
duration: assigneeDuration,
|
||||
isAssignee: true,
|
||||
),
|
||||
helpers: helpers,
|
||||
);
|
||||
}
|
||||
@@ -29,14 +29,17 @@ DateTime? resolveExecutionStart(
|
||||
}
|
||||
|
||||
/// Computes the effective worked duration, subtracting all paused intervals
|
||||
/// between the resolved start and [endAt].
|
||||
/// between the resolved start and [endAt], then subtracting [adjustment]
|
||||
/// (e.g. time reassigned to another task when the user helped elsewhere).
|
||||
///
|
||||
/// [events] must be in DESCENDING order (newest first) — the same order
|
||||
/// returned by activity-log queries with `ascending: false`.
|
||||
/// returned by activity-log queries with `ascending: false`. The result never
|
||||
/// goes below zero.
|
||||
Duration computeEffectiveDuration({
|
||||
required DateTime? fallbackStartedAt,
|
||||
required DateTime endAt,
|
||||
required List<DurationEvent> events,
|
||||
Duration adjustment = Duration.zero,
|
||||
}) {
|
||||
final start = resolveExecutionStart(events, fallbackStartedAt);
|
||||
if (start == null || !endAt.isAfter(start)) return Duration.zero;
|
||||
@@ -67,7 +70,7 @@ Duration computeEffectiveDuration({
|
||||
pausedTotal += endAt.difference(pausedSince);
|
||||
}
|
||||
|
||||
final total = endAt.difference(start) - pausedTotal;
|
||||
final total = endAt.difference(start) - pausedTotal - adjustment;
|
||||
return total.isNegative ? Duration.zero : total;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user