Move files, complete PHP migration

The application works the same way as the F# version
This commit is contained in:
2024-06-23 16:35:55 -04:00
parent 9421bb2035
commit 20ad50928a
37 changed files with 354 additions and 301 deletions

View File

@@ -5,10 +5,19 @@ namespace MyPrayerJournal;
use Auth0\SDK\Auth0;
use Auth0\SDK\Exception\ConfigurationException;
/**
* myPrayerJournal-specific authorization functions
*/
class Auth
{
/** @var Auth0|null The Auth0 client to use for requests (initialized on first use) */
private static ?Auth0 $auth0 = null;
/**
* Get the current Auth0 client
*
* @return Auth0 The current Auth0 client
*/
public static function client(): Auth0
{
if (is_null(self::$auth0)) {

View File

@@ -1,6 +1,6 @@
<?php declare(strict_types=1);
namespace MyPrayerJournal;
namespace MyPrayerJournal\Domain;
use JsonSerializable;

View File

@@ -1,6 +1,6 @@
<?php declare(strict_types=1);
namespace MyPrayerJournal;
namespace MyPrayerJournal\Domain;
use BitBadger\PDODocument\DocumentException;

View File

@@ -1,6 +1,6 @@
<?php declare(strict_types=1);
namespace MyPrayerJournal;
namespace MyPrayerJournal\Domain;
use DateInterval;
use JsonSerializable;

View File

@@ -1,6 +1,6 @@
<?php declare(strict_types=1);
namespace MyPrayerJournal;
namespace MyPrayerJournal\Domain;
/**
* The type of recurrence a request can have

View File

@@ -1,11 +1,12 @@
<?php declare(strict_types=1);
namespace MyPrayerJournal;
namespace MyPrayerJournal\Domain;
use BitBadger\PDODocument\{Custom, DocumentException, DocumentList, Find, Mapper\DocumentMapper};
use DateTimeImmutable;
use Exception;
use JsonSerializable;
use MyPrayerJournal\Table;
use Visus\Cuid2\Cuid2;
/**

View File

@@ -1,6 +1,6 @@
<?php declare(strict_types=1);
namespace MyPrayerJournal;
namespace MyPrayerJournal\Domain;
/**
* An action taken on a prayer request

View File

@@ -1,238 +0,0 @@
<?php declare(strict_types=1);
namespace MyPrayerJournal;
use BitBadger\PDODocument\{DocumentException, DocumentList};
use DateTimeImmutable;
use DateTimeZone;
use Exception;
/**
* User interface building blocks
*/
class UI
{
/**
* Generate a material icon
*
* @param string $name The name of the material icon
* @return string The material icon wrapped in a `span` tag
*/
public static function icon(string $name): string {
return "<span class=material-icons>$name</span>";
}
/**
* Render the journal items for the current user
*
* @throws DocumentException If any is encountered
*/
public static function journal(): void
{
Layout::bareHead();
$reqs = Request::forJournal();
if ($reqs->hasItems()) { ?>
<section id=journalItems class="row row-cols-1 row-cols-md-2 row-cols-lg-3 row-cols-xl-4 g-3" hx-target=this
hx-swap=outerHTML aria-label="Prayer Requests"><?php
$spacer = '<span>&nbsp;</span>';
foreach ($reqs->items() as /** @var Request $req */ $req) { ?>
<div class=col>
<div class="card h-100">
<div class="card-header p-0 d-flex" role=toolbar><?php
self::pageLink("/request/edit?id=$req->id", self::icon('edit'),
['class' => 'btn btn-secondary', 'title' => 'Edit Request']); ?>
<?=$spacer?>
<button type=button class="btn btn-secondary" title="Add Notes" data-bs-toggle=modal
data-bs-target=#notesModal hx-get="/components/request/add-note?id=<?=$req->id?>"
hx-target=#notesBody hx-swap=innerHTML><?=self::icon('comment');?></button>
<?=$spacer?>
<button type=button class="btn btn-secondary" title="Snooze Request" data-bs-toggle=modal
data-bs-target=#snoozeModal hx-get="/components/request/snooze?id=<?=$req->id?>"
hx-target=#snoozeBody hx-swap=innerHTML><?=self::icon('schedule');?></button>
<div class=flex-grow-1></div>
<button type=button class="btn btn-success w-25" hx-patch="/request/prayed?id=<?=$req->id?>"
title="Mark as Prayed"><?=self::icon('done');?></button>
</div>
<div class=card-body>
<p class=request-text><?=htmlentities($req->currentText());?>
</div>
<div class="card-footer text-end text-muted px-1 py-0">
<em><?php
$lastPrayed = $req->lastPrayed();
echo 'last ' . (is_null($lastPrayed) ? 'activity': 'prayed') . ' ';
self::relativeDate($lastPrayed ?? $req->history[0]->asOf); ?>
</em>
</div>
</div>
</div><?php
} ?>
</section><?php
} else {
UI::noResults('No Active Requests', '/request/edit?id=new', 'Add a Request', <<<'TEXT'
You have no requests to be shown; see the &ldquo;Active&rdquo; link above for snoozed or deferred
requests, and the &ldquo;Answered&rdquo; link for answered requests
TEXT);
}
Layout::bareFoot();
}
/**
* Create a card when there are no results found
*/
public static function noResults(string $heading, string $link, string $buttonText, string $text): void
{ ?>
<div class=card>
<h5 class=card-header><?=$heading?></h5>
<div class="card-body text-center">
<p class=card-text><?=$text?></p><?php
self::pageLink($link, $buttonText, ['class' => 'btn btn-primary']); ?>
</div>
</div><?php
}
/**
* Generate a link to a page within myPrayerJournal
*
* @param string $href The URL for the link
* @param string $text The text for the link
* @param array $attrs Any additional attributes that should be placed on the `a` tag
*/
public static function pageLink(string $href, string $text, array $attrs = []): void
{ ?>
<a href="<?=$href?>" hx-get="<?=$href?>" hx-target=#top hx-swap=innerHTML hx-push-url=true<?php
foreach ($attrs as $key => $value) echo " $key=\"" . htmlspecialchars($value) . "\""; ?>><?=$text?></a><?php
}
/**
* @throws Exception
*/
public static function relativeDate(string $date): void
{
$parsed = new DateTimeImmutable($date);
$inZone = $parsed->setTimezone(new DateTimeZone($_REQUEST['time_zone']));
echo '<span title="' . date_format($inZone, 'l, F j, Y \a\t g:ia T') . '">'
. self::formatDistance('now', $parsed) . '</span>';
}
// Many thanks to date-fns (https://date-fns.org) for this logic
/**
* Format the distance between two dates
*
* @param string|DateTimeImmutable $from The starting date/time
* @param string|DateTimeImmutable $to The ending date/time
* @return string The distance between two dates
* @throws Exception If date/time objects cannot be created
*/
public static function formatDistance(string|DateTimeImmutable $from, string|DateTimeImmutable $to): string
{
$aDay = 1_440.0;
$almost2Days = 2_520.0;
$aMonth = 43_200.0;
$twoMonths = 86_400.0;
$dtFrom = is_string($from) ? new DateTimeImmutable($from) : $from;
$dtTo = is_string($to) ? new DateTimeImmutable($to) : $to;
$minutes = abs($dtFrom->getTimestamp() - $dtTo->getTimestamp()) / 60;
$months = round($minutes / $aMonth);
$years = round($months / 12);
$typeAndNumber = match (true) {
$minutes < 1.0 => [FormatDistanceToken::LessThanXMinutes, 1],
$minutes < 45.0 => [FormatDistanceToken::XMinutes, round($minutes)],
$minutes < 90.0 => [FormatDistanceToken::AboutXHours, 1],
$minutes < $aDay => [FormatDistanceToken::AboutXHours, round($minutes / 60)],
$minutes < $almost2Days => [FormatDistanceToken::XDays, 1],
$minutes < $aMonth => [FormatDistanceToken::XDays, round($minutes / $aDay)],
$minutes < $twoMonths => [FormatDistanceToken::AboutXMonths, round($minutes / $aMonth)],
$months < 12 => [FormatDistanceToken::XMonths, round($minutes / $aMonth)],
$months % 12 < 3 => [FormatDistanceToken::AboutXYears, $years],
$months % 12 < 9 => [FormatDistanceToken::OverXYears, $years],
default => [FormatDistanceToken::AlmostXYears, $years]
};
$format = match ($typeAndNumber[0]) {
FormatDistanceToken::LessThanXMinutes => ['less than a minute', 'less than %d minutes'],
FormatDistanceToken::XMinutes => ['a minute', '%d minutes'],
FormatDistanceToken::AboutXHours => ['about an hour', 'about %d hours'],
FormatDistanceToken::XHours => ['an hour', '%d hours'],
FormatDistanceToken::XDays => ['a day', '%d days'],
FormatDistanceToken::AboutXWeeks => ['about a week', 'about %d weeks'],
FormatDistanceToken::XWeeks => ['a week', '%d weeks'],
FormatDistanceToken::AboutXMonths => ['about a month', 'about %d months'],
FormatDistanceToken::XMonths => ['a month', '%d months'],
FormatDistanceToken::AboutXYears => ['about a year', 'about %d years'],
FormatDistanceToken::XYears => ['a year', '%d years'],
FormatDistanceToken::OverXYears => ['over a year', 'over %d years'],
FormatDistanceToken::AlmostXYears => ['almost a year', 'almost %d years']
};
$value = $typeAndNumber[1] == 1 ? $format[0] : sprintf($format[1], $typeAndNumber[1]);
return $dtFrom > $dtTo ? "$value ago" : "in $value";
}
public static function requestItem(Request $req): void
{
$btnClass = "btn btn-light mx-2";
$restoreBtn = fn(string $id, string $link, string $title) =>
'<button class="' . $btnClass. '" hx-patch="/request/' . $link . '?id=' . $id
. '" title="' . htmlspecialchars($title) . '">' . self::icon('restore') . '</button>'; ?>
<div class="list-group-item px-0 d-flex flex-row align-items-start" hx-target=this
hx-swap=outerHTML><?php
self::pageLink("/request/full?id=$req->id", self::icon('description'),
['class' => $btnClass, 'title' => 'View Full Request']);
if (!$req->isAnswered()) {
self::pageLink("/request/edit?id=$req->id", self::icon('edit'),
['class' => $btnClass, 'title' => 'Edit Request']);
}
if ($req->isSnoozed()) {
echo $restoreBtn($req->id, 'cancel-snooze', 'Cancel Snooze');
} elseif ($req->isPending()) {
echo $restoreBtn($req->id, 'show', 'Show Now');
}
echo '<p class="request-text mb-0">' . $req->currentText();
if ($req->isSnoozed() || $req->isPending() || $req->isAnswered()) { ?>
<br>
<small class=text-muted><em><?php
switch (true) {
case $req->isSnoozed():
echo 'Snooze expires '; self::relativeDate($req->snoozedUntil);
break;
case $req->isPending():
echo 'Request appears next '; self::relativeDate($req->showAfter);
break;
default:
echo 'Answered '; self::relativeDate($req->history[0]->asOf);
} ?>
</em></small><?php
} ?>
</div><?php
}
/**
* Render the given list of requests
*
* @param DocumentList<Request> $reqs The list of requests to render
* @throws Exception If date/time instances are not valid
*/
public static function requestList(DocumentList $reqs): void
{
echo '<div class=list-group>';
foreach ($reqs->items() as $req) self::requestItem($req);
echo '</div>';
}
}
enum FormatDistanceToken
{
case LessThanXMinutes;
case XMinutes;
case AboutXHours;
case XHours;
case XDays;
case AboutXWeeks;
case XWeeks;
case AboutXMonths;
case XMonths;
case AboutXYears;
case XYears;
case OverXYears;
case AlmostXYears;
}

175
src/lib/UI/Component.php Normal file
View File

@@ -0,0 +1,175 @@
<?php declare(strict_types=1);
namespace MyPrayerJournal\UI;
use BitBadger\PDODocument\{DocumentException, DocumentList};
use DateTimeImmutable;
use DateTimeZone;
use Exception;
use MyPrayerJournal\Domain\Request;
/**
* User interface building blocks
*/
class Component
{
/**
* Generate a material icon
*
* @param string $name The name of the material icon
* @return string The material icon wrapped in a `span` tag
*/
public static function icon(string $name): string {
return "<span class=material-icons>$name</span>";
}
/**
* Render the journal items for the current user
*
* @throws DocumentException If any is encountered
*/
public static function journal(): void
{
Layout::bareHead();
$reqs = Request::forJournal();
if ($reqs->hasItems()) { ?>
<section id=journalItems class="row row-cols-1 row-cols-md-2 row-cols-lg-3 row-cols-xl-4 g-3" hx-target=this
hx-swap=outerHTML aria-label="Prayer Requests"><?php
$spacer = '<span>&nbsp;</span>';
foreach ($reqs->items() as /** @var Request $req */ $req) { ?>
<div class=col>
<div class="card h-100">
<div class="card-header p-0 d-flex" role=toolbar>
<?=self::pageLink("/request/edit?id=$req->id", self::icon('edit'),
['class' => 'btn btn-secondary', 'title' => 'Edit Request'])?>
<?=$spacer?>
<button type=button class="btn btn-secondary" title="Add Notes" data-bs-toggle=modal
data-bs-target=#notesModal hx-get="/components/request/add-note?id=<?=$req->id?>"
hx-target=#notesBody hx-swap=innerHTML><?=self::icon('comment');?></button>
<?=$spacer?>
<button type=button class="btn btn-secondary" title="Snooze Request" data-bs-toggle=modal
data-bs-target=#snoozeModal hx-get="/components/request/snooze?id=<?=$req->id?>"
hx-target=#snoozeBody hx-swap=innerHTML><?=self::icon('schedule');?></button>
<div class=flex-grow-1></div>
<button type=button class="btn btn-success w-25" hx-patch="/request/prayed?id=<?=$req->id?>"
title="Mark as Prayed"><?=self::icon('done');?></button>
</div>
<div class=card-body>
<p class=request-text><?=htmlentities($req->currentText());?>
</div>
<div class="card-footer text-end text-muted px-1 py-0">
<em><?php
$lastPrayed = $req->lastPrayed();
echo 'last ' . (is_null($lastPrayed) ? 'activity': 'prayed') . ' '
. self::relativeDate($lastPrayed ?? $req->history[0]->asOf); ?>
</em>
</div>
</div>
</div><?php
} ?>
</section><?php
} else {
Component::noResults('No Active Requests', '/request/edit?id=new', 'Add a Request', <<<'TEXT'
You have no requests to be shown; see the &ldquo;Active&rdquo; link above for snoozed or deferred
requests, and the &ldquo;Answered&rdquo; link for answered requests
TEXT);
}
Layout::bareFoot();
}
/**
* Create a card when there are no results found
*/
public static function noResults(string $heading, string $link, string $buttonText, string $text): void
{ ?>
<div class=card>
<h5 class=card-header><?=$heading?></h5>
<div class="card-body text-center">
<p class=card-text><?=$text?></p>
<?=self::pageLink($link, $buttonText, ['class' => 'btn btn-primary'])?>
</div>
</div><?php
}
/**
* Create a link to a page within myPrayerJournal
*
* @param string $href The URL for the link
* @param string $text The text for the link
* @param array $attrs Any additional attributes that should be placed on the `a` tag
*/
public static function pageLink(string $href, string $text, array $attrs = []): string
{
$extraAttrs = array_reduce(array_keys($attrs),
fn($acc, $key) => $acc . sprintf(' %s="%s"', $key, $attrs[$key]), '');
return sprintf('<a href="%s" hx-get="%s" hx-target=#top hx-swap=innerHTML hx-push-url=true%s>%s</a>', $href,
$href, $extraAttrs, $text);
}
/**
* Create a relative date with a tooltip with the actual date/time
*
* @return string The HTML for the relative date
* @throws Exception If the date/time cannot be parsed
*/
public static function relativeDate(string $date): string
{
$parsed = new DateTimeImmutable($date);
$inZone = $parsed->setTimezone(new DateTimeZone($_REQUEST['time_zone']));
return sprintf('<span title="%s">%s</span>', date_format($inZone, 'l, F j, Y \a\t g:ia T'),
RelativeDate::between('now', $parsed));
}
/**
* Render a request list item
*
* @param Request $req The request on which the list items should be based
* @throws Exception If date/time values cannot be parsed
*/
public static function requestItem(Request $req): void
{
$btnClass = "btn btn-light mx-2";
$restoreBtn = fn(string $id, string $link, string $title) => sprintf(
'<button class="%s" hx-patch="/request/%s?id=%s" title="%s" hx-target=#req-%s hx-swap=outerHTML>%s</button>',
$btnClass, $link, $id, htmlspecialchars($title), $id, self::icon('restore')); ?>
<div class="list-group-item px-0 d-flex flex-row align-items-start" id=req-<?=$req->id?>><?php
echo self::pageLink("/request/full?id=$req->id", self::icon('description'),
['class' => $btnClass, 'title' => 'View Full Request']);
if (!$req->isAnswered()) {
echo self::pageLink("/request/edit?id=$req->id", self::icon('edit'),
['class' => $btnClass, 'title' => 'Edit Request']);
}
if ($req->isSnoozed()) {
echo $restoreBtn($req->id, 'cancel-snooze', 'Cancel Snooze');
} elseif ($req->isPending()) {
echo $restoreBtn($req->id, 'show', 'Show Now');
}
echo '<p class="request-text mb-0">' . $req->currentText();
if ($req->isSnoozed() || $req->isPending() || $req->isAnswered()) { ?>
<br>
<small class=text-muted><em><?php
if ($req->isSnoozed()) {
echo 'Snooze expires ' . self::relativeDate($req->snoozedUntil);
} elseif ($req->isPending()) {
echo 'Request appears next ' . self::relativeDate($req->showAfter);
} else {
echo 'Answered ' . self::relativeDate($req->history[0]->asOf);
} ?>
</em></small><?php
} ?>
</div><?php
}
/**
* Render the given list of requests
*
* @param DocumentList<Request> $reqs The list of requests to render
* @throws Exception If date/time instances are not valid
*/
public static function requestList(DocumentList $reqs): void
{
echo '<div class=list-group>';
foreach ($reqs->items() as $req) self::requestItem($req);
echo '</div>';
}
}

View File

@@ -1,9 +1,10 @@
<?php declare(strict_types=1);
namespace MyPrayerJournal;
namespace MyPrayerJournal\UI;
use BitBadger\PDODocument\Custom;
use BitBadger\PDODocument\Mapper\ExistsMapper;
use MyPrayerJournal\Table;
class Layout
{
@@ -67,8 +68,7 @@ class Layout
str_starts_with($_SERVER['PHP_SELF'], $url) => ['class' => 'is-active-route'],
default => []
};
echo '<li class=nav-item>';
UI::pageLink($url, $text, $classAttr);
echo '<li class=nav-item>' . Component::pageLink($url, $text, $classAttr);
}
/**
@@ -85,9 +85,10 @@ class Layout
SQL, [':userId' => $_SESSION['user_id']], new ExistsMapper())
: false; ?>
<nav class="navbar navbar-dark" role="navigation">
<div class=container-fluid><?php
UI::pageLink('/', '<span class=m>my</span><span class=p>Prayer</span><span class=j>Journal</span>',
['class' => 'navbar-brand']); ?>
<div class=container-fluid>
<?=Component::pageLink('/',
'<span class=m>my</span><span class=p>Prayer</span><span class=j>Journal</span>',
['class' => 'navbar-brand'])?>
<ul class="navbar-nav me-auto d-flex flex-row"><?php
if (key_exists('user_id', $_SESSION)) {
self::navLink('/journal', 'Journal');
@@ -128,11 +129,9 @@ class Layout
<footer class=container-fluid>
<p class="text-muted text-end">
myPrayerJournal <?=self::displayVersion();?><br>
<em><small><?php
UI::pageLink('/legal/privacy-policy', 'Privacy Policy');
echo ' &bull; ';
UI::pageLink('/legal/terms-of-service', 'Terms of Service');
echo ' &bull; '; ?>
<em><small>
<?=Component::pageLink('/legal/privacy-policy', 'Privacy Policy')?> &bull;
<?=Component::pageLink('/legal/terms-of-service', 'Terms of Service')?> &bull;
<a href=https://git.bitbadger.solutions/bit-badger/myPrayerJournal target=_blank
rel=noopener>Developed</a> and hosted by
<a href=https://bitbadger.solutions target=_blank rel=noopener>Bit Badger Solutions</a>

View File

@@ -0,0 +1,66 @@
<?php declare(strict_types=1);
namespace MyPrayerJournal\UI;
use DateTimeImmutable;
use Exception;
/**
* A type of relative date, along with the formatting strings
*/
enum RelativeDate: string
{
case LessThanXMinutes = 'less than a minute|less than %d minutes';
case XMinutes = 'a minute|%d minutes';
case AboutXHours = 'about an hour|about %d hours';
case XHours = 'an hour|%d hours';
case XDays = 'a day|%d days';
case AboutXWeeks = 'about a week|about %d weeks';
case XWeeks = 'a week|%d weeks';
case AboutXMonths = 'about a month|about %d months';
case XMonths = 'a month|%d months';
case AboutXYears = 'about a year|about %d years';
case XYears = 'a year|%d years';
case OverXYears = 'over a year|over %d years';
case AlmostXYears = 'almost a year|almost %d years';
// Many thanks to date-fns (https://date-fns.org) for this logic
/**
* Format the distance between two dates
*
* @param string|DateTimeImmutable $from The starting date/time
* @param string|DateTimeImmutable $to The ending date/time
* @return string The distance between two dates
* @throws Exception If date/time objects cannot be created
*/
public static function between(string|DateTimeImmutable $from, string|DateTimeImmutable $to): string
{
$aDay = 1_440.0;
$almost2Days = 2_520.0;
$aMonth = 43_200.0;
$twoMonths = 86_400.0;
$dtFrom = is_string($from) ? new DateTimeImmutable($from) : $from;
$dtTo = is_string($to) ? new DateTimeImmutable($to) : $to;
$minutes = abs($dtFrom->getTimestamp() - $dtTo->getTimestamp()) / 60;
$months = round($minutes / $aMonth);
$years = round($months / 12);
[$type, $number] = match (true) {
$minutes < 1.0 => [RelativeDate::LessThanXMinutes, 1],
$minutes < 45.0 => [RelativeDate::XMinutes, round($minutes)],
$minutes < 90.0 => [RelativeDate::AboutXHours, 1],
$minutes < $aDay => [RelativeDate::AboutXHours, round($minutes / 60)],
$minutes < $almost2Days => [RelativeDate::XDays, 1],
$minutes < $aMonth => [RelativeDate::XDays, round($minutes / $aDay)],
$minutes < $twoMonths => [RelativeDate::AboutXMonths, round($minutes / $aMonth)],
$months < 12 => [RelativeDate::XMonths, round($minutes / $aMonth)],
$months % 12 < 3 => [RelativeDate::AboutXYears, $years],
$months % 12 < 9 => [RelativeDate::OverXYears, $years],
default => [RelativeDate::AlmostXYears, $years]
};
[$singular, $plural] = explode('|', $type->value);
$value = $number == 1 ? $singular : sprintf($plural, $number);
return $dtFrom > $dtTo ? "$value ago" : "in $value";
}
}