Add support for progress alert rendering in goals module

- Implement `goals_progress_alert_renderer` service to compose and render progress alert messages based on destination, progress type, and style.
- Add format classes (`sms_progress_alert_format`, `email_progress_alert_format`, `slack_progress_alert_format`) with length constraints.
- Extend `goals_criteria` to include alert customization properties (`progress_alert_destination`, `progress_alert_progress_type`, `progress_alert_style`, `progress_alert_format`) with parsing and validation logic.
- Update `toArray` and `fromJson` methods to support new fields.
This commit is contained in:
Jeppe Bundgaard
2026-01-27 08:44:44 +01:00
parent 845bd0c6c6
commit 2f6ae98d42
5 changed files with 283 additions and 0 deletions
@@ -5,6 +5,9 @@ namespace goals\classes;
use classes\db;
use goals\helpers\goals_criteria_type;
use goals\helpers\goals_criteria_progress_alert_frequency;
use goals\helpers\goals_criteria_progress_alert_destination;
use goals\helpers\goals_criteria_progress_alert_progress_type;
use goals\helpers\goals_criteria_progress_alert_style;
use goals\interfaces\goals_criteria_i;
use goals\traits\goals_result_parser_t;
use goals\traits\goals_target_t;
@@ -43,6 +46,26 @@ class goals_criteria implements goals_criteria_i
* @var goals_criteria_progress_alert_frequency $progress_alert_frequency
*/
public goals_criteria_progress_alert_frequency $progress_alert_frequency;
/**
* Progress alert destination for the goal
* @var goals_criteria_progress_alert_destination $progress_alert_destination
*/
public goals_criteria_progress_alert_destination $progress_alert_destination;
/**
* Progress alert progress type for the goal
* @var goals_criteria_progress_alert_progress_type $progress_alert_progress_type
*/
public goals_criteria_progress_alert_progress_type $progress_alert_progress_type;
/**
* Progress alert style for the goal
* @var goals_criteria_progress_alert_style $progress_alert_style
*/
public goals_criteria_progress_alert_style $progress_alert_style;
/**
* Progress alert format (free-form string for now; rendering is handled elsewhere)
* @var string|null $progress_alert_format
*/
public ?string $progress_alert_format = null;
/**
* Constructor
*/
@@ -54,6 +77,9 @@ class goals_criteria implements goals_criteria_i
$this->start = new \DateTime();
$this->end = new \DateTime();
$this->progress_alert_frequency = goals_criteria_progress_alert_frequency::NONE;
$this->progress_alert_destination = goals_criteria_progress_alert_destination::NONE;
$this->progress_alert_progress_type = goals_criteria_progress_alert_progress_type::NONE;
$this->progress_alert_style = goals_criteria_progress_alert_style::NONE;
}
@@ -110,6 +136,52 @@ class goals_criteria implements goals_criteria_i
}
}
// Parse progress alert destination (ignore unknowns)
if (isset($data['progress_alert_destination']) && is_string($data['progress_alert_destination'])) {
$dest = goals_criteria_progress_alert_destination::tryFrom($data['progress_alert_destination']);
if ($dest !== null) {
$criteria->progress_alert_destination = $dest;
}
} elseif (isset($data['progressAlertDestination']) && is_string($data['progressAlertDestination'])) {
$dest = goals_criteria_progress_alert_destination::tryFrom($data['progressAlertDestination']);
if ($dest !== null) {
$criteria->progress_alert_destination = $dest;
}
}
// Parse progress alert progress type (ignore unknowns)
if (isset($data['progress_alert_progress_type']) && is_string($data['progress_alert_progress_type'])) {
$ptype = goals_criteria_progress_alert_progress_type::tryFrom($data['progress_alert_progress_type']);
if ($ptype !== null) {
$criteria->progress_alert_progress_type = $ptype;
}
} elseif (isset($data['progressAlertProgressType']) && is_string($data['progressAlertProgressType'])) {
$ptype = goals_criteria_progress_alert_progress_type::tryFrom($data['progressAlertProgressType']);
if ($ptype !== null) {
$criteria->progress_alert_progress_type = $ptype;
}
}
// Parse progress alert style (ignore unknowns)
if (isset($data['progress_alert_style']) && is_string($data['progress_alert_style'])) {
$style = goals_criteria_progress_alert_style::tryFrom($data['progress_alert_style']);
if ($style !== null) {
$criteria->progress_alert_style = $style;
}
} elseif (isset($data['progressAlertStyle']) && is_string($data['progressAlertStyle'])) {
$style = goals_criteria_progress_alert_style::tryFrom($data['progressAlertStyle']);
if ($style !== null) {
$criteria->progress_alert_style = $style;
}
}
// Parse progress alert format (string)
if (isset($data['progress_alert_format']) && is_string($data['progress_alert_format'])) {
$criteria->progress_alert_format = trim((string)$data['progress_alert_format']);
} elseif (isset($data['progressAlertFormat']) && is_string($data['progressAlertFormat'])) {
$criteria->progress_alert_format = trim((string)$data['progressAlertFormat']);
}
// Parse timeframe with validation
if (isset($data['start']) && is_string($data['start'])) {
try {
@@ -252,6 +324,10 @@ class goals_criteria implements goals_criteria_i
'departments' => $departments,
'products' => $products,
'progress_alert_frequency' => $this->progress_alert_frequency?->name ?? goals_criteria_progress_alert_frequency::NONE->name,
'progress_alert_destination' => $this->progress_alert_destination?->name ?? goals_criteria_progress_alert_destination::NONE->name,
'progress_alert_progress_type' => $this->progress_alert_progress_type?->name ?? goals_criteria_progress_alert_progress_type::NONE->name,
'progress_alert_style' => $this->progress_alert_style?->name ?? goals_criteria_progress_alert_style::NONE->name,
'progress_alert_format' => $this->progress_alert_format,
];
}
@@ -264,6 +340,14 @@ class goals_criteria implements goals_criteria_i
return json_encode($this->toArray());
}
/**
* Render a progress alert message based on current destination, progress type, style and optional template
*/
public function renderProgressAlert(): string
{
return \goals\services\goals_progress_alert_renderer::render($this);
}
private function getProgress(): int
{
return match ($this->type) {
@@ -361,6 +445,15 @@ class goals_criteria implements goals_criteria_i
if (!in_array($this->progress_alert_frequency, goals_criteria_progress_alert_frequency::cases())) {
$this->progress_alert_frequency = goals_criteria_progress_alert_frequency::NONE;
}
if (!in_array($this->progress_alert_destination, goals_criteria_progress_alert_destination::cases())) {
$this->progress_alert_destination = goals_criteria_progress_alert_destination::NONE;
}
if (!in_array($this->progress_alert_progress_type, goals_criteria_progress_alert_progress_type::cases())) {
$this->progress_alert_progress_type = goals_criteria_progress_alert_progress_type::NONE;
}
if (!in_array($this->progress_alert_style, goals_criteria_progress_alert_style::cases())) {
$this->progress_alert_style = goals_criteria_progress_alert_style::NONE;
}
// Sanitize label
if (is_string($this->label)) {
@@ -372,5 +465,20 @@ class goals_criteria implements goals_criteria_i
$this->label = $label;
}
}
// Sanitize progress alert format (length depends on destination)
if (is_string($this->progress_alert_format)) {
$fmt = trim($this->progress_alert_format);
$fmt = strip_tags($fmt);
$fmt = preg_replace('/\s+/', ' ', $fmt);
if ($fmt !== null) {
$maxLen = match ($this->progress_alert_destination) {
\goals\helpers\goals_criteria_progress_alert_destination::SMS => 160,
default => 1024,
};
$fmt = mb_substr($fmt, 0, $maxLen);
$this->progress_alert_format = $fmt === '' ? null : $fmt;
}
}
}
}
@@ -0,0 +1,28 @@
<?php
namespace goals\formats;
use goals\traits\goals_criteria_progress_alert_format_t;
class email_progress_alert_format implements \JsonSerializable
{
use goals_criteria_progress_alert_format_t;
private string $text;
public const MAX_LEN = 1024;
public function __construct(string $text)
{
$this->text = $text;
}
public function render(): string
{
$rendered = trim($this->text);
if (mb_strlen($rendered) > self::MAX_LEN) {
$rendered = mb_substr($rendered, 0, self::MAX_LEN);
}
return $rendered;
}
}
@@ -0,0 +1,29 @@
<?php
namespace goals\formats;
use goals\traits\goals_criteria_progress_alert_format_t;
class slack_progress_alert_format implements \JsonSerializable
{
use goals_criteria_progress_alert_format_t;
private string $text;
public const MAX_LEN = 1024;
public function __construct(string $text)
{
$this->text = $text;
}
public function render(): string
{
$rendered = trim($this->text);
// Slack supports simple markdown; no transformation here — enforce overall length
if (mb_strlen($rendered) > self::MAX_LEN) {
$rendered = mb_substr($rendered, 0, self::MAX_LEN);
}
return $rendered;
}
}
@@ -0,0 +1,29 @@
<?php
namespace goals\formats;
use goals\traits\goals_criteria_progress_alert_format_t;
class sms_progress_alert_format implements \JsonSerializable
{
use goals_criteria_progress_alert_format_t;
private string $text;
public const MAX_LEN = 160;
public function __construct(string $text)
{
$this->text = $text;
}
public function render(): string
{
$rendered = trim($this->text);
// Enforce SMS length hard limit
if (mb_strlen($rendered) > self::MAX_LEN) {
$rendered = mb_substr($rendered, 0, self::MAX_LEN);
}
return $rendered;
}
}
@@ -0,0 +1,89 @@
<?php
namespace goals\services;
use goals\classes\goals_criteria;
use goals\formats\email_progress_alert_format;
use goals\formats\slack_progress_alert_format;
use goals\formats\sms_progress_alert_format;
use goals\helpers\goals_criteria_progress_alert_destination as Dest;
use goals\helpers\goals_criteria_progress_alert_progress_type as PType;
use goals\helpers\goals_criteria_progress_alert_style as Style;
class goals_progress_alert_renderer
{
public static function render(goals_criteria $criteria): string
{
$label = (string)($criteria->label ?? 'Goal');
$count = (int)self::getProgress($criteria);
$target = (int)($criteria->target ?? 0);
$percent = $target > 0 ? round(($count / max(1, $target)) * 100, 2) : 0.0;
$dateFmt = 'Y-m-d';
$tfStart = $criteria->start instanceof \DateTimeInterface ? $criteria->start->format($dateFmt) : '';
$tfEnd = $criteria->end instanceof \DateTimeInterface ? $criteria->end->format($dateFmt) : '';
$timeframe = ($tfStart && $tfEnd) ? "$tfStart$tfEnd" : '';
$deptList = implode(',', $criteria->departments?->listIDs() ?? []);
$prefix = match ($criteria->progress_alert_style) {
Style::DEPARTMENT_COMPARE => '[Dept compare] ',
Style::COLLECTIVE => '[Collective] ',
Style::SINGLE_DEPARTMENT => '[Single] ',
default => ''
};
$body = match ($criteria->progress_alert_progress_type) {
PType::PERCENTAGE_ONLY => sprintf('Progress: %s%%', number_format($percent, 2)),
PType::COUNT_ONLY => sprintf('Progress: %d', $count),
PType::COUNT_AND_TARGET => sprintf('Progress: %d / %d', $count, $target),
PType::ALL => sprintf('Progress: %s%% (%d / %d)', number_format($percent, 2), $count, $target),
default => sprintf('Progress: %s%%', number_format($percent, 2)),
};
$parts = array_filter([
$prefix . $label,
$body,
$timeframe,
$deptList ? "Depts: $deptList" : null,
]);
$composed = implode(' | ', $parts);
// If a custom template string is provided, use it as a printf-style template
// Supported tokens: {label}, {percent}, {count}, {target}, {timeframe}, {departments}, {prefix}
if (is_string($criteria->progress_alert_format) && $criteria->progress_alert_format !== '') {
$tmpl = $criteria->progress_alert_format;
$map = [
'{label}' => $label,
'{percent}' => number_format($percent, 2),
'{count}' => (string)$count,
'{target}' => (string)$target,
'{timeframe}' => $timeframe,
'{departments}' => $deptList,
'{prefix}' => $prefix,
'{body}' => $body,
];
$composed = strtr($tmpl, $map);
}
// Wrap in proper destination format class to enforce channel limits
return match ($criteria->progress_alert_destination) {
Dest::SMS => (new sms_progress_alert_format($composed))->render(),
Dest::EMAIL => (new email_progress_alert_format($composed))->render(),
Dest::SLACK => (new slack_progress_alert_format($composed))->render(),
default => $composed,
};
}
private static function getProgress(goals_criteria $criteria): int
{
$ref = new \ReflectionClass($criteria);
if ($ref->hasMethod('getProgress')) {
$m = $ref->getMethod('getProgress');
$m->setAccessible(true);
/** @var int $val */
$val = $m->invoke($criteria);
return (int)$val;
}
return 0;
}
}