diff --git a/src/lib/History.php b/src/lib/History.php index 067aaf7..d555e96 100644 --- a/src/lib/History.php +++ b/src/lib/History.php @@ -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; } } diff --git a/src/lib/Layout.php b/src/lib/Layout.php index a25c38b..a0e90bc 100644 --- a/src/lib/Layout.php +++ b/src/lib/Layout.php @@ -20,6 +20,16 @@ class Layout echo ''; } + /** + * 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 "$title « myPrayerJournal"; } else { echo << + $title « myPrayerJournal '; + if (!self::isHtmx()) echo '
'; + self::navBar(); + echo '
'; + } + + /** + * Generate the end of the page for a full or partial page result + */ + public static function pageFoot(): void + { + echo '
'; + if (!self::isHtmx()) { + echo '
'; + self::htmlFoot(); + } + echo ''; + } } diff --git a/src/lib/Note.php b/src/lib/Note.php index 7a8eff4..4b2bbc8 100644 --- a/src/lib/Note.php +++ b/src/lib/Note.php @@ -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 * diff --git a/src/lib/Recurrence.php b/src/lib/Recurrence.php index 8635019..cb883fd 100644 --- a/src/lib/Recurrence.php +++ b/src/lib/Recurrence.php @@ -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; } } diff --git a/src/lib/Request.php b/src/lib/Request.php index f3d0c73..61c03e6 100644 --- a/src/lib/Request.php +++ b/src/lib/Request.php @@ -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 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(<<>'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 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 The user's answered requests + * @throws DocumentException If any is encountered + */ + public static function answered(): DocumentList + { + return self::forUser(false); + } } diff --git a/src/lib/UI.php b/src/lib/UI.php index f2a5bf2..45df9c3 100644 --- a/src/lib/UI.php +++ b/src/lib/UI.php @@ -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 "$name"; } + /** + * 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()) { ?> +
 '; + foreach ($reqs->items() as /** @var Request $req */ $req) { ?> +
+
+ +
+

currentText());?> +

+ +
+
+
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" ] */ ?> +
items() as /** @var Request $req */ $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 '

' . $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 [] +// ] +?> +

+
'GET') not_found(); require '../../start.php'; Auth::requireUser(false); -Layout::bareHead(); -$reqs = Request::forJournal(); -if ($reqs->hasItems()) { ?> -
 '; - foreach ($reqs->items() as /** @var Request $req */ $req) { ?> -
-
- -
-

currentText());?> -

- -
-
-
+require '../start.php'; +if ($_SERVER['REQUEST_METHOD'] <> 'GET') not_found(); + +Layout::pageHead('Documentation'); ?>

Documentation

@@ -96,4 +99,4 @@ page_head('Documentation'); ?> and strengthen your prayer life.
+require '../start.php'; +if ($_SERVER['REQUEST_METHOD'] <> 'GET') not_found(); + +Layout::pageHead('Welcome'); ?>

 

myPrayerJournal is a place where individuals can record their prayer requests, record that they prayed for them, @@ -12,4 +15,4 @@ page_head('Welcome'); ?> On” link above, and log on with either a Microsoft or Google account. You can also learn more about the site at the “Docs” link, also above.

'GET') not_found(); Auth::requireUser(); $user = Auth::user(); $name = $user['given_name'] ?? 'Your'; -page_head('Welcome'); ?> +Layout::pageHead('Journal'); ?>

Prayer Journal

+require '../../start.php'; +if ($_SERVER['REQUEST_METHOD'] <> 'GET') not_found(); + +Layout::pageHead('Privacy Policy'); ?>

Privacy Policy

as of May 21st, 2018
@@ -66,4 +69,4 @@ page_head('Privacy Policy'); ?>
'GET') not_found(); -page_head('Terms of Service'); ?> +Layout::pageHead('Terms of Service'); ?>

Terms of Service

as of May 21st, 2018
@@ -55,4 +56,4 @@ page_head('Terms of Service'); ?> You may also wish to review our to learn how we handle your data.
'GET') not_found(); + +Auth::requireUser(); + +$isNew = $_GET['id'] == 'new'; + +$req = match ($isNew) { + true => new Request('new'), + false => Request::byId($_GET['id']) +}; +if (!$req) not_found(); + +$cancelLink = match (true) { + str_ends_with($_SERVER['HTTP_REFERER'] ?? '', 'active.php') => '/requests/active', + str_ends_with($_SERVER['HTTP_REFERER'] ?? '', 'snoozed.php') => '/requests/snoozed', + default => '/journal' +}; +$action = $_GET['id'] == 'new' ? 'Add' : 'Edit'; + +Layout::pageHead("$action Prayer Request");?> +
+

Prayer Request

+
=/request/save hx-target=#top hx-push-url=true> + id?>> + > +
+ + +

+
+
+
+ value?> + checked> + +
+
+ value?>> + +
+
+ value?>> + +
+
+
+
+

Recurrence   After prayer, request reappears… +

+
+ value?> + onclick="mpj.edit.toggleRecurrence(event)"recurrence->period == RecurrencePeriod::Immediate) echo ' checked'; ?>> + +
+
+ recurrence->period <> RecurrencePeriod::Immediate) echo ' checked'; ?>> + +
+
+ recurrence->interval ?? 0?> style="width:6rem;"recurrence->period == RecurrencePeriod::Immediate) echo ' disabled'; ?>> + +
+
+ + +
+
+
+
+
+ 'btn btn-secondary ms-2']); ?> +
+
+
'GET') not_found(); + +Auth::requireUser(); + +$req = Request::byId($_GET['id']); +if (!$req) not_found(); + +$answered = $req->isAnswered() ? new DateTimeImmutable($req->history[0]->asOf) : null; +$prayed = sizeof(array_filter($req->history, fn(History $hist) => $hist->action == RequestAction::Prayed)); +$daysOpen = + (($answered ?? new DateTimeImmutable('now'))->getTimestamp() + - (new DateTimeImmutable(end($req->history)->asOf))->getTimestamp()) / 86400; + +$logs = array_merge( + array_map(fn(Note $note) => [new DateTimeImmutable($note->asOf), 'Notes', $note->text], $req->notes), + array_map(fn(History $hist) => [new DateTimeImmutable($hist->asOf), $hist->action->value, $hist->text ?? ''], + $req->history)); +usort($logs, fn($a, $b) => $a[0] > $b[0] ? -1 : 1); +if ($req->isAnswered()) array_shift($logs); + +Layout::pageHead('Full Request');?> +
+
+
Full Prayer Request
+
+
+ Answered format('F j, Y')?> (history[0]->asOf); ?>) + • + Prayed times • Open days +
+

currentText())?> +

+
    +
  • +

      format('F j, Y')?> '') echo '

    ' . htmlentities($log[2]); + } ?> +

+
+
'PATCH') not_found(); + +Auth::requireUser(false); + +$req = Request::byId($_GET['id']); +if (!$req) not_found(); + +$now = new DateTimeImmutable('now'); + +array_unshift($req->history, new History($now->format('c'), RequestAction::Prayed)); +$patch = ['history' => $req->history]; + +if ($req->recurrence->period <> RecurrencePeriod::Immediate) { + $patch['showAfter'] = $now->add($req->recurrence->interval())->format('c'); +} +Patch::byId(Table::REQUEST, $req->id, $patch); + +UI::journal(); diff --git a/src/public/request/save.php b/src/public/request/save.php new file mode 100644 index 0000000..8031bdd --- /dev/null +++ b/src/public/request/save.php @@ -0,0 +1,51 @@ + 'POST' && $_SERVER['REQUEST_METHOD'] <> 'PATCH') not_found(); + +Auth::requireUser(false); + +$now = new DateTimeImmutable('now'); +$recurrence = new Recurrence(RecurrencePeriod::from($_POST['recurType'] ?? $_PATCH['recurType'])); +if ($recurrence->period <> RecurrencePeriod::Immediate) { + $recurrence->interval = (int)($_POST['recurCount'] ?? $_PATCH['recurCount']); +} + +switch ($_SERVER['REQUEST_METHOD']) { + case 'POST': + Document::insert(Table::REQUEST, new Request( + enteredOn: $now->format('c'), + userId: $_SESSION['user_id'], + recurrence: $recurrence, + history: [new History($now->format('c'), RequestAction::Created, $_POST['requestText'])])); + //Messages.pushSuccess ctx "Added prayer request" "/journal" + header('Location: /journal'); + http_response_code(303); + exit; + + case 'PATCH': + $req = Request::byId($_PATCH['requestId']); + if (!$req) not_found(); + $patch = []; + // update recurrence if changed + if ($recurrence != $req->recurrence) { + $patch['recurrence'] = $recurrence; + if ($recurrence->period == RecurrencePeriod::Immediate) { + RemoveFields::byId(Table::REQUEST, $req->id, ['showAfter']); + } + } + // append history + $upd8Text = trim($_PATCH['requestText']); + $text = $upd8Text == '' || $upd8Text == $req->currentText() ? null : $upd8Text; + array_unshift($req->history, new History($now->format('c'), RequestAction::from($_PATCH['status']), $text)); + $patch['history'] = $req->history; + Patch::byId(Table::REQUEST, $req->id, $patch); + //Messages.pushSuccess ctx "Prayer request updated successfully" nextUrl + // TODO: make redirect that filters out non-local URLs + header('Location: ' . $_PATCH['returnTo']); + http_response_code(303); + exit; +} diff --git a/src/public/requests/active.php b/src/public/requests/active.php new file mode 100644 index 0000000..3a13b29 --- /dev/null +++ b/src/public/requests/active.php @@ -0,0 +1,22 @@ + 'GET') not_found(); + +Auth::requireUser(); + +$reqs = Request::active(); + +Layout::pageHead('Active Requests'); ?> +
+

Active Requests

hasItems()) { + UI::requestList($reqs); + } else { + UI::noResults('No Active Requests', '/journal', 'Return to your journal', + 'Your prayer journal has no active requests'); + } ?> +
'GET') not_found(); + +Auth::requireUser(); + +$reqs = Request::answered(); + +Layout::pageHead('Answered Requests'); ?> +
+

Answered Requests

hasItems()) { + UI::requestList($reqs); + } else { + UI::noResults('No Answered Requests', '/journal', 'Return to your journal', <<<'TEXT' + Your prayer journal has no answered requests; once you have marked one as “Answered”, it will + appear here + TEXT); + } ?> +
'GET') not_found(); require '../../start.php'; Auth::logOn(); diff --git a/src/start.php b/src/start.php index 70d9753..d84afc5 100644 --- a/src/start.php +++ b/src/start.php @@ -14,9 +14,9 @@ const MPJ_VERSION = '4.0.0-alpha1'; if (php_sapi_name() != 'cli') { session_start(); - $auth0_session = Auth::client()->getCredentials(); - if (!is_null($auth0_session)) { - $_SESSION['user_id'] = $auth0_session->user['sub']; + $auth0_user = Auth::user(); + if (!is_null($auth0_user)) { + $_SESSION['user_id'] = $auth0_user['sub']; } } @@ -24,31 +24,14 @@ Configuration::$pdoDSN = 'sqlite:' . implode(DIRECTORY_SEPARATOR, [__DIR__, 'dat Configuration::$mode = Mode::SQLite; Definition::ensureTable(Table::REQUEST); +$_PATCH = []; +if ($_SERVER['REQUEST_METHOD'] ?? '' == 'PATCH') parse_str(file_get_contents('php://input'), $_PATCH); + /** - * Is this an htmx request? - * - * @return bool True if this is an htmx request, false if not + * Return a 404 and exit */ -function is_htmx(): bool +function not_found(): never { - return key_exists('HTTP_HX_REQUEST', $_SERVER) && !key_exists('HTTP_HX_HISTORY_RESTORE_REQUEST', $_SERVER); -} - -function page_head(string $title): void -{ - Layout::htmlHead($title); - echo ''; - if (!is_htmx()) echo '
'; - Layout::navBar(); - echo '
'; -} - -function page_foot(): void -{ - echo '
'; - if (!is_htmx()) { - echo '
'; - Layout::htmlFoot(); - } - echo ''; + http_response_code(404); + die('Not found'); }