opsdash-app/opsdash/lib/Service/ReportSummaryService.php
blade34242 5cb0d79f4a Redesign email hero, add chart blocks, fix days_off, add checkpoint/recap test buttons
- Email hero: formatted period title, RECAP/CHECKPOINT badge, 2x2 stat grid,
  inline meta tags, greeting separated from title, no addHeading()
- Checkpoint vs Recap distinction in subject, badge, and footer
- Replace "Calendar pace" with Balance index in calendar_goals hero stats
- Email chart blocks: Calendar split (pie as horizontal bars) and
  Day-of-week pattern for calendar_goals; adds Category split for
  category_and_calendar_goals; charts data pre-aggregated in
  ReportSummaryService.buildChartData() with per-weekday DOW averages
- Fix: days_off no longer counts future dates in the period as quiet days
- Fix: detectTimeSummaryDisplayMode and detectTargetsDisplayMode now always
  return the strategy-mapped mode when strategy is set, preventing stale
  categories config from overriding a known onboarding strategy
- Fix: resolveTodayGroups filters today groups by active display mode
- Add Checkpoint and Recap test-send buttons to onboarding Preferences;
  test sends now always use offset=-1 (recap) or offset=0 (checkpoint)
2026-05-19 14:01:31 +07:00

603 lines
24 KiB
PHP

<?php
declare(strict_types=1);
namespace OCA\Opsdash\Service;
use OCP\IConfig;
final class ReportSummaryService {
private const MAX_OFFSET = 24;
private const MAX_GROUP = 9;
private const MAX_AGG_PER_CAL = 2000;
private const MAX_AGG_TOTAL = 5000;
public function __construct(
private CalendarAccessService $calendarAccess,
private OverviewEventsCollector $eventsCollector,
private OverviewAggregationService $aggregationService,
private OverviewSelectionService $selectionService,
private OverviewBalanceService $overviewBalanceService,
private UserConfigService $userConfigService,
private PersistSanitizer $persistSanitizer,
private NotesService $notesService,
private IConfig $config,
) {
}
/**
* @param string[]|null $requestedCals
* @param array<string,mixed>|null $groupsOverride
* @param array<string,mixed>|null $targetsConfigOverride
* @return array<string,mixed>
*/
public function build(
string $appName,
string $uid,
string $range = 'week',
int $offset = 0,
?array $requestedCals = null,
?array $groupsOverride = null,
?array $targetsConfigOverride = null,
): array {
$range = strtolower($range) === 'month' ? 'month' : 'week';
$offset = max(-self::MAX_OFFSET, min(self::MAX_OFFSET, $offset));
$provided = is_array($requestedCals);
$requested = $provided
? array_values(array_filter(array_map(static fn ($x) => trim((string)$x), $requestedCals), static fn ($x) => $x !== ''))
: null;
$savedRaw = (string)$this->config->getUserValue($uid, $appName, 'selected_cals', '__UNSET__');
$selection = $this->selectionService->resolveInitialSelection($savedRaw, $provided, $requested);
$userTz = $this->calendarAccess->resolveUserTimezone($uid);
$weekStart = $this->calendarAccess->resolveUserWeekStart($uid);
[$from, $to] = $this->calendarAccess->rangeBounds($range, $offset, $userTz, $weekStart);
$cals = $this->calendarAccess->getCalendarsFor($uid);
$availableIds = [];
$calendarLabels = [];
foreach ($cals as $cal) {
$id = '';
try {
$id = (string)($cal->getUri() ?? '');
} catch (\Throwable) {
$id = '';
}
if ($id === '') {
$id = (string)spl_object_id($cal);
}
$availableIds[] = $id;
try {
$calendarLabels[$id] = (string)($cal->getDisplayName() ?? $id);
} catch (\Throwable) {
$calendarLabels[$id] = $id;
}
}
$allowedSet = [];
foreach ($availableIds as $id) {
$allowedSet[$id] = 1;
}
$selectedIds = $this->selectionService->finalizeSelectedIds(
$selection['includeAll'],
$availableIds,
$selection['selectedIds'],
);
$selectedIds = array_values(array_filter($selectedIds, static fn ($id) => isset($allowedSet[$id])));
$groupsById = [];
if (is_array($groupsOverride)) {
$groupsById = $groupsOverride;
} else {
try {
$groupsRaw = (string)$this->config->getUserValue($uid, $appName, 'cal_groups', '');
if ($groupsRaw !== '') {
$tmp = json_decode($groupsRaw, true);
if (is_array($tmp)) {
$groupsById = $tmp;
}
}
} catch (\Throwable) {
$groupsById = [];
}
}
$groupsById = $this->persistSanitizer->cleanGroups($groupsById, $allowedSet, $availableIds);
$targetsConfig = is_array($targetsConfigOverride)
? $this->persistSanitizer->cleanTargetsConfig($targetsConfigOverride)
: $this->userConfigService->readTargetsConfig($appName, $uid);
$onboardingState = $this->userConfigService->readOnboardingState($appName, $uid);
$categoryMeta = [];
$groupToCategory = [];
if (!empty($targetsConfig['categories']) && is_array($targetsConfig['categories'])) {
foreach ($targetsConfig['categories'] as $cat) {
if (!is_array($cat)) {
continue;
}
$catId = substr((string)($cat['id'] ?? ''), 0, 64);
if ($catId === '') {
continue;
}
$label = trim((string)($cat['label'] ?? '')) ?: ucfirst($catId);
$categoryMeta[$catId] = ['id' => $catId, 'label' => $label];
if (!empty($cat['groupIds']) && is_array($cat['groupIds'])) {
foreach ($cat['groupIds'] as $gid) {
$n = (int)$gid;
if ($n < 0 || $n > self::MAX_GROUP) {
continue;
}
$groupToCategory[$n] = $catId;
}
}
}
}
$categoryMeta['__uncategorized__'] = ['id' => '__uncategorized__', 'label' => 'Unassigned'];
$mapCalToCategory = function (string $calId) use ($groupsById, $groupToCategory): string {
$group = isset($groupsById[$calId]) ? (int)$groupsById[$calId] : 0;
return $groupToCategory[$group] ?? '__uncategorized__';
};
$collect = $this->eventsCollector->collect(
principal: 'principals/users/' . $uid,
cals: $cals,
includeAll: $selection['includeAll'],
selectedIds: $selectedIds,
from: $from,
to: $to,
maxPerCal: self::MAX_AGG_PER_CAL,
maxTotal: self::MAX_AGG_TOTAL,
debug: false,
);
$agg = $this->aggregationService->aggregate(
events: $collect['events'],
from: $from,
to: $to,
userTz: $userTz,
allDayHours: (float)($targetsConfig['allDayHours'] ?? 8),
colorsById: [],
categoryMeta: $categoryMeta,
mapCalToCategory: $mapCalToCategory,
);
$categoryTotalsPrev = [];
$prevTotal = 0.0;
try {
[$prevFrom, $prevTo] = $this->calendarAccess->rangeBounds($range, $offset - 1, $userTz, $weekStart);
$prevCollect = $this->eventsCollector->collect(
principal: 'principals/users/' . $uid,
cals: $cals,
includeAll: $selection['includeAll'],
selectedIds: $selectedIds,
from: $prevFrom,
to: $prevTo,
maxPerCal: self::MAX_AGG_PER_CAL,
maxTotal: self::MAX_AGG_TOTAL,
debug: false,
);
$prevAgg = $this->aggregationService->aggregate(
events: $prevCollect['events'],
from: $prevFrom,
to: $prevTo,
userTz: $userTz,
allDayHours: (float)($targetsConfig['allDayHours'] ?? 8),
colorsById: [],
categoryMeta: $categoryMeta,
mapCalToCategory: $mapCalToCategory,
);
$categoryTotalsPrev = $prevAgg['categoryTotals'] ?? [];
$prevTotal = (float)($prevAgg['totalHours'] ?? 0.0);
} catch (\Throwable) {
$categoryTotalsPrev = [];
$prevTotal = 0.0;
}
$targetsWeek = $this->readTargetsMap($uid, $appName, 'cal_targets_week');
$targetsMonth = $this->readTargetsMap($uid, $appName, 'cal_targets_month');
$balanceConfig = is_array($targetsConfig['balance'] ?? null) ? $targetsConfig['balance'] : [];
$balance = $this->overviewBalanceService->build(
range: $range,
targetsConfig: $targetsConfig,
targetsWeek: $targetsWeek,
targetsMonth: $targetsMonth,
byCalMap: $agg['byCalMap'],
idToName: $calendarLabels,
categoryMeta: $categoryMeta,
categoryTotals: $agg['categoryTotals'],
totalHours: (float)$agg['totalHours'],
categoryTotalsPrev: $categoryTotalsPrev,
prevTotal: $prevTotal,
categoryColors: array_map(static fn ($v) => $v ?: '#2563eb', $agg['categoryColors']),
balanceConfig: $balanceConfig,
perDayByCat: $agg['perDayByCat'],
from: $from,
to: $to,
trendHistory: [],
);
$topCategory = null;
foreach ($agg['categoryTotals'] as $catId => $hours) {
if ($topCategory === null || $hours > $topCategory['hours']) {
$label = $categoryMeta[$catId]['label'] ?? $catId;
$topCategory = ['id' => $catId, 'label' => $label, 'hours' => round((float)$hours, 2)];
}
}
$topCalendar = !empty($agg['byCalList'])
? [
'id' => $agg['byCalList'][0]['id'],
'label' => $agg['byCalList'][0]['calendar'],
'hours' => round((float)$agg['byCalList'][0]['total_hours'], 2),
]
: null;
$selectedLabels = array_values(array_map(
static fn (string $id) => $calendarLabels[$id] ?? $id,
$selectedIds,
));
$byDayList = array_values($agg['byDay']);
usort($byDayList, static fn (array $a, array $b) => strcmp((string)$a['date'], (string)$b['date']));
$busiestDay = null;
foreach ($byDayList as $row) {
if ($busiestDay === null || (float)$row['total_hours'] > (float)$busiestDay['total_hours']) {
$busiestDay = $row;
}
}
$today = (new \DateTimeImmutable('now', $userTz))->format('Y-m-d');
$daysOff = 0;
foreach ($byDayList as $row) {
$rowDate = (string)($row['date'] ?? '');
if ($rowDate > $today) {
continue;
}
if ((float)($row['total_hours'] ?? 0.0) <= 0.0001) {
$daysOff++;
}
}
$longestEntry = null;
if (!empty($agg['long']) && is_array($agg['long'][0] ?? null)) {
$longestEntry = $agg['long'][0];
}
$notesPayload = $this->notesService->getNotes($uid, $range, $offset);
$notes = is_array($notesPayload['notes'] ?? null) ? $notesPayload['notes'] : ['current' => '', 'previous' => ''];
$targetSummary = $this->buildTargetSummary(
range: $range,
targetsConfig: $targetsConfig,
targetsWeek: $targetsWeek,
targetsMonth: $targetsMonth,
byCalList: $agg['byCalList'],
groupsById: $groupsById,
from: $from,
to: $to,
);
$reportVariant = $this->resolveReportVariant($onboardingState, $targetsConfig, $targetSummary);
return [
'range' => $range,
'offset' => $offset,
'from' => $from->format('Y-m-d'),
'to' => $to->format('Y-m-d'),
'onboarding_strategy' => (string)($onboardingState['strategy'] ?? ''),
'report_variant' => $reportVariant,
'active_preset' => (string)$this->config->getUserValue($uid, $appName, 'active_preset', ''),
'selected' => $selectedIds,
'selected_labels' => $selectedLabels,
'selected_count' => count($selectedIds),
'total_hours' => round((float)$agg['totalHours'], 2),
'future_hours' => round((float)$agg['futureTotalHours'], 2),
'events' => (int)$agg['eventsCount'],
'active_days' => (int)$agg['daysCount'],
'avg_per_day' => round((float)$agg['avgPerDay'], 2),
'avg_per_event' => round((float)$agg['avgPerEvent'], 2),
'top_calendar' => $topCalendar,
'top_category' => $topCategory,
'busiest_day' => $busiestDay ? [
'date' => (string)$busiestDay['date'],
'hours' => round((float)$busiestDay['total_hours'], 2),
'events' => (int)($busiestDay['events_count'] ?? 0),
] : null,
'days_off' => $daysOff,
'longest_session' => $longestEntry ? [
'calendar' => (string)($longestEntry['calendar'] ?? ''),
'summary' => (string)($longestEntry['summary'] ?? ''),
'hours' => round((float)($longestEntry['duration_h'] ?? 0.0), 2),
'start' => (string)($longestEntry['start'] ?? ''),
] : null,
'balance' => [
'index' => round((float)($balance['balanceIndex'] ?? 0.0), 3),
'warnings' => array_values(array_map('strval', $balance['balanceOverview']['warnings'] ?? [])),
],
'targets' => $targetSummary,
'notes' => [
'current' => trim((string)($notes['current'] ?? '')),
'previous' => trim((string)($notes['previous'] ?? '')),
],
'generated_at' => (new \DateTimeImmutable('now', $userTz))->format(\DateTimeInterface::ATOM),
'charts' => $this->buildChartData($agg, $categoryMeta, $from, $to),
];
}
/**
* @return array<string,float>
*/
private function readTargetsMap(string $uid, string $appName, string $key): array {
try {
$raw = (string)$this->config->getUserValue($uid, $appName, $key, '');
if ($raw === '') {
return [];
}
$decoded = json_decode($raw, true);
if (!is_array($decoded)) {
return [];
}
$clean = [];
foreach ($decoded as $id => $value) {
$hours = (float)$value;
if ($hours > 0) {
$clean[(string)$id] = $hours;
}
}
return $clean;
} catch (\Throwable) {
return [];
}
}
/**
* @param array<string,mixed> $targetsConfig
* @param array<string,float> $targetsWeek
* @param array<string,float> $targetsMonth
* @param array<int,array<string,mixed>> $byCalList
* @param array<string,mixed> $groupsById
* @return array<string,mixed>
*/
private function buildTargetSummary(
string $range,
array $targetsConfig,
array $targetsWeek,
array $targetsMonth,
array $byCalList,
array $groupsById,
\DateTimeImmutable $from,
\DateTimeImmutable $to,
): array {
$targetMap = $range === 'month' ? $targetsMonth : $targetsWeek;
$totalTarget = max(0.0, (float)($targetsConfig['totalHours'] ?? 0.0));
$totalActual = 0.0;
foreach ($byCalList as $row) {
$totalActual += max(0.0, (float)($row['total_hours'] ?? 0.0));
}
$totalPercent = $totalTarget > 0 ? ($totalActual / $totalTarget) * 100.0 : 0.0;
$calendarPercent = $this->computeCalendarPercent($from, $to);
$gap = $totalPercent - $calendarPercent;
$thresholds = is_array($targetsConfig['pace']['thresholds'] ?? null)
? $targetsConfig['pace']['thresholds']
: ['onTrack' => -2, 'atRisk' => -10];
$totalStatus = $this->resolveTargetStatus($totalTarget, $totalPercent, $gap, $thresholds);
$rows = [];
$calendarRows = [];
$byCalMap = [];
foreach ($byCalList as $row) {
$calId = (string)($row['id'] ?? '');
if ($calId !== '') {
$byCalMap[$calId] = $row;
}
}
foreach ((array)($targetsConfig['categories'] ?? []) as $cat) {
if (!is_array($cat)) {
continue;
}
$catId = (string)($cat['id'] ?? '');
if ($catId === '') {
continue;
}
$groupIds = array_map('intval', is_array($cat['groupIds'] ?? null) ? $cat['groupIds'] : []);
$actual = 0.0;
foreach ($byCalList as $row) {
$calId = (string)($row['id'] ?? '');
$groupId = (int)($groupsById[$calId] ?? 0);
if (!in_array($groupId, $groupIds, true)) {
continue;
}
$actual += max(0.0, (float)($row['total_hours'] ?? 0.0));
}
$target = max(0.0, (float)($cat['targetHours'] ?? 0.0));
$percent = $target > 0 ? ($actual / $target) * 100.0 : 0.0;
$status = $this->resolveTargetStatus($target, $percent, $percent - $calendarPercent, $thresholds);
if ($target <= 0 && $actual <= 0) {
continue;
}
$rows[] = [
'label' => trim((string)($cat['label'] ?? $catId)),
'actual' => round($actual, 2),
'target' => round($target, 2),
'remaining' => round(max(0.0, $target - $actual), 2),
'percent' => round($percent, 1),
'status' => $status,
];
}
foreach ($targetMap as $calId => $target) {
$actual = max(0.0, (float)($byCalMap[$calId]['total_hours'] ?? 0.0));
$target = max(0.0, (float)$target);
if ($target <= 0 && $actual <= 0) {
continue;
}
$percent = $target > 0 ? ($actual / $target) * 100.0 : 0.0;
$status = $this->resolveTargetStatus($target, $percent, $percent - $calendarPercent, $thresholds);
$calendarRows[] = [
'id' => $calId,
'label' => trim((string)($byCalMap[$calId]['calendar'] ?? $calId)),
'actual' => round($actual, 2),
'target' => round($target, 2),
'remaining' => round(max(0.0, $target - $actual), 2),
'percent' => round($percent, 1),
'status' => $status,
];
}
usort($rows, static function (array $a, array $b): int {
$statusOrder = ['behind' => 0, 'at_risk' => 1, 'on_track' => 2, 'done' => 3, 'none' => 4];
$aStatus = $statusOrder[$a['status']] ?? 99;
$bStatus = $statusOrder[$b['status']] ?? 99;
if ($aStatus !== $bStatus) {
return $aStatus <=> $bStatus;
}
return strcmp((string)$a['label'], (string)$b['label']);
});
usort($calendarRows, static function (array $a, array $b): int {
$statusOrder = ['behind' => 0, 'at_risk' => 1, 'on_track' => 2, 'done' => 3, 'none' => 4];
$aStatus = $statusOrder[$a['status']] ?? 99;
$bStatus = $statusOrder[$b['status']] ?? 99;
if ($aStatus !== $bStatus) {
return $aStatus <=> $bStatus;
}
return strcmp((string)$a['label'], (string)$b['label']);
});
return [
'total' => [
'actual' => round($totalActual, 2),
'target' => round($totalTarget, 2),
'remaining' => round(max(0.0, $totalTarget - $totalActual), 2),
'percent' => round($totalPercent, 1),
'calendarPercent' => round($calendarPercent, 1),
'status' => $totalStatus,
],
'calendars' => array_slice($calendarRows, 0, 5),
'categories' => array_slice($rows, 0, 5),
];
}
/**
* @param array<string,mixed> $onboardingState
* @param array<string,mixed> $targetsConfig
* @param array<string,mixed> $targetSummary
*/
private function resolveReportVariant(array $onboardingState, array $targetsConfig, array $targetSummary): string {
$strategy = (string)($onboardingState['strategy'] ?? '');
if ($strategy === 'total_only') {
return 'single_goal';
}
if ($strategy === 'total_plus_categories') {
return 'calendar_goals';
}
if ($strategy === 'full_granular') {
return 'category_and_calendar_goals';
}
$categories = is_array($targetsConfig['categories'] ?? null) ? $targetsConfig['categories'] : [];
if ($categories !== []) {
return 'category_and_calendar_goals';
}
$calendarRows = is_array($targetSummary['calendars'] ?? null) ? $targetSummary['calendars'] : [];
if ($calendarRows !== []) {
return 'calendar_goals';
}
return 'single_goal';
}
/**
* @param array<string,mixed> $thresholds
*/
private function resolveTargetStatus(float $target, float $percent, float $gap, array $thresholds): string {
if ($target <= 0) {
return 'none';
}
if ($percent >= 100.0) {
return 'done';
}
$onTrack = (float)($thresholds['onTrack'] ?? -2);
$atRisk = (float)($thresholds['atRisk'] ?? -10);
if ($gap >= $onTrack) {
return 'on_track';
}
if ($gap >= $atRisk) {
return 'at_risk';
}
return 'behind';
}
/**
* @param array<string,mixed> $agg
* @param array<string,mixed> $categoryMeta
* @return array<string,mixed>
*/
private function buildChartData(array $agg, array $categoryMeta, \DateTimeImmutable $from, \DateTimeImmutable $to): array {
// DOW averages: total per weekday divided by number of that weekday in the period
$dowOrder = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
$dowCounts = array_fill_keys($dowOrder, 0);
$cursor = $from;
while ($cursor <= $to) {
$d = $cursor->format('D');
if (isset($dowCounts[$d])) {
$dowCounts[$d]++;
}
$cursor = $cursor->modify('+1 day');
}
$dowTotals = is_array($agg['dowTotals'] ?? null) ? $agg['dowTotals'] : [];
$dowAvg = [];
foreach ($dowOrder as $d) {
$total = (float)($dowTotals[$d] ?? 0.0);
$count = max(1, $dowCounts[$d]);
$dowAvg[$d] = round($total / $count, 2);
}
// Calendar pie: top 7 by hours
$byCalList = is_array($agg['byCalList'] ?? null) ? $agg['byCalList'] : [];
$calPie = [];
foreach (array_slice($byCalList, 0, 7) as $row) {
$hours = round((float)($row['total_hours'] ?? 0.0), 2);
if ($hours <= 0) continue;
$calPie[] = ['label' => (string)($row['calendar'] ?? ''), 'hours' => $hours];
}
// Category pie: from categoryTotals + colors + labels
$categoryTotals = is_array($agg['categoryTotals'] ?? null) ? $agg['categoryTotals'] : [];
$categoryColors = is_array($agg['categoryColors'] ?? null) ? $agg['categoryColors'] : [];
$catPie = [];
foreach ($categoryTotals as $catId => $hours) {
$hours = round((float)$hours, 2);
if ($hours <= 0) continue;
$label = (string)(($categoryMeta[$catId]['label'] ?? null) ?: $catId);
$color = (string)($categoryColors[$catId] ?? '');
$catPie[] = ['label' => $label, 'hours' => $hours, 'color' => $color !== '' ? $color : null];
}
usort($catPie, static fn ($a, $b) => $b['hours'] <=> $a['hours']);
return [
'dow_avg' => $dowAvg,
'dow_order' => $dowOrder,
'cal_pie' => $calPie,
'cat_pie' => $catPie,
];
}
private function computeCalendarPercent(\DateTimeImmutable $from, \DateTimeImmutable $to): float {
$start = $from->setTime(0, 0, 0);
$end = $to->setTime(0, 0, 0);
$today = (new \DateTimeImmutable('today', $from->getTimezone()))->setTime(0, 0, 0);
if ($today < $start) {
return 0.0;
}
if ($today > $end) {
return 100.0;
}
$totalDays = max(1, (int)$start->diff($end)->format('%a') + 1);
$elapsedDays = (int)$start->diff($today)->format('%a') + 1;
return min(100.0, max(0.0, ($elapsedDays / $totalDays) * 100.0));
}
}