First cut of Add / Edit / Active / Answered pages

- Centralized UI between UI and Layout classes
This commit is contained in:
2024-06-22 16:58:33 -04:00
parent 0b7fa77247
commit b759c3494e
20 changed files with 528 additions and 96 deletions

View File

@@ -19,7 +19,7 @@ class History implements JsonSerializable
public function jsonSerialize(): mixed
{
$values = ['asOf' => $this->asOf, 'action' => $this->action->value];
if (!is_null($this->text)) $values['text'] = $this->text;
if (isset($this->text)) $values['text'] = $this->text;
return $values;
}
}

View File

@@ -20,6 +20,16 @@ class Layout
echo '</body></html>';
}
/**
* Is this an htmx request?
*
* @return bool True if this is an htmx request, false if not
*/
private static function isHtmx(): bool
{
return key_exists('HTTP_HX_REQUEST', $_SERVER) && !key_exists('HTTP_HX_HISTORY_RESTORE_REQUEST', $_SERVER);
}
/**
* Create the `DOCTYPE` declaration, `html`, and `head` tags for the page
*
@@ -27,7 +37,7 @@ class Layout
*/
public static function htmlHead(string $title): void
{
if (is_htmx()) {
if (self::isHtmx()) {
echo "<!DOCTYPE html><html lang=en><head lang=en><title>$title &#xab; myPrayerJournal</title></head>";
} else {
echo <<<HEAD
@@ -36,6 +46,7 @@ class Layout
<head>
<meta name=viewport content="width=device-width, initial-scale=1">
<meta name=description content="Online prayer journal - free w/Google or Microsoft account">
<meta name=htmx-config content='{"historyCacheSize":0}'>
<title>$title &#xab; myPrayerJournal</title>
<link href=https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css rel=stylesheet
integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN"
@@ -130,4 +141,30 @@ class Layout
</footer><?php
}
/**
* Generate the heading for a full or partial page result
*
* @param string $title The title of the page
*/
public static function pageHead(string $title): void
{
self::htmlHead($title);
echo '<body>';
if (!self::isHtmx()) echo '<section id=top aria-label="Top navigation">';
self::navBar();
echo '<main role=main>';
}
/**
* Generate the end of the page for a full or partial page result
*/
public static function pageFoot(): void
{
echo '</main>';
if (!self::isHtmx()) {
echo '</section>';
self::htmlFoot();
}
echo '</body></html>';
}
}

View File

@@ -14,7 +14,7 @@ class Note
* @param string $text The text of the note
*/
public function __construct(public string $asOf, public string $text) { }
// AFU2SCY5X2BNVRXP6W47D369
/**
* Retrieve notes for a given request
*

View File

@@ -2,6 +2,7 @@
namespace MyPrayerJournal;
use DateInterval;
use JsonSerializable;
/**
@@ -15,10 +16,26 @@ class Recurrence implements JsonSerializable
*/
public function __construct(public RecurrencePeriod $period, public ?int $interval = null) { }
/**
* Get the date/time interval for this recurrence
*
* @return DateInterval The interval matching the recurrence
*/
public function interval(): DateInterval
{
$period = match ($this->period) {
RecurrencePeriod::Immediate => 'T0S',
RecurrencePeriod::Hours => "T{$this->interval}H",
RecurrencePeriod::Days => "{$this->interval}D",
RecurrencePeriod::Weeks => ($this->interval * 7) . 'D'
};
return new DateInterval("P$period");
}
public function jsonSerialize(): mixed
{
$values = ['period' => $this->period->value];
if (!is_null($this->interval)) $values['interval'] = $this->interval;
if (isset($this->interval)) $values['interval'] = $this->interval;
return $values;
}
}

View File

@@ -56,6 +56,16 @@ class Request implements JsonSerializable
return null;
}
/**
* Has this request been answered?
*
* @return bool True if the request is answered, false if not
*/
public function isAnswered(): bool
{
return $this->history[0]->action == RequestAction::Answered;
}
public function jsonSerialize(): mixed
{
$values = [
@@ -66,8 +76,8 @@ class Request implements JsonSerializable
'history' => $this->history,
'notes' => $this->notes
];
if (!is_null($this->snoozedUntil)) $values['snoozedUntil'] = $this->snoozedUntil;
if (!is_null($this->showAfter)) $values['showAfter'] = $this->showAfter;
if (isset($this->snoozedUntil)) $values['snoozedUntil'] = $this->snoozedUntil;
if (isset($this->showAfter)) $values['showAfter'] = $this->showAfter;
return $values;
}
@@ -107,4 +117,53 @@ class Request implements JsonSerializable
ORDER BY coalesce(last_prayed, data->>'snoozedUntil', data->>'showAfter', data->>'$.history[0].asOf')
SQL, [':userId' => $_SESSION['user_id']], new DocumentMapper(self::class));
}
/**
* Get either the user's active or answered requests
*
* @param bool $active True to retrieve active requests, false to retrieve answered requests
* @return DocumentList<Request> The requests matching the criteria
* @throws DocumentException If any is encountered
*/
private static function forUser(bool $active = true): DocumentList
{
$table = Table::REQUEST;
$op = $active ? '<>' : '=';
$order = $active
? "coalesce(data->>'snoozedUntil', data->>'showAfter', last_prayed, data->>'$.history[0].asOf')"
: "data->>'$.history[0].asOf' DESC";
return Custom::list(<<<SQL
SELECT data, (
SELECT h.value->>'asOf' as_of
FROM $table i LEFT JOIN json_each(i.data, '$.history') h
WHERE r.data->>'id' = i.data->>'id' AND h.value->>'action' = 'Prayed'
LIMIT 1) last_prayed
FROM $table r
WHERE data->>'userId' = :userId
AND data->>'$.history[0].action' $op 'Answered'
ORDER BY $order
SQL, [':userId' => $_SESSION['user_id']], new DocumentMapper(self::class));
}
/**
* Get a list of active requests for a user
*
* @return DocumentList<Request> The user's active requests
* @throws DocumentException If any is encountered
*/
public static function active(): DocumentList
{
return self::forUser();
}
/**
* Get a list of answered requests for a user
*
* @return DocumentList<Request> The user's answered requests
* @throws DocumentException If any is encountered
*/
public static function answered(): DocumentList
{
return self::forUser(false);
}
}

View File

@@ -2,6 +2,8 @@
namespace MyPrayerJournal;
use BitBadger\PDODocument\DocumentException;
use BitBadger\PDODocument\DocumentList;
use DateTimeImmutable;
use DateTimeZone;
@@ -20,6 +22,60 @@ class UI
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-notes?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
*/
@@ -76,4 +132,42 @@ class UI
}
}
public static function requestList(DocumentList $reqs): void
{
$btnClass = "btn btn-light mx-2";
/// Create a request within the list
/* let reqListItem now tz req =
let isFuture instant = defaultArg (instant |> Option.map (fun it -> it > now)) false
let reqId = RequestId.toString req.RequestId
let isSnoozed = isFuture req.SnoozedUntil
let isPending = (not isSnoozed) && isFuture req.ShowAfter
let restoreBtn (link : string) title =
button [ btnClass; _hxPatch $"/request/{reqId}/{link}"; _title title ] [ icon "restore" ] */ ?>
<div class=list-group><?php
foreach ($reqs->items() as /** @var Request $req */ $req) { ?>
<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 isSnoozed then restoreBtn "cancel-snooze" "Cancel Snooze"
// elif isPending then restoreBtn "show" "Show Now"
echo '<p class="request-text mb-0">' . $req->currentText();
// if isSnoozed || isPending || isAnswered then
// br []
// small [ _class "text-muted" ] [
// if isSnoozed then [ str "Snooze expires "; relativeDate req.SnoozedUntil.Value now tz ]
// elif isPending then [ str "Request appears next "; relativeDate req.ShowAfter.Value now tz ]
// else (* isAnswered *) [ str "Answered "; relativeDate req.AsOf now tz ]
// |> em []
// ]
?>
</div><?php
} ?>
</div><?php
}
}