opsdash-app/opsdash/lib/Service/ReportRenderService.php
blade34242 b9be99c40e
Some checks failed
Nextcloud Server Tests / version-consistency (push) Successful in 33s
Nextcloud Server Tests / matrix-config (push) Successful in 34s
Nextcloud Server Tests / Nextcloud stable30 / PHP 8.2 (stable30, 8.2) (push) Successful in 18m36s
Nextcloud Server Tests / Nextcloud stable31 / PHP 8.2 (stable31, 8.2) (push) Successful in 17m16s
Nextcloud Server Tests / Nextcloud stable31 / PHP 8.3 (stable31, 8.3) (push) Successful in 18m1s
Nextcloud Server Tests / Nextcloud stable32 / PHP 8.2 (stable32, 8.2) (push) Successful in 17m18s
Nextcloud Server Tests / Nextcloud stable32 / PHP 8.3 (stable32, 8.3) (push) Successful in 17m11s
Nextcloud Server Tests / Nextcloud stable33 / PHP 8.2 (stable33, 8.2) (push) Failing after 29m43s
Nextcloud Server Tests / Nextcloud stable33 / PHP 8.3 (stable33, 8.3) (push) Successful in 17m24s
Fix recap selected calendar escaping
2026-05-19 14:39:51 +07:00

1072 lines
52 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
declare(strict_types=1);
namespace OCA\Opsdash\Service;
use OCP\IURLGenerator;
use OCP\Mail\IMailer;
final class ReportRenderService {
public function __construct(
private IMailer $mailer,
private IURLGenerator $urlGenerator,
) {
}
/**
* @param array<string,mixed> $summary
* @param array<string,mixed> $reportingConfig
* @return array{subject:string,plain:string,html:string}
*/
public function render(array $summary, array $reportingConfig, string $displayName): array {
$rangeLabel = ($summary['range'] ?? 'week') === 'month' ? 'Monthly' : 'Weekly';
$periodLabel = sprintf('%s to %s', (string)($summary['from'] ?? ''), (string)($summary['to'] ?? ''));
$reportVariant = $this->resolveReportVariant($summary);
$reportVariantLabel = $this->reportVariantLabel($reportVariant);
$activePreset = trim((string)($summary['active_preset'] ?? ''));
$isCheckpoint = ((int)($summary['offset'] ?? -1)) === 0;
$mailTypeLabel = $isCheckpoint ? 'Checkpoint' : 'Recap';
$subject = sprintf('Opsdash %s · %s · %s', strtolower($mailTypeLabel), $rangeLabel, $periodLabel);
$selectedLabels = array_values(array_map('strval', $summary['selected_labels'] ?? []));
$selectedLine = empty($selectedLabels)
? 'None'
: implode(', ', $selectedLabels);
$topCalendar = is_array($summary['top_calendar'] ?? null) ? $summary['top_calendar'] : null;
$topCategory = is_array($summary['top_category'] ?? null) ? $summary['top_category'] : null;
$targets = is_array($summary['targets'] ?? null) ? $summary['targets'] : [];
$targetTotal = is_array($targets['total'] ?? null) ? $targets['total'] : [];
$calendarRows = is_array($targets['calendars'] ?? null) ? $targets['calendars'] : [];
$categoryRows = is_array($targets['categories'] ?? null) ? $targets['categories'] : [];
$balance = is_array($summary['balance'] ?? null) ? $summary['balance'] : [];
$balanceWarnings = array_values(array_map('strval', $balance['warnings'] ?? []));
$notes = is_array($summary['notes'] ?? null) ? $summary['notes'] : [];
$busiestDay = is_array($summary['busiest_day'] ?? null) ? $summary['busiest_day'] : null;
$longestSession = is_array($summary['longest_session'] ?? null) ? $summary['longest_session'] : null;
$template = $this->mailer->createEMailTemplate('opsdash.report.test');
$template->setSubject($subject);
$template->addHeader();
$template->addBodyText(
$this->renderHeroHtml($summary, $reportVariant, $displayName, $rangeLabel, $periodLabel, $selectedLine, $reportVariantLabel, $activePreset, $mailTypeLabel),
$this->renderHeroPlain($displayName, $rangeLabel, $periodLabel, $selectedLine, $reportVariantLabel, $activePreset, $mailTypeLabel),
);
switch ($reportVariant) {
case 'calendar_goals':
$template->addBodyText(
$this->renderKpiGridHtml($this->calendarGoalKpis($summary, $topCalendar)),
$this->renderKpiGridPlain('KPI snapshot', $this->calendarGoalKpis($summary, $topCalendar)),
);
$template->addBodyText(
$this->renderTargetBoardHtml('Calendar targets', 'Per-calendar progress for the selected recap period.', $targetTotal, $calendarRows, 'Calendar'),
$this->renderTargetBoardPlain('Calendar targets', $targetTotal, $calendarRows),
);
$charts = is_array($summary['charts'] ?? null) ? $summary['charts'] : [];
$calPie = is_array($charts['cal_pie'] ?? null) ? $charts['cal_pie'] : [];
$dowAvg = is_array($charts['dow_avg'] ?? null) ? $charts['dow_avg'] : [];
$dowOrder = is_array($charts['dow_order'] ?? null) ? $charts['dow_order'] : ['Mon','Tue','Wed','Thu','Fri','Sat','Sun'];
$totalHours = (float)($summary['total_hours'] ?? 0.0);
$calPieHtml = $this->renderPieChartHtml('Calendar split', 'Share of tracked hours by calendar', $calPie, $totalHours);
if ($calPieHtml !== '') {
$template->addBodyText($calPieHtml, $this->renderPieChartPlain('Calendar split', $calPie, $totalHours));
}
$dowHtml = $this->renderDowChartHtml($dowAvg, $dowOrder);
if ($dowHtml !== '') {
$template->addBodyText($dowHtml, $this->renderDowChartPlain($dowAvg, $dowOrder));
}
$template->addBodyText(
$this->renderActivityHtml($summary, $busiestDay, $longestSession),
$this->renderActivityPlain($summary, $busiestDay, $longestSession),
);
break;
case 'category_and_calendar_goals':
$template->addBodyText(
$this->renderKpiGridHtml($this->categoryGoalKpis($summary, $topCalendar, $topCategory)),
$this->renderKpiGridPlain('KPI snapshot', $this->categoryGoalKpis($summary, $topCalendar, $topCategory)),
);
$template->addBodyText(
$this->renderTargetBoardHtml('Targets &amp; pace', 'Category targets and pacing signals for this recap window.', $targetTotal, $categoryRows, 'Category'),
$this->renderTargetBoardPlain('Targets & pace', $targetTotal, $categoryRows),
);
$template->addBodyText(
$this->renderBalanceHtml($balance, $balanceWarnings),
$this->renderBalancePlain($balance, $balanceWarnings),
);
$charts = is_array($summary['charts'] ?? null) ? $summary['charts'] : [];
$catPie = is_array($charts['cat_pie'] ?? null) ? $charts['cat_pie'] : [];
$calPie = is_array($charts['cal_pie'] ?? null) ? $charts['cal_pie'] : [];
$dowAvg = is_array($charts['dow_avg'] ?? null) ? $charts['dow_avg'] : [];
$dowOrder = is_array($charts['dow_order'] ?? null) ? $charts['dow_order'] : ['Mon','Tue','Wed','Thu','Fri','Sat','Sun'];
$totalHours = (float)($summary['total_hours'] ?? 0.0);
$catPieHtml = $this->renderPieChartHtml('Category split', 'Share of tracked hours by category', $catPie, $totalHours);
$calPieHtml = $this->renderPieChartHtml('Calendar split', 'Share of tracked hours by calendar', $calPie, $totalHours);
if ($catPieHtml !== '' || $calPieHtml !== '') {
$template->addBodyText($catPieHtml . $calPieHtml, $this->renderPieChartPlain('Category split', $catPie, $totalHours));
}
$dowHtml = $this->renderDowChartHtml($dowAvg, $dowOrder);
if ($dowHtml !== '') {
$template->addBodyText($dowHtml, $this->renderDowChartPlain($dowAvg, $dowOrder));
}
$template->addBodyText(
$this->renderActivityHtml($summary, $busiestDay, $longestSession),
$this->renderActivityPlain($summary, $busiestDay, $longestSession),
);
break;
case 'single_goal':
default:
$template->addBodyText(
$this->renderKpiGridHtml($this->singleGoalKpis($summary, $topCalendar)),
$this->renderKpiGridPlain('KPI snapshot', $this->singleGoalKpis($summary, $topCalendar)),
);
$template->addBodyText(
$this->renderProgressHtml($targetTotal),
$this->renderProgressPlain($targetTotal),
);
$template->addBodyText(
$this->renderActivityHtml($summary, $busiestDay, $longestSession),
$this->renderActivityPlain($summary, $busiestDay, $longestSession),
);
break;
}
$template->addBodyText(
$this->renderNotesHtml($notes, $reportingConfig),
$this->renderNotesPlain($notes, $reportingConfig),
);
$template->addBodyButton(
'Open Opsdash overview',
$this->urlGenerator->linkToRouteAbsolute('opsdash.overview.index'),
);
$footerType = $isCheckpoint ? 'checkpoints' : 'recaps';
$template->addFooter(sprintf('You\'re receiving this because you have automatic %s enabled in Opsdash.', $footerType));
return [
'subject' => $template->renderSubject(),
'plain' => $template->renderText(),
'html' => $template->renderHtml(),
];
}
private function resolveReportVariant(array $summary): string {
$variant = (string)($summary['report_variant'] ?? '');
return match ($variant) {
'calendar_goals', 'category_and_calendar_goals' => $variant,
default => 'single_goal',
};
}
private function reportVariantLabel(string $variant): string {
return match ($variant) {
'calendar_goals' => 'Calendar Goals',
'category_and_calendar_goals' => 'Calendar + Category Goals',
default => 'Single Goal',
};
}
/**
* @return array<int,array{label:string,value:string,detail:string}>
*/
private function singleGoalKpis(array $summary, ?array $topCalendar): array {
$cards = [
['label' => 'Total hours', 'value' => $this->formatHours((float)($summary['total_hours'] ?? 0.0)), 'detail' => 'Tracked in this period'],
['label' => 'Target progress', 'value' => $this->formatPercent((float)($summary['targets']['total']['percent'] ?? 0.0)) . '%', 'detail' => $this->statusLabel((string)($summary['targets']['total']['status'] ?? 'none'))],
['label' => 'Remaining', 'value' => $this->formatHours((float)($summary['targets']['total']['remaining'] ?? 0.0)), 'detail' => 'Still needed to hit the total goal'],
['label' => 'Active days', 'value' => (string)(int)($summary['active_days'] ?? 0), 'detail' => 'Days with tracked activity'],
['label' => 'Events', 'value' => (string)(int)($summary['events'] ?? 0), 'detail' => 'Captured events'],
['label' => 'Avg / day', 'value' => $this->formatHours((float)($summary['avg_per_day'] ?? 0.0)), 'detail' => 'Across active days'],
];
if ($topCalendar) {
$cards[] = ['label' => 'Top calendar', 'value' => (string)($topCalendar['label'] ?? ''), 'detail' => $this->formatHours((float)($topCalendar['hours'] ?? 0.0))];
}
return $cards;
}
/**
* @return array<int,array{label:string,value:string,detail:string}>
*/
private function calendarGoalKpis(array $summary, ?array $topCalendar): array {
$cards = [
['label' => 'Total hours', 'value' => $this->formatHours((float)($summary['total_hours'] ?? 0.0)), 'detail' => 'Tracked across selected calendars'],
['label' => 'Calendar pace', 'value' => $this->formatPercent((float)($summary['targets']['total']['percent'] ?? 0.0)) . '%', 'detail' => $this->statusLabel((string)($summary['targets']['total']['status'] ?? 'none'))],
['label' => 'Future planned', 'value' => $this->formatHours((float)($summary['future_hours'] ?? 0.0)), 'detail' => 'Still scheduled ahead'],
['label' => 'Active days', 'value' => (string)(int)($summary['active_days'] ?? 0), 'detail' => 'Days with tracked activity'],
['label' => 'Avg / event', 'value' => $this->formatHours((float)($summary['avg_per_event'] ?? 0.0)), 'detail' => 'Typical event size'],
['label' => 'Selected calendars', 'value' => (string)(int)($summary['selected_count'] ?? 0), 'detail' => 'Included in this recap'],
];
if ($topCalendar) {
$cards[] = ['label' => 'Top calendar', 'value' => (string)($topCalendar['label'] ?? ''), 'detail' => $this->formatHours((float)($topCalendar['hours'] ?? 0.0))];
}
return $cards;
}
/**
* @return array<int,array{label:string,value:string,detail:string}>
*/
private function categoryGoalKpis(array $summary, ?array $topCalendar, ?array $topCategory): array {
$cards = [
['label' => 'Total hours', 'value' => $this->formatHours((float)($summary['total_hours'] ?? 0.0)), 'detail' => 'Tracked in this period'],
['label' => 'Target progress', 'value' => $this->formatPercent((float)($summary['targets']['total']['percent'] ?? 0.0)) . '%', 'detail' => $this->statusLabel((string)($summary['targets']['total']['status'] ?? 'none'))],
['label' => 'Balance index', 'value' => $this->formatIndex((float)($summary['balance']['index'] ?? 0.0)), 'detail' => 'Time mix health'],
['label' => 'Active days', 'value' => (string)(int)($summary['active_days'] ?? 0), 'detail' => 'Days with tracked activity'],
['label' => 'Future planned', 'value' => $this->formatHours((float)($summary['future_hours'] ?? 0.0)), 'detail' => 'Still scheduled ahead'],
['label' => 'Events', 'value' => (string)(int)($summary['events'] ?? 0), 'detail' => 'Captured events'],
];
if ($topCalendar) {
$cards[] = ['label' => 'Top calendar', 'value' => (string)($topCalendar['label'] ?? ''), 'detail' => $this->formatHours((float)($topCalendar['hours'] ?? 0.0))];
}
if ($topCategory) {
$cards[] = ['label' => 'Top category', 'value' => (string)($topCategory['label'] ?? ''), 'detail' => $this->formatHours((float)($topCategory['hours'] ?? 0.0))];
}
return $cards;
}
private function renderHeroHtml(
array $summary,
string $reportVariant,
string $displayName,
string $rangeLabel,
string $periodLabel,
string $selectedLine,
string $reportVariantLabel,
string $activePreset = '',
string $mailTypeLabel = 'Recap',
): string {
$isMonthly = strtolower($rangeLabel) === 'monthly';
$periodTitle = $this->formatPeriodTitle((string)($summary['from'] ?? ''), (string)($summary['to'] ?? ''), $isMonthly);
switch ($reportVariant) {
case 'calendar_goals':
$stats = [
['Total hours', $this->formatHours((float)($summary['total_hours'] ?? 0.0)), '#22d3ee'],
['Balance index', $this->formatIndex((float)($summary['balance']['index'] ?? 0.0)), '#fcd34d'],
['Active days', (string)(int)($summary['active_days'] ?? 0), '#ffffff'],
['Future planned', $this->formatHours((float)($summary['future_hours'] ?? 0.0)), '#c4b5fd'],
];
break;
case 'category_and_calendar_goals':
$stats = [
['Total hours', $this->formatHours((float)($summary['total_hours'] ?? 0.0)), '#22d3ee'],
['Target', $this->formatPercent((float)($summary['targets']['total']['percent'] ?? 0.0)) . '%', '#c4b5fd'],
['Active days', (string)(int)($summary['active_days'] ?? 0), '#ffffff'],
['Balance index', $this->formatIndex((float)($summary['balance']['index'] ?? 0.0)), '#fcd34d'],
];
break;
default:
$stats = [
['Total hours', $this->formatHours((float)($summary['total_hours'] ?? 0.0)), '#22d3ee'],
['Target', $this->formatPercent((float)($summary['targets']['total']['percent'] ?? 0.0)) . '%', '#c4b5fd'],
['Active days', (string)(int)($summary['active_days'] ?? 0), '#ffffff'],
['Events', (string)(int)($summary['events'] ?? 0), '#fcd34d'],
];
}
// 2×2 stat grid — each card at 50% width, more breathing room than 4-in-a-row
$statRows = '';
for ($i = 0; $i < 4; $i += 2) {
$left = $stats[$i] ?? null;
$right = $stats[$i + 1] ?? null;
$leftHtml = $left ? $this->heroStatCell($left[0], $left[1], $left[2], 'padding-right:5px;') : '<td style="width:50%;padding-right:5px;"></td>';
$rightHtml = $right ? $this->heroStatCell($right[0], $right[1], $right[2], '') : '<td style="width:50%;"></td>';
$rowMargin = $i > 0 ? 'margin-top:6px;' : '';
$statRows .= sprintf(
'<table role="presentation" style="width:100%%;border-collapse:collapse;%s"><tr>%s%s</tr></table>',
$rowMargin, $leftHtml, $rightHtml,
);
}
// Meta tags: slim inline row at the bottom — calendars, model, optional profile
$metaTags = sprintf(
'<span style="display:inline-block;padding:4px 10px;background:rgba(255,255,255,.1);border:1px solid rgba(255,255,255,.15);border-radius:6px;font-size:11px;color:rgba(255,255,255,.7);margin-right:6px;margin-top:4px;"><span style="color:rgba(255,255,255,.4);font-size:9px;letter-spacing:.1em;text-transform:uppercase;margin-right:5px;">Cal</span>%s</span>',
$this->escape($selectedLine),
);
$metaTags .= sprintf(
'<span style="display:inline-block;padding:4px 10px;background:rgba(255,255,255,.1);border:1px solid rgba(255,255,255,.15);border-radius:6px;font-size:11px;color:rgba(255,255,255,.7);margin-right:6px;margin-top:4px;"><span style="color:rgba(255,255,255,.4);font-size:9px;letter-spacing:.1em;text-transform:uppercase;margin-right:5px;">Model</span>%s</span>',
$this->escape($reportVariantLabel),
);
if ($activePreset !== '') {
$metaTags .= sprintf(
'<span style="display:inline-block;padding:4px 10px;background:rgba(255,255,255,.14);border:1px solid rgba(255,255,255,.22);border-radius:6px;font-size:11px;font-weight:700;color:rgba(255,255,255,.9);margin-top:4px;"><span style="color:rgba(255,255,255,.4);font-size:9px;letter-spacing:.1em;text-transform:uppercase;margin-right:5px;">Profile</span>%s</span>',
$this->escape($activePreset),
);
}
$name = $this->escape($displayName !== '' ? $displayName : 'there');
$checkpointLabel = $this->escape(strtoupper($rangeLabel) . ' ' . strtoupper($mailTypeLabel));
return sprintf(
'<div style="background:linear-gradient(145deg,#0f1f35 0%%,#1e3a5f 55%%,#0c4a78 100%%);border-radius:16px;padding:28px 26px 22px;color:#ffffff;">
<div style="margin-bottom:18px;">
<span style="display:inline-block;padding:5px 12px;background:rgba(34,211,238,.12);border:1px solid rgba(34,211,238,.28);border-radius:6px;font-size:10px;font-family:monospace;letter-spacing:.14em;text-transform:uppercase;color:#67e8f9;">&#9670;&nbsp; Opsdash &middot; %s</span>
</div>
<div style="font-size:13px;color:rgba(255,255,255,.45);margin-bottom:10px;">Hey %s,</div>
<div style="font-size:32px;line-height:1.0;font-weight:800;letter-spacing:-.025em;margin-bottom:4px;">%s</div>
<div style="font-size:11px;color:rgba(255,255,255,.3);letter-spacing:.04em;font-family:monospace;margin-bottom:22px;">%s</div>
%s
<div style="margin-top:18px;line-height:1;">%s</div>
</div>',
$checkpointLabel,
$name,
$this->escape($periodTitle),
$this->escape($periodLabel),
$statRows,
$metaTags,
);
}
private function heroStatCell(string $label, string $value, string $color, string $extraStyle): string {
return sprintf(
'<td style="width:50%%;vertical-align:top;%s">
<div style="background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.12);border-radius:10px;padding:14px 14px 12px;">
<div style="font-size:9px;font-weight:700;letter-spacing:.12em;text-transform:uppercase;color:rgba(255,255,255,.38);margin-bottom:8px;">%s</div>
<div style="font-size:22px;font-weight:800;color:%s;line-height:1;word-break:break-word;">%s</div>
</div>
</td>',
$extraStyle,
$this->escape($label),
$color,
$this->escape($value),
);
}
private function formatPeriodTitle(string $from, string $to, bool $isMonthly): string {
if ($from === '' && $to === '') {
return $isMonthly ? 'Monthly recap' : 'Weekly recap';
}
try {
$dtFrom = new \DateTimeImmutable($from);
if ($isMonthly) {
return $dtFrom->format('F Y');
}
$dtTo = new \DateTimeImmutable($to);
if ($dtFrom->format('Y-m') === $dtTo->format('Y-m')) {
return $dtFrom->format('M j') . '' . $dtTo->format('j, Y');
}
return $dtFrom->format('M j') . ' ' . $dtTo->format('M j, Y');
} catch (\Throwable) {
return $isMonthly ? 'Monthly recap' : 'Weekly recap';
}
}
private function renderHeroPlain(
string $displayName,
string $rangeLabel,
string $periodLabel,
string $selectedLine,
string $reportVariantLabel,
string $activePreset = '',
string $mailTypeLabel = 'Recap',
): string {
$lines = [
sprintf('Hey %s,', $displayName !== '' ? $displayName : 'there'),
'',
sprintf('%s %s · %s', $rangeLabel, $mailTypeLabel, $periodLabel),
];
if ($activePreset !== '') {
$lines[] = sprintf('Profile: %s', $activePreset);
}
$lines[] = sprintf('Calendars: %s', $selectedLine);
$lines[] = sprintf('Model: %s', $reportVariantLabel);
return implode(PHP_EOL, $lines);
}
/**
* @param array<int,array{label:string,value:string,detail:string}> $cards
*/
private function renderKpiGridHtml(array $cards): string {
// TimeSummaryCard style — single white card with label/value rows
$rows = '';
foreach ($cards as $i => $card) {
$borderTop = $i > 0 ? 'border-top:1px solid #f1f5f9;' : '';
$rows .= sprintf(
'<tr>
<td style="padding:9px 0;%s vertical-align:middle;">
<span style="font-size:13px;color:#64748b;">%s</span>
</td>
<td style="padding:9px 0;%s vertical-align:middle;text-align:right;">
<div style="font-size:13px;font-weight:700;color:#0f172a;font-variant-numeric:tabular-nums;">%s</div>
<div style="font-size:11px;color:#94a3b8;margin-top:1px;">%s</div>
</td>
</tr>',
$borderTop,
$this->escape($card['label']),
$borderTop,
$this->escape($card['value']),
$this->escape($card['detail']),
);
}
$inner = $this->widgetTitle('Time summary', 'Key metrics for this recap period')
. sprintf('<table role="presentation" style="width:100%%;border-collapse:collapse;">%s</table>', $rows);
return $this->widgetCard($inner);
}
/**
* @param array<int,array{label:string,value:string,detail:string}> $cards
*/
private function renderKpiGridPlain(string $title, array $cards): string {
$lines = [$title];
foreach ($cards as $card) {
$lines[] = sprintf('%s: %s (%s)', $card['label'], $card['value'], $card['detail']);
}
return implode(PHP_EOL, $lines);
}
/**
* @param array<string,mixed> $targetTotal
*/
private function renderProgressHtml(array $targetTotal): string {
// TimeTargetsCard single-goal style
$pct = (float)($targetTotal['percent'] ?? 0.0);
$status = (string)($targetTotal['status'] ?? 'none');
$bar = min(100, (int)round($pct));
[$pillBg, $pillColor, $barColor] = $this->statusStyles($status);
$actual = $this->escape($this->formatHours((float)($targetTotal['actual'] ?? 0.0)));
$target = $this->escape($this->formatHours((float)($targetTotal['target'] ?? 0.0)));
$remaining = $this->escape($this->formatHours((float)($targetTotal['remaining'] ?? 0.0)));
$pctText = $this->escape($this->formatPercent($pct));
$statusTxt = $this->escape($this->statusLabel($status));
$inner = $this->widgetTitle('Targets')
// total line: actual / target
. sprintf(
'<div style="font-size:24px;font-weight:800;color:#0f172a;line-height:1;margin-bottom:4px;">
%s <span style="font-size:14px;color:#94a3b8;font-weight:400;">/ %s</span>
</div>',
$actual, $target,
)
// progress bar
. $this->progressBar($bar, $barColor, '10px 0 8px')
// footer: percent · status · remaining
. sprintf(
'<div style="font-size:13px;color:#0f172a;">
<span style="font-weight:700;font-variant-numeric:tabular-nums;">%s%%</span>
<span style="margin:0 6px;color:#cbd5e1;">·</span>
%s
<span style="margin-left:6px;color:#94a3b8;">· remaining %s</span>
</div>',
$pctText,
$this->statusPill($statusTxt, $pillBg, $pillColor),
$remaining,
);
return $this->widgetCard($inner);
}
/**
* @param array<string,mixed> $targetTotal
*/
private function renderProgressPlain(array $targetTotal): string {
return implode(PHP_EOL, [
'Goal progress',
sprintf(
'Total: %s / %s (%s%%) · %s · remaining %s',
$this->formatHours((float)($targetTotal['actual'] ?? 0.0)),
$this->formatHours((float)($targetTotal['target'] ?? 0.0)),
$this->formatPercent((float)($targetTotal['percent'] ?? 0.0)),
$this->statusLabel((string)($targetTotal['status'] ?? 'none')),
$this->formatHours((float)($targetTotal['remaining'] ?? 0.0)),
),
]);
}
/**
* @param array<string,mixed> $targetTotal
* @param array<int,array<string,mixed>> $rows
*/
private function renderTargetBoardHtml(string $title, string $intro, array $targetTotal, array $rows, string $rowLabel): string {
// TimeTargetsCard style: total summary + per-row category/calendar blocks with dot, bar, status
$totalStatus = (string)($targetTotal['status'] ?? 'none');
$totalPct = (float)($targetTotal['percent'] ?? 0.0);
$totalBar = min(100, (int)round($totalPct));
[$tPillBg, $tPillColor, $tBarColor] = $this->statusStyles($totalStatus);
// Total line
$totalHtml = sprintf(
'<table role="presentation" style="width:100%%;border-collapse:collapse;margin-bottom:4px;">
<tr>
<td style="vertical-align:middle;">
<span style="font-size:22px;font-weight:800;color:#0f172a;">%s</span>
<span style="font-size:13px;color:#94a3b8;margin-left:4px;">/ %s</span>
</td>
<td style="vertical-align:middle;text-align:right;">
<span style="font-size:13px;font-weight:700;color:#0f172a;font-variant-numeric:tabular-nums;">%s%%</span>
<span style="margin-left:6px;">%s</span>
</td>
</tr>
</table>',
$this->escape($this->formatHours((float)($targetTotal['actual'] ?? 0.0))),
$this->escape($this->formatHours((float)($targetTotal['target'] ?? 0.0))),
$this->escape($this->formatPercent($totalPct)),
$this->statusPill($this->escape($this->statusLabel($totalStatus)), $tPillBg, $tPillColor),
)
. $this->progressBar($totalBar, $tBarColor, '0 0 16px');
// Per-row blocks (up to 5 rows)
$rowsHtml = '';
$sliced = array_slice($rows, 0, 5);
if (empty($sliced)) {
$rowsHtml = sprintf(
'<div style="padding:12px 0;font-size:13px;color:#94a3b8;">No %s targets configured.</div>',
strtolower($this->escape($rowLabel)),
);
} else {
foreach ($sliced as $row) {
$pct = (float)($row['percent'] ?? 0.0);
$status = (string)($row['status'] ?? 'none');
$bar = min(100, (int)round($pct));
[$pillBg, $pillColor, $barColor] = $this->statusStyles($status);
// Try to use a color dot if a hex color is available on the row
$dotColor = (string)($row['color'] ?? '#94a3b8');
if (!preg_match('/^#[0-9a-fA-F]{3,6}$/', $dotColor)) {
$dotColor = '#94a3b8';
}
$rowsHtml .= sprintf(
'<div style="padding:12px 0;border-top:1px solid #f1f5f9;">
<table role="presentation" style="width:100%%;border-collapse:collapse;margin-bottom:6px;">
<tr>
<td style="vertical-align:middle;">
<span style="display:inline-block;width:9px;height:9px;border-radius:50%%;background:%s;vertical-align:middle;margin-right:6px;"></span>
<span style="font-size:13px;font-weight:600;color:#0f172a;vertical-align:middle;">%s</span>
</td>
<td style="vertical-align:middle;text-align:right;white-space:nowrap;">
<span style="font-size:12px;font-weight:700;color:#0f172a;font-variant-numeric:tabular-nums;">%s%%</span>
<span style="margin-left:5px;">%s</span>
</td>
</tr>
</table>
%s
<div style="font-size:12px;color:#64748b;margin-top:5px;font-variant-numeric:tabular-nums;">
<strong style="color:#0f172a;">%s</strong> / %s
</div>
</div>',
$dotColor,
$this->escape((string)($row['label'] ?? $rowLabel)),
$this->escape($this->formatPercent($pct)),
$this->statusPill($this->escape($this->statusLabel($status)), $pillBg, $pillColor),
$this->progressBar($bar, $barColor, '0', '6px'),
$this->escape($this->formatHours((float)($row['actual'] ?? 0.0))),
$this->escape($this->formatHours((float)($row['target'] ?? 0.0))),
);
}
}
$inner = $this->widgetTitle($this->unescapeAmp($title), $intro)
. $totalHtml
. $rowsHtml;
return $this->widgetCard($inner);
}
/**
* @param array<string,mixed> $targetTotal
* @param array<int,array<string,mixed>> $rows
*/
private function renderTargetBoardPlain(string $title, array $targetTotal, array $rows): string {
$lines = [
$title,
sprintf(
'Total: %s / %s (%s%%) · %s · remaining %s',
$this->formatHours((float)($targetTotal['actual'] ?? 0.0)),
$this->formatHours((float)($targetTotal['target'] ?? 0.0)),
$this->formatPercent((float)($targetTotal['percent'] ?? 0.0)),
$this->statusLabel((string)($targetTotal['status'] ?? 'none')),
$this->formatHours((float)($targetTotal['remaining'] ?? 0.0)),
),
];
foreach (array_slice($rows, 0, 5) as $row) {
$lines[] = sprintf(
'%s: %s / %s (%s%%) · %s',
(string)($row['label'] ?? 'Row'),
$this->formatHours((float)($row['actual'] ?? 0.0)),
$this->formatHours((float)($row['target'] ?? 0.0)),
$this->formatPercent((float)($row['percent'] ?? 0.0)),
$this->statusLabel((string)($row['status'] ?? 'none')),
);
}
return implode(PHP_EOL, $lines);
}
/**
* @param array<string,mixed> $balance
* @param string[] $balanceWarnings
*/
private function renderBalanceHtml(array $balance, array $balanceWarnings): string {
// BalanceIndexCard style: index ring on left, messages on right
$index = (float)($balance['index'] ?? 0.0);
$indexDisplay = $this->escape($this->formatIndex($index));
// Pick index ring color matching app thresholds
if ($index >= 0.85) {
$ringColor = '#22c55e';
$ringBg = '#dcfce7';
$ringText = '#15803d';
} elseif ($index >= 0.65) {
$ringColor = '#f59e0b';
$ringBg = '#fef3c7';
$ringText = '#b45309';
} else {
$ringColor = '#ef4444';
$ringBg = '#fee2e2';
$ringText = '#dc2626';
}
// Warnings
if ($balanceWarnings === []) {
$warningsHtml = '<div style="padding:10px 12px;background:#f0fdf4;border:1px solid #bbf7d0;border-left:3px solid #22c55e;border-radius:8px;font-size:12px;color:#15803d;line-height:1.5;">No balance warnings for this period.</div>';
} else {
$warningsHtml = '';
foreach ($balanceWarnings as $warning) {
$warningsHtml .= sprintf(
'<div style="padding:10px 12px;background:#fffbeb;border:1px solid #fde68a;border-left:3px solid #f59e0b;border-radius:8px;font-size:12px;color:#78350f;line-height:1.5;margin-bottom:6px;">%s</div>',
$this->escape($warning),
);
}
}
// Index badge: outer circle (colored border), inner white circle, value centred
$badgeHtml = sprintf(
'<div style="display:inline-block;width:56px;height:56px;border-radius:50%%;background:%s;border:3px solid %s;text-align:center;line-height:50px;">
<span style="font-size:16px;font-weight:800;color:%s;line-height:50px;">%s</span>
</div>',
$ringBg,
$ringColor,
$ringText,
$indexDisplay,
);
$inner = $this->widgetTitle('Balance index', 'Time mix health for this recap period')
. sprintf(
'<table role="presentation" style="width:100%%;border-collapse:collapse;">
<tr>
<td style="width:72px;vertical-align:top;padding-right:16px;padding-top:2px;">
%s
<div style="font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.08em;color:#94a3b8;text-align:center;margin-top:6px;">Index</div>
</td>
<td style="vertical-align:top;">%s</td>
</tr>
</table>',
$badgeHtml,
$warningsHtml,
);
return $this->widgetCard($inner);
}
/**
* @param array<string,mixed> $balance
* @param string[] $balanceWarnings
*/
private function renderBalancePlain(array $balance, array $balanceWarnings): string {
$lines = [
'Balance',
sprintf('Balance index: %s', $this->formatIndex((float)($balance['index'] ?? 0.0))),
];
if ($balanceWarnings === []) {
$lines[] = 'Warnings: none';
} else {
foreach ($balanceWarnings as $warning) {
$lines[] = '- ' . $warning;
}
}
return implode(PHP_EOL, $lines);
}
/**
* @param array<string,mixed> $summary
* @param array<string,mixed>|null $busiestDay
* @param array<string,mixed>|null $longestSession
*/
private function renderActivityHtml(array $summary, ?array $busiestDay, ?array $longestSession): string {
// TimeSummaryCard activity section style: clean label/value rows
$daysOff = (int)($summary['days_off'] ?? 0);
$busiestValue = $busiestDay ? $this->escape((string)($busiestDay['date'] ?? '—')) : '—';
$busiestDetail = $busiestDay
? $this->escape(sprintf('%s · %d events', $this->formatHours((float)($busiestDay['hours'] ?? 0.0)), (int)($busiestDay['events'] ?? 0)))
: 'No standout day';
$longestLabel = $longestSession
? $this->escape((string)(($longestSession['summary'] ?? '') ?: ($longestSession['calendar'] ?? '—')))
: '—';
$longestDetail = $longestSession
? $this->escape(sprintf('%s · %s', $this->formatHours((float)($longestSession['hours'] ?? 0.0)), (string)($longestSession['start'] ?? '')))
: 'No long session found';
$metaStyle = 'font-size:13px;color:#64748b;';
$valueStyle = 'font-size:13px;font-weight:700;color:#0f172a;';
$subStyle = 'font-size:11px;color:#94a3b8;margin-top:2px;';
$rows = sprintf(
'<tr>
<td style="padding:9px 0;vertical-align:middle;width:38%%;">
<span style="%s">Days off</span>
</td>
<td style="padding:9px 0;vertical-align:middle;text-align:right;">
<div style="%s">%d</div>
<div style="%s">Quiet days in this period</div>
</td>
</tr>
<tr>
<td style="padding:9px 0;border-top:1px solid #f1f5f9;vertical-align:top;width:38%%;">
<span style="%s">Busiest day</span>
</td>
<td style="padding:9px 0;border-top:1px solid #f1f5f9;vertical-align:top;text-align:right;">
<div style="%s">%s</div>
<div style="%s">%s</div>
</td>
</tr>
<tr>
<td style="padding:9px 0;border-top:1px solid #f1f5f9;vertical-align:top;width:38%%;">
<span style="%s">Longest session</span>
</td>
<td style="padding:9px 0;border-top:1px solid #f1f5f9;vertical-align:top;text-align:right;">
<div style="%s">%s</div>
<div style="%s">%s</div>
</td>
</tr>',
$metaStyle,
$valueStyle, $daysOff,
$subStyle,
$metaStyle,
$valueStyle, $busiestValue,
$subStyle, $busiestDetail,
$metaStyle,
$valueStyle, $longestLabel,
$subStyle, $longestDetail,
);
$inner = $this->widgetTitle('Activity highlights')
. sprintf('<table role="presentation" style="width:100%%;border-collapse:collapse;">%s</table>', $rows);
return $this->widgetCard($inner);
}
/**
* @param array<string,mixed> $summary
* @param array<string,mixed>|null $busiestDay
* @param array<string,mixed>|null $longestSession
*/
private function renderActivityPlain(array $summary, ?array $busiestDay, ?array $longestSession): string {
$lines = [
'Activity',
sprintf('Days off: %d', (int)($summary['days_off'] ?? 0)),
$busiestDay
? sprintf('Busiest day: %s (%s, %d events)', (string)($busiestDay['date'] ?? ''), $this->formatHours((float)($busiestDay['hours'] ?? 0.0)), (int)($busiestDay['events'] ?? 0))
: 'Busiest day: —',
$longestSession
? sprintf('Longest session: %s (%s, %s)', (string)(($longestSession['summary'] ?? '') ?: ($longestSession['calendar'] ?? '')), $this->formatHours((float)($longestSession['hours'] ?? 0.0)), (string)($longestSession['start'] ?? ''))
: 'Longest session: —',
];
return implode(PHP_EOL, $lines);
}
/**
* @param array<string,mixed> $notes
* @param array<string,mixed> $reportingConfig
*/
private function renderNotesHtml(array $notes, array $reportingConfig): string {
$current = trim((string)($notes['current'] ?? ''));
$previous = trim((string)($notes['previous'] ?? ''));
if ($current === '' && $previous === '') {
return '';
}
$blocks = '';
if ($current !== '') {
$blocks .= sprintf(
'<div style="%s">
<div style="font-size:10px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:#94a3b8;margin-bottom:4px;">This period</div>
<div style="font-size:13px;line-height:1.65;color:#34506a;">%s</div>
</div>',
$previous !== '' ? 'margin-bottom:14px;' : '',
nl2br($this->escape($current)),
);
}
if ($previous !== '') {
$blocks .= sprintf(
'<div%s>
<div style="font-size:10px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:#94a3b8;margin-bottom:4px;">Previous period</div>
<div style="font-size:13px;line-height:1.65;color:#34506a;">%s</div>
</div>',
$current !== '' ? ' style="border-top:1px solid #f1f5f9;padding-top:14px;"' : '',
nl2br($this->escape($previous)),
);
}
return $this->widgetCard(
$this->widgetTitle('Notes') . $blocks
);
}
/**
* @param array<string,mixed> $notes
* @param array<string,mixed> $reportingConfig
*/
private function renderNotesPlain(array $notes, array $reportingConfig): string {
$current = trim((string)($notes['current'] ?? ''));
$previous = trim((string)($notes['previous'] ?? ''));
if ($current === '' && $previous === '') {
return '';
}
$lines = ['Notes'];
if ($current !== '') {
$lines[] = 'This period: ' . $current;
}
if ($previous !== '') {
$lines[] = 'Previous period: ' . $previous;
}
return implode(PHP_EOL, $lines);
}
private function formatHours(float $hours): string {
return number_format($hours, 2, '.', '') . ' h';
}
private function formatPercent(float $percent): string {
return number_format($percent, 1, '.', '');
}
private function formatIndex(float $index): string {
return number_format($index, 2, '.', '');
}
/**
* @param array<string,mixed> $reportingConfig
*/
private function renderModePrefsPlain(array $reportingConfig): string {
$parts = [];
$modes = is_array($reportingConfig['modes'] ?? null) ? $reportingConfig['modes'] : [];
foreach (['week' => 'week', 'month' => 'month'] as $key => $label) {
$mode = is_array($modes[$key] ?? null) ? $modes[$key] : [];
$parts[] = sprintf(
'%s=%s/%s/reminder-%s',
$label,
!empty($mode['enabled']) ? 'on' : 'off',
$this->cadenceLabel((string)($mode['cadence'] ?? 'end')),
(string)($mode['reminderLead'] ?? 'none'),
);
}
return implode(', ', $parts);
}
private function cadenceLabel(string $cadence): string {
return match ($cadence) {
'daily' => 'daily',
'mid' => 'mid',
default => 'end',
};
}
private function statusLabel(string $status): string {
return match ($status) {
'on_track' => 'On Track',
'at_risk' => 'At Risk',
'behind' => 'Behind',
'done' => 'Done',
default => '—',
};
}
/**
* @return array{0:string,1:string,2:string} [pillBg, pillColor, barColor]
*/
private function statusStyles(string $status): array {
return match ($status) {
'done', 'on_track' => ['#dcfce7', '#15803d', 'linear-gradient(90deg,#22c55e,#4ade80)'],
'at_risk' => ['#fef3c7', '#b45309', 'linear-gradient(90deg,#f59e0b,#fbbf24)'],
'behind' => ['#fee2e2', '#dc2626', 'linear-gradient(90deg,#ef4444,#f87171)'],
default => ['#e0f2fe', '#0369a1', 'linear-gradient(90deg,#0ea5e9,#38bdf8)'],
};
}
private function widgetCard(string $content, string $marginTop = '20px'): string {
return sprintf(
'<div style="margin-top:%s;border:1px solid #e2e8f0;border-radius:14px;padding:18px 20px;background:#ffffff;">%s</div>',
$marginTop,
$content,
);
}
private function widgetTitle(string $title, string $subtitle = ''): string {
$sub = $subtitle !== ''
? sprintf('<div style="font-size:12px;color:#64748b;margin-top:2px;">%s</div>', $this->escape($subtitle))
: '';
return sprintf(
'<div style="margin-bottom:14px;padding-bottom:12px;border-bottom:1px solid #f1f5f9;">
<div style="font-size:15px;font-weight:700;color:#0f172a;">%s</div>%s
</div>',
$this->escape($title),
$sub,
);
}
private function progressBar(int $pct, string $barColor, string $margin = '0', string $height = '7px'): string {
return sprintf(
'<div style="background:#f1f5f9;border-radius:999px;height:%s;overflow:hidden;margin:%s;">
<div style="width:%d%%;height:100%%;background:%s;border-radius:999px;"></div>
</div>',
$height,
$margin,
$pct,
$barColor,
);
}
private function statusPill(string $label, string $bg, string $color): string {
return sprintf(
'<span style="display:inline-block;padding:2px 9px;border-radius:999px;font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;background:%s;color:%s;">%s</span>',
$bg,
$color,
$label,
);
}
private function unescapeAmp(string $value): string {
return str_replace('&amp;', '&', $value);
}
private const CHART_PALETTE = ['#0ea5e9', '#22d3ee', '#a78bfa', '#f59e0b', '#22c55e', '#f472b6', '#94a3b8'];
/**
* @param array<int,array{label:string,hours:float,color?:string|null}> $rows
*/
private function renderPieChartHtml(string $title, string $subtitle, array $rows, float $totalHours): string {
if (empty($rows) || $totalHours <= 0) {
return '';
}
$bars = '';
foreach ($rows as $i => $row) {
$pct = round(($row['hours'] / $totalHours) * 100, 1);
$fill = min(100, (int)round($pct));
$color = (string)($row['color'] ?? '');
if ($color === '' || !preg_match('/^#[0-9a-fA-F]{3,6}$/', $color)) {
$color = self::CHART_PALETTE[$i % count(self::CHART_PALETTE)];
}
$bars .= sprintf(
'<tr>
<td style="width:110px;padding:7px 10px 7px 0;vertical-align:middle;">
<span style="display:inline-block;width:8px;height:8px;border-radius:50%%;background:%s;vertical-align:middle;margin-right:6px;flex-shrink:0;"></span>
<span style="font-size:12px;font-weight:600;color:#1e293b;vertical-align:middle;">%s</span>
</td>
<td style="padding:7px 8px 7px 0;vertical-align:middle;">
<div style="background:#f1f5f9;border-radius:4px;height:8px;overflow:hidden;">
<div style="width:%d%%;height:8px;background:%s;border-radius:4px;"></div>
</div>
</td>
<td style="width:52px;padding:7px 0 7px 6px;vertical-align:middle;text-align:right;white-space:nowrap;">
<span style="font-size:11px;font-weight:700;color:#0f172a;font-variant-numeric:tabular-nums;">%s h</span>
</td>
<td style="width:38px;padding:7px 0 7px 6px;vertical-align:middle;text-align:right;white-space:nowrap;">
<span style="font-size:11px;color:#94a3b8;font-variant-numeric:tabular-nums;">%s%%</span>
</td>
</tr>',
$color,
$this->escape($row['label']),
$fill,
$color,
$this->escape($this->formatHours($row['hours'])),
$this->escape((string)$pct),
);
}
$inner = $this->widgetTitle($title, $subtitle)
. sprintf('<table role="presentation" style="width:100%%;border-collapse:collapse;">%s</table>', $bars);
return $this->widgetCard($inner);
}
/**
* @param array<int,array{label:string,hours:float,color?:string|null}> $rows
*/
private function renderPieChartPlain(string $title, array $rows, float $totalHours): string {
if (empty($rows) || $totalHours <= 0) {
return '';
}
$lines = [$title];
foreach ($rows as $row) {
$pct = $totalHours > 0 ? round(($row['hours'] / $totalHours) * 100, 1) : 0;
$lines[] = sprintf('%s: %s h (%s%%)', $row['label'], $this->formatHours($row['hours']), $pct);
}
return implode(PHP_EOL, $lines);
}
/**
* @param array<string,float> $dowAvg keyed Mon..Sun
* @param string[] $dowOrder
*/
private function renderDowChartHtml(array $dowAvg, array $dowOrder): string {
if (empty($dowAvg)) {
return '';
}
$maxVal = max(0.001, max(array_values($dowAvg)));
$maxBarPx = 64;
$barCells = '';
$labelCells = '';
$valueCells = '';
foreach ($dowOrder as $d) {
$val = (float)($dowAvg[$d] ?? 0.0);
$ratio = $val / $maxVal;
$barH = max(2, (int)round($ratio * $maxBarPx));
$spacerH = $maxBarPx - $barH;
$isWeekend = $d === 'Sat' || $d === 'Sun';
$barColor = $isWeekend ? '#c4b5fd' : '#0ea5e9';
$labelColor = $isWeekend ? '#a78bfa' : '#64748b';
$barCells .= sprintf(
'<td style="width:14.28%%;padding:0 3px;vertical-align:bottom;text-align:center;">
<div style="height:%dpx;"></div>
<div style="background:%s;border-radius:3px 3px 0 0;height:%dpx;"></div>
</td>',
$spacerH, $barColor, $barH,
);
$labelCells .= sprintf(
'<td style="width:14.28%%;padding:4px 3px 0;text-align:center;font-size:10px;font-weight:600;color:%s;letter-spacing:.04em;">%s</td>',
$labelColor, $this->escape($d),
);
$valueCells .= sprintf(
'<td style="width:14.28%%;padding:2px 3px 0;text-align:center;font-size:9px;color:#94a3b8;font-variant-numeric:tabular-nums;">%s</td>',
$val > 0 ? $this->escape($this->formatHours($val)) : '—',
);
}
$inner = $this->widgetTitle('Day-of-week pattern', 'Average hours per weekday')
. sprintf(
'<table role="presentation" style="width:100%%;border-collapse:collapse;">
<tr>%s</tr>
<tr>%s</tr>
<tr>%s</tr>
</table>',
$barCells, $labelCells, $valueCells,
);
return $this->widgetCard($inner);
}
/**
* @param array<string,float> $dowAvg
* @param string[] $dowOrder
*/
private function renderDowChartPlain(array $dowAvg, array $dowOrder): string {
$lines = ['Day-of-week pattern (avg h)'];
foreach ($dowOrder as $d) {
$val = (float)($dowAvg[$d] ?? 0.0);
$lines[] = sprintf('%s: %s', $d, $val > 0 ? $this->formatHours($val) . ' h' : '—');
}
return implode(PHP_EOL, $lines);
}
private function escape(string $value): string {
return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
}