Compare commits

..

No commits in common. "version4" and "4.0.0-alpha1" have entirely different histories.

46 changed files with 512 additions and 909 deletions

3
.gitignore vendored
View File

@ -5,6 +5,3 @@
# PHP ignore files
src/vendor
src/.env
# databases
src/data/*.db

View File

@ -1,15 +1,15 @@
{
"name": "bit-badger/my-prayer-journal",
"minimum-stability": "beta",
"require": {
"php": ">=8.4",
"php": ">=8.2",
"ext-pdo": "*",
"ext-sqlite3": "*",
"auth0/auth0-php": "^8.11",
"bit-badger/pdo-document": "^2",
"bit-badger/pdo-document": "^1",
"guzzlehttp/guzzle": "^7.8",
"guzzlehttp/psr7": "^2.6",
"http-interop/http-factory-guzzle": "^1.2",
"square/pjson": "^0.5",
"auth0/auth0-php": "^8.11",
"vlucas/phpdotenv": "^5.6"
},
"autoload": {

554
src/composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -10,13 +10,14 @@ require 'start.php';
echo 'Retrieving v3 requests...' . PHP_EOL;
Configuration::resetPDO();
Configuration::useDSN('pgsql:host=localhost;user=mpj;password=devpassword;dbname=mpj');
Configuration::$pdoDSN = 'pgsql:host=localhost;user=mpj;password=devpassword;dbname=mpj';
$reqs = Custom::array('SELECT data FROM mpj.request', [], new ArrayMapper());
echo 'Found ' . sizeof($reqs) . ' requests; migrating to v4...' . PHP_EOL;
Configuration::resetPDO();
Configuration::useDSN('sqlite:./data/mpj.db');
Configuration::$mode = Mode::SQLite;
Configuration::$pdoDSN = 'sqlite:./data/mpj.db';
Configuration::$autoId = AutoId::RandomString;
Configuration::$idStringLength = 12;
@ -25,7 +26,7 @@ Definition::ensureTable(Table::REQUEST);
/** Convert dates to the same format */
function convertDate(string $date): string
{
return new DateTimeImmutable($date)->format('c');
return (new DateTimeImmutable($date))->format('c');
}
foreach ($reqs as $reqJson) {

View File

@ -1,10 +1,4 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
<?php declare(strict_types=1);
namespace MyPrayerJournal;

View File

@ -1,53 +1,25 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
<?php declare(strict_types=1);
namespace MyPrayerJournal\Domain;
use BitBadger\InspiredByFSharp\Option;
use Square\Pjson\{Json, JsonSerialize};
use JsonSerializable;
/**
* A record of an action taken on a request
*/
class History
class History implements JsonSerializable
{
use JsonSerialize;
/** @var string The date/time this entry was made */
#[Json]
public string $asOf = '';
/** @var RequestAction The action taken for this history entry */
#[Json]
public RequestAction $action;
/** @var string|null The text for this history entry (optional) */
#[Json('text', omit_empty: true)]
private ?string $dbText {
get => $this->text->unwrap();
set { $this->text = Option::of($value); }
}
/** @var Option<string> The text for this history entry */
public Option $text {
get => $this->text ?? Option::None();
set { $this->text = $value; }
}
/**
* @param string $asOf The date/time this entry was made
* @param RequestAction $action The action taken for this history entry
* @param string|null $text The text for this history entry (optional)
*/
public function __construct(string $asOf, RequestAction $action, ?string $text = null)
public function __construct(public string $asOf, public RequestAction $action, public ?string $text = null) { }
public function jsonSerialize(): mixed
{
$this->asOf = $asOf;
$this->action = $action;
$this->dbText = $text;
$values = ['asOf' => $this->asOf, 'action' => $this->action->value];
if (isset($this->text)) $values['text'] = $this->text;
return $values;
}
}

View File

@ -1,29 +1,19 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
<?php declare(strict_types=1);
namespace MyPrayerJournal\Domain;
use BitBadger\InspiredByFSharp\Option;
use BitBadger\PDODocument\DocumentException;
use Square\Pjson\{Json, JsonSerialize};
/**
* A note entered on a prayer request
*/
class Note
{
use JsonSerialize;
/**
* @param string $asOf The date/time this note was recorded
* @param string $text The text of the note
*/
public function __construct(#[Json] public string $asOf, #[Json] public string $text) { }
public function __construct(public string $asOf, public string $text) { }
/**
* Retrieve notes for a given request
@ -34,6 +24,7 @@ class Note
*/
public static function byRequestId(string $id): array
{
return Request::byId($id)->map(fn(Request $it) => Option::Some($it->notes))->getOrDefault([]);
$req = Request::byId($id);
return $req->isDefined() ? $req->get()->notes : [];
}
}

View File

@ -1,53 +1,20 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
<?php declare(strict_types=1);
namespace MyPrayerJournal\Domain;
use BitBadger\InspiredByFSharp\Option;
use DateInterval;
use Square\Pjson\{Json, JsonSerialize};
use JsonSerializable;
/**
* The recurrence for a prayer request
*/
class Recurrence
class Recurrence implements JsonSerializable
{
use JsonSerialize;
/** @var RecurrencePeriod The recurrence period */
#[Json]
public RecurrencePeriod $period {
get => $this->period ?? RecurrencePeriod::Immediate;
set { $this->period = $value; }
}
/** @var int|null How many of the periods will pass before the request is visible again */
#[Json('interval', omit_empty: true)]
private ?int $dbInterval {
get => $this->interval->unwrap();
set { $this->interval = Option::of($value); }
}
/** @var Option<int> How many of the periods will pass before the request is visible again */
public Option $interval {
get => $this->interval ?? Option::None();
set { $this->interval = $value; }
}
/**
* @param RecurrencePeriod $period The recurrence period
* @param int|null $interval How many of the periods will pass before the request is visible again
*/
public function __construct(RecurrencePeriod $period, ?int $interval = null)
{
$this->period = $period;
$this->dbInterval = $interval;
}
public function __construct(public RecurrencePeriod $period, public ?int $interval = null) { }
/**
* Get the date/time interval for this recurrence
@ -58,10 +25,17 @@ class Recurrence
{
$period = match ($this->period) {
RecurrencePeriod::Immediate => 'T0S',
RecurrencePeriod::Hours => "T{$this->interval->value}H",
RecurrencePeriod::Days => "{$this->interval->value}D",
RecurrencePeriod::Weeks => ($this->interval->value * 7) . 'D'
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 (isset($this->interval)) $values['interval'] = $this->interval;
return $values;
}
}

View File

@ -1,10 +1,4 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
<?php declare(strict_types=1);
namespace MyPrayerJournal\Domain;

View File

@ -1,94 +1,77 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
<?php declare(strict_types=1);
namespace MyPrayerJournal\Domain;
use BitBadger\InspiredByFSharp\Option;
use BitBadger\PDODocument\{Custom, DocumentException, DocumentList, Find};
use BitBadger\PDODocument\Mapper\DocumentMapper;
use DateTimeImmutable;
use Exception;
use JsonSerializable;
use MyPrayerJournal\Table;
use Square\Pjson\{Json, JsonSerialize};
use PhpOption\{None, Option};
/**
* A prayer request
*/
class Request
class Request implements JsonSerializable
{
use JsonSerialize;
/**
* @param string $id The ID for the request
* @param string $enteredOn The date/time this request was originally entered
* @param string $userId The ID of the user to whom this request belongs
* @param string|null $snoozedUntil The date/time the snooze expires for this request (null = not snoozed)
* @param string|null $showAfter The date/time the current recurrence period is over (null = immediate)
* @param Recurrence $recurrence The recurrence for this request
* @param History[] $history The history of this request
* @param Note[] $notes Notes regarding this request
* @throws Exception If the ID generation fails
*/
public function __construct(public string $id = '', public string $enteredOn = '', public string $userId = '',
public ?string $snoozedUntil = null, public ?string $showAfter = null,
public Recurrence $recurrence = new Recurrence(RecurrencePeriod::Immediate),
public array $history = [], public array $notes = []) { }
/** @var string The ID for the request */
#[Json]
public string $id = '';
/** @var string The date/time this request was originally entered */
#[Json]
public string $enteredOn = '';
/** @var string The ID of the user to whom this request belongs */
#[Json]
public string $userId = '';
/** @var string|null The date/time the snooze expires for this request (null = not snoozed) */
#[Json('snoozedUntil', omit_empty: true)]
private ?string $dbSnoozedUntil {
get => $this->snoozedUntil->unwrap();
set { $this->snoozedUntil = Option::of($value); }
/**
* Get the current text for this request
*
* @return string The most recent text for the request
*/
public function currentText(): string
{
foreach ($this->history as $hist) if (isset($hist->text)) return $hist->text;
return '';
}
/** @var Option<string> The date/time the snooze expires for this request (None = not snoozed) */
public Option $snoozedUntil {
get => $this->snoozedUntil ?? Option::None();
set { $this->snoozedUntil = $value; }
/**
* Get the date/time this request was last marked as prayed
*
* @return string|null The date/time this request was last marked as prayed
*/
public function lastPrayed(): ?string
{
foreach ($this->history as $hist) if ($hist->action == RequestAction::Prayed) return $hist->asOf;
return null;
}
/** @var string|null The date/time the current recurrence period is over (null = immediate) */
#[Json(omit_empty: true)]
public ?string $showAfter = null;
/** @var Recurrence The recurrence for this request */
#[Json]
public Recurrence $recurrence {
get => $this->recurrence ?? new Recurrence(RecurrencePeriod::Immediate);
set => $this->recurrence = $value;
/**
* 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;
}
/** @var History[] The history of this request */
#[Json(type: History::class)]
public array $history = [];
/** @param Note[] $notes Notes regarding this request */
#[Json(type: Note::class)]
public array $notes = [];
/** The current text for this request */
public string $currentText {
get => Option::of(array_find($this->history, fn(History $it) => $it->text->isSome))
->map(fn(History $it) => $it->text->value)
->getOrDefault('');
}
/** @var Option<string> The date/time this request was last marked as prayed */
public Option $lastPrayed {
get => Option::of(array_find($this->history, fn(History $it) => $it->action === RequestAction::Prayed))
->map(fn(History $it) => $it->asOf);
}
/** Has this request been answered? */
public bool $isAnswered {
get => $this->history[0]->action === RequestAction::Answered;
}
/** Is this request currently snoozed? */
public bool $isSnoozed {
get => $this->snoozedUntil->isSome
&& new DateTimeImmutable($this->snoozedUntil->value) > new DateTimeImmutable('now');
/**
* Is this request currently snoozed?
*
* @return bool True if the request is snoozed, false if not
* @throws Exception If the snoozed until date/time is not valid
*/
public function isSnoozed(): bool
{
return isset($this->snoozedUntil) && new DateTimeImmutable($this->snoozedUntil) > new DateTimeImmutable('now');
}
/**
@ -97,12 +80,28 @@ class Request
* @return bool True if the request is pending, false if not
* @throws Exception If the snoozed or show-after date/times are not valid
*/
public bool $isPending {
get => !$this->isSnoozed
public function isPending(): bool
{
return !$this->isSnoozed()
&& isset($this->showAfter)
&& new DateTimeImmutable($this->showAfter) > new DateTimeImmutable('now');
}
public function jsonSerialize(): mixed
{
$values = [
'id' => $this->id,
'enteredOn' => $this->enteredOn,
'userId' => $this->userId,
'recurrence' => $this->recurrence,
'history' => $this->history,
'notes' => $this->notes
];
if (isset($this->snoozedUntil)) $values['snoozedUntil'] = $this->snoozedUntil;
if (isset($this->showAfter)) $values['showAfter'] = $this->showAfter;
return $values;
}
/**
* Find a request by its ID
*
@ -112,8 +111,8 @@ class Request
*/
public static function byId(string $id): Option
{
return Find::byId(Table::REQUEST, $id, self::class)
->map(fn(Request $it) => $it->userId === $_SESSION['user_id'] ? $it : null);
$req = Find::byId(Table::REQUEST, $id, self::class);
return ($req->getOrElse(new Request('x'))->userId == $_SESSION['user_id']) ? $req : None::create();
}
/**
@ -188,7 +187,7 @@ class Request
*/
public static function answered(): DocumentList
{
return self::forUser(active: false);
return self::forUser(false);
}
/**

View File

@ -1,10 +1,4 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
<?php declare(strict_types=1);
namespace MyPrayerJournal\Domain;

View File

@ -1,10 +1,4 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
<?php declare(strict_types=1);
namespace MyPrayerJournal;
@ -14,5 +8,5 @@ namespace MyPrayerJournal;
class Table
{
/** @var string The prayer request table used by myPrayerJournal */
const string REQUEST = 'request';
const REQUEST = 'request';
}

View File

@ -1,10 +1,4 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
<?php declare(strict_types=1);
namespace MyPrayerJournal\UI;
@ -38,13 +32,11 @@ class Component
{
Layout::bareHead();
$reqs = Request::forJournal();
if ($reqs->hasItems) { ?>
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) {
$lastPrayed = $req->lastPrayed;
$lastActivity = $lastPrayed->getOrDefault($req->history[0]->asOf); ?>
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>
@ -63,11 +55,13 @@ class Component
title="Mark as Prayed"><?=self::icon('done');?></button>
</div>
<div class=card-body>
<p class=request-text><?=htmlentities($req->currentText);?>
<p class=request-text><?=htmlentities($req->currentText());?>
</div>
<div class="card-footer text-end text-muted px-1 py-0">
<em>last <?=$lastPrayed->map(fn() => 'prayed')->getOrDefault('activity')?>
<?=self::relativeDate($lastPrayed->getOrDefault($req->history[0]->asOf))?>
<em><?php
$lastPrayed = $req->lastPrayed();
echo 'last ' . (is_null($lastPrayed) ? 'activity': 'prayed') . ' '
. self::relativeDate($lastPrayed ?? $req->history[0]->asOf); ?>
</em>
</div>
</div>
@ -121,9 +115,9 @@ class Component
public static function relativeDate(string $date): string
{
$parsed = new DateTimeImmutable($date);
$iso = $parsed->format('c');
$title = $parsed->setTimezone(new DateTimeZone($_REQUEST['time_zone']))->format('l, F j, Y \a\t g:ia T');
return "<relative-date-time title=\"$title\" interval=10000>$iso</relative-date-time>";
$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));
}
/**
@ -141,24 +135,26 @@ class Component
<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) {
if (!$req->isAnswered()) {
echo self::pageLink("/request/edit?id=$req->id", self::icon('edit'),
['class' => $btnClass, 'title' => 'Edit Request']);
}
if ($req->isSnoozed) {
if ($req->isSnoozed()) {
echo $restoreBtn($req->id, 'cancel-snooze', 'Cancel Snooze');
} elseif ($req->isPending) {
} elseif ($req->isPending()) {
echo $restoreBtn($req->id, 'show', 'Show Now');
}
echo '<p class="request-text mb-0">' . htmlentities($req->currentText);
if ($req->isSnoozed || $req->isPending || $req->isAnswered) { ?>
echo '<p class="request-text mb-0">' . $req->currentText();
if ($req->isSnoozed() || $req->isPending() || $req->isAnswered()) { ?>
<br>
<small class=text-muted><em><?php
echo match (true) {
$req->isSnoozed => 'Snooze expires ' . self::relativeDate($req->snoozedUntil->value),
$req->isPending => 'Request appears next ' . self::relativeDate($req->showAfter),
default => 'Answered ' . self::relativeDate($req->history[0]->asOf)
};?>
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
@ -173,7 +169,7 @@ class Component
public static function requestList(DocumentList $reqs): void
{
echo '<div class=list-group>';
$reqs->iter(self::requestItem(...));
foreach ($reqs->items() as $req) self::requestItem($req);
echo '</div>';
}
}

View File

@ -1,10 +1,4 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
<?php declare(strict_types=1);
namespace MyPrayerJournal\UI;
@ -62,7 +56,7 @@ class Layout
integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN"
crossorigin=anonymous>
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel=stylesheet>
<link href=/_/style.css rel=stylesheet>
<link href=/style/style.css rel=stylesheet>
</head>
HEAD;
}
@ -142,19 +136,18 @@ class Layout
rel=noopener>Developed</a> and hosted by
<a href=https://bitbadger.solutions target=_blank rel=noopener>Bit Badger Solutions</a>
</small></em>
<script src=https://unpkg.com/htmx.org@2.0.4 crossorigin=anonymous
integrity="sha384-HGfztofotfshcF7+8n44JQL2oJmowVChPTg48S+jvZoztPfvwD79OC/LTtG6dMp+"></script>
<script>if (!htmx) document.write('<script src=\"/_/htmx.min.js\"><\/script>')</script>
<script src=https://unpkg.com/htmx.org@2.0.0 crossorigin=anonymous
integrity="sha384-wS5l5IKJBvK6sPTKa2WZ1js3d947pvWXbPJ1OmWfEuxLgeHcEbjUUA5i9V5ZkpCw"></script>
<script>if (!htmx) document.write('<script src=\"/script/htmx.min.js\"><\/script>')</script>
<script async src=https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js
integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL"
crossorigin=anonymous></script>
<script>
setTimeout(function () {
if (!bootstrap) document.write('<script src=\"/_/bootstrap.bundle.min.js\"><\/script>')
if (!bootstrap) document.write('<script src=\"/script/bootstrap.bundle.min.js\"><\/script>')
}, 2000)
</script>
<script src=/_/mpj.js></script>
<script src=/_/relative-date-time.js defer></script>
<script src=/script/mpj.js></script>
</footer><?php
}

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";
}
}

File diff suppressed because one or more lines are too long

View File

@ -1,121 +0,0 @@
"use strict"
/**
* Relative Date/Time Custom HTML Element
*
* This creates an element that will take the existing date/time and replace it with words (ex. "about a year ago",
* "in 3 hours"). It will update based on the interval provided in the tag.
*
* ```html
* <relative-date-time interval=5000>2024-08-22T12:34:56+00:00</relative-date-time>
* ```
*/
class RelativeDateTime extends HTMLElement {
static #LessThanXMinutes = Symbol()
static #XMinutes = Symbol()
static #AboutXHours = Symbol()
static #XDays = Symbol()
static #AboutXMonths = Symbol()
static #XMonths = Symbol()
static #AboutXYears = Symbol()
static #OverXYears = Symbol()
static #AlmostXYears = Symbol()
static #messages = new Map([
[RelativeDateTime.#LessThanXMinutes, ['less than a minute', 'less than %d minutes']],
[RelativeDateTime.#XMinutes, ['a minute', '%d minutes']],
[RelativeDateTime.#AboutXHours, ['about an hour', 'about %d hours']],
[RelativeDateTime.#XDays, ['a day', '%d days']],
[RelativeDateTime.#AboutXMonths, ['about a month', 'about %d months']],
[RelativeDateTime.#XMonths, ['a month', '%d months']],
[RelativeDateTime.#AboutXYears, ['about a year', 'about %d years']],
[RelativeDateTime.#OverXYears, ['over a year', 'over %d years']],
[RelativeDateTime.#AlmostXYears, ['almost a year', 'almost %d years']],
])
static #aDay = 1440.0
static #almost2Days = 2520.0
static #aMonth = 43200.0
static #twoMonths = 86400.0
/**
* The date, parsed from the `innerHTML` of the element
* @type Date
*/
#jsDate
/**
* The ID of the interval (set via `setTimeout`, passed to `clearTimeout`)
* @type ?number
*/
#timeOut = null
constructor() {
super();
}
#update() {
const now = new Date()
const minutes = Math.abs((this.#jsDate.getTime() - now.getTime()) / 60 / 1000);
const months = Math.round(minutes / RelativeDateTime.#aMonth);
const years = Math.floor(months / 12);
/** @type symbol */
let typ
/** @type number */
let nbr
if (minutes < 1.0) {
typ = RelativeDateTime.#LessThanXMinutes
nbr = 1
} else if (minutes < 45.0) {
typ = RelativeDateTime.#XMinutes
nbr = Math.round(minutes)
} else if (minutes < 90.0) {
typ = RelativeDateTime.#AboutXHours
nbr = 1
} else if (minutes < RelativeDateTime.#aDay) {
typ = RelativeDateTime.#AboutXHours
nbr = Math.round(minutes / 60)
} else if (minutes < RelativeDateTime.#almost2Days) {
typ = RelativeDateTime.#XDays
nbr = 1
} else if (minutes < RelativeDateTime.#aMonth) {
typ = RelativeDateTime.#XDays
nbr = Math.round(minutes / RelativeDateTime.#aDay)
} else if (minutes < RelativeDateTime.#twoMonths) {
typ = RelativeDateTime.#AboutXMonths
nbr = Math.round(minutes / RelativeDateTime.#aMonth)
} else if (months < 12) {
typ = RelativeDateTime.#XMonths
nbr = Math.round(minutes / RelativeDateTime.#aMonth)
} else if (months % 12 < 3) {
typ = RelativeDateTime.#AboutXYears
nbr = years
} else if (months % 12 < 9) {
typ = RelativeDateTime.#OverXYears
nbr = years
} else {
typ = RelativeDateTime.#AlmostXYears
nbr = years + 1
}
const tmpl = RelativeDateTime.#messages.get(typ)
const message = nbr === 1 ? tmpl[0] : tmpl[1].replace("%d", nbr.toString())
this.innerText = this.#jsDate < now ? `${message} ago` : `in ${message}`
}
connectedCallback() {
this.#jsDate = new Date(this.innerText)
this.#update()
this.#timeOut = setInterval(() => this.#update(), parseInt(this.getAttribute("interval")))
}
disconnectedCallback() {
if (this.#timeOut) clearInterval(this.#timeOut)
}
}
customElements.define("relative-date-time", RelativeDateTime)

View File

@ -1,10 +1,4 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
<?php declare(strict_types=1);
use MyPrayerJournal\Auth;
use MyPrayerJournal\UI\Component;

View File

@ -1,10 +1,4 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
<?php declare(strict_types=1);
use MyPrayerJournal\UI\Layout;

View File

@ -1,10 +1,4 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
<?php declare(strict_types=1);
use MyPrayerJournal\UI\{Component, Layout};

View File

@ -1,10 +1,4 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
<?php declare(strict_types=1);
use MyPrayerJournal\UI\Layout;
@ -16,7 +10,7 @@ Layout::bareHead(); ?>
<form hx-patch="/request/snooze?id=<?=$req->id?>" hx-target=#journalItems hx-swap=outerHTML>
<div class="form-floating pb-3">
<input type=date id=until name=until class=form-control
min="<?=new DateTimeImmutable('now')->format('Y-m-d')?>" required>
min="<?=(new DateTimeImmutable('now'))->format('Y-m-d')?>" required>
<label for=until>Until</label>
</div>
<p class="text-end mb-0"><button type=submit class="btn btn-primary">Snooze</button>

View File

@ -1,10 +1,4 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
<?php declare(strict_types=1);
use MyPrayerJournal\UI\Layout;

View File

@ -1,10 +1,4 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
<?php declare(strict_types=1);
use MyPrayerJournal\UI\Component;use MyPrayerJournal\UI\Layout;

View File

@ -1,10 +1,4 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
<?php declare(strict_types=1);
use MyPrayerJournal\Auth;
use MyPrayerJournal\UI\{Component, Layout};

View File

@ -1,10 +1,4 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
<?php declare(strict_types=1);
use MyPrayerJournal\UI\Layout;

View File

@ -1,10 +1,4 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
<?php declare(strict_types=1);
use MyPrayerJournal\UI\{Component, Layout};

View File

@ -1,12 +1,5 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
<?php declare(strict_types=1);
declare(strict_types=1);
use BitBadger\InspiredByFSharp\Option;
use BitBadger\PDODocument\RemoveFields;
use MyPrayerJournal\Table;
use MyPrayerJournal\UI\{Component, Layout};
@ -16,7 +9,7 @@ require '../../start.php';
$req = validate_request($_GET['id'], ['PATCH'], false);
RemoveFields::byId(Table::REQUEST, $req->id, ['snoozedUntil']);
$req->snoozedUntil = Option::None();
$req->snoozedUntil = null;
// TODO: message
Layout::bareHead();

View File

@ -1,10 +1,4 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
<?php declare(strict_types=1);
use MyPrayerJournal\Auth;
use MyPrayerJournal\Domain\{RecurrencePeriod, Request, RequestAction};
@ -37,7 +31,7 @@ Layout::pageHead("$action Prayer Request");?>
<input type=hidden name=returnTo value=<?=$cancelLink?>>
<div class="form-floating pb-3">
<textarea id=requestText name=requestText class=form-control style="min-height: 8rem;"
placeholder="Enter the text of the request" autofocus required><?=$req->currentText?></textarea>
placeholder="Enter the text of the request" autofocus required><?=$req->currentText()?></textarea>
<label for=requestText>Prayer Request</label>
</div><br><?php
if (!$isNew) { ?>
@ -78,23 +72,23 @@ Layout::pageHead("$action Prayer Request");?>
</div>
<div class="form-floating mx-2">
<input type=number class=form-control id=recurCount name=recurCount placeholder=0 required
value=<?=$req->recurrence->interval->getOrDefault(0)?> style="width:6rem;"<?php
if ($req->recurrence->period === RecurrencePeriod::Immediate) echo ' disabled'; ?>>
value=<?=$req->recurrence->interval ?? 0?> style="width:6rem;"<?php
if ($req->recurrence->period == RecurrencePeriod::Immediate) echo ' disabled'; ?>>
<label for=recurCount>Count</label>
</div>
<div class="form-floating mx-2">
<select class=form-control id=recurInterval name=recurInterval style="width:6rem;" required<?php
if ($req->recurrence->period === RecurrencePeriod::Immediate) echo ' disabled'; ?>>
if ($req->recurrence->period == RecurrencePeriod::Immediate) echo ' disabled'; ?>>
<option value=<?=RecurrencePeriod::Hours->value?><?php
if ($req->recurrence->period === RecurrencePeriod::Hours) echo ' selected'; ?>>
if ($req->recurrence->period == RecurrencePeriod::Hours) echo ' selected'; ?>>
hours
</option>
<option value=<?=RecurrencePeriod::Days->value?><?php
if ($req->recurrence->period === RecurrencePeriod::Days) echo ' selected'; ?>>
if ($req->recurrence->period == RecurrencePeriod::Days) echo ' selected'; ?>>
days
</option>
<option value=<?=RecurrencePeriod::Weeks->value?><?php
if ($req->recurrence->period === RecurrencePeriod::Weeks) echo ' selected'; ?>>
if ($req->recurrence->period == RecurrencePeriod::Weeks) echo ' selected'; ?>>
weeks
</option>
</select>

View File

@ -1,19 +1,13 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
<?php declare(strict_types=1);
use MyPrayerJournal\Domain\{History, Note, RequestAction};
use MyPrayerJournal\UI\{Component, Layout};
use MyPrayerJournal\UI\{Layout, RelativeDate};
require '../../start.php';
$req = validate_request($_GET['id'], ['GET']);
$answered = $req->isAnswered ? new DateTimeImmutable($req->history[0]->asOf) : null;
$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()
@ -21,10 +15,10 @@ $daysOpen =
$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->getOrDefault('')],
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);
if ($req->isAnswered()) array_shift($logs);
Layout::pageHead('Full Request');?>
<article class="container mt-3">
@ -34,11 +28,11 @@ Layout::pageHead('Full Request');?>
<h6 class="card-subtitle text-muted mb-2"><?php
if (!is_null($answered)) { ?>
Answered <?=$answered->format('F j, Y')?>
(<?=Component::relativeDate($req->history[0]->asOf)?>) &bull;<?php
(<?= RelativeDate::between('now', $req->history[0]->asOf);?>) &bull;<?php
} ?>
Prayed <?=number_format($prayed)?> times &bull; Open <?=number_format($daysOpen)?> days
</h6>
<p class=card-text><?=htmlentities($req->currentText)?>
<p class=card-text><?=htmlentities($req->currentText())?>
</div>
<ul class="list-group list-group-flush"><?php
foreach ($logs as $log) { ?>

View File

@ -1,20 +1,14 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
<?php declare(strict_types=1);
use BitBadger\PDODocument\Patch;
use MyPrayerJournal\Table;
use MyPrayerJournal\Domain\Note;
use MyPrayerJournal\Table;
require '../../start.php';
$req = validate_request($_GET['id'], ['POST'], false);
array_unshift($req->notes, new Note(new DateTimeImmutable('now')->format('c'), $_POST['notes']));
array_unshift($req->notes, new Note((new DateTimeImmutable('now'))->format('c'), $_POST['notes']));
Patch::byId(Table::REQUEST, $req->id, ['notes' => $req->notes]);
hide_modal('notes');

View File

@ -1,14 +1,8 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
<?php declare(strict_types=1);
use BitBadger\PDODocument\Patch;
use MyPrayerJournal\Table;
use MyPrayerJournal\Domain\{History, RecurrencePeriod, RequestAction};
use MyPrayerJournal\Table;
use MyPrayerJournal\UI\Component;
require '../../start.php';

View File

@ -1,12 +1,5 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
<?php declare(strict_types=1);
declare(strict_types=1);
use BitBadger\InspiredByFSharp\Option;
use BitBadger\PDODocument\{Document, Patch, RemoveFields};
use MyPrayerJournal\{Auth, Table};
use MyPrayerJournal\Domain\{History, Recurrence, RecurrencePeriod, Request, RequestAction};
@ -19,17 +12,16 @@ Auth::requireUser(false);
$now = new DateTimeImmutable('now');
$recurrence = new Recurrence(RecurrencePeriod::from($_POST['recurType'] ?? $_PATCH['recurType']));
if ($recurrence->period <> RecurrencePeriod::Immediate) {
$recurrence->interval = Option::of((int)($_POST['recurCount'] ?? $_PATCH['recurCount']));
$recurrence->interval = (int)($_POST['recurCount'] ?? $_PATCH['recurCount']);
}
switch ($_SERVER['REQUEST_METHOD']) {
case 'POST':
$req = new Request();
$req->enteredOn = $now->format('c');
$req->userId = $_SESSION['user_id'];
$req->recurrence = $recurrence;
$req->history = [new History($now->format('c'), RequestAction::Created, $_POST['requestText'])];
Document::insert(Table::REQUEST, $req);
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"
see_other('/journal');
@ -37,15 +29,15 @@ switch ($_SERVER['REQUEST_METHOD']) {
$req = Request::byId($_PATCH['requestId'])->getOrCall(not_found(...));
$patch = [];
// update recurrence if changed
if ($recurrence !== $req->recurrence) {
if ($recurrence != $req->recurrence) {
$patch['recurrence'] = $recurrence;
if ($recurrence->period === RecurrencePeriod::Immediate) {
if ($recurrence->period == RecurrencePeriod::Immediate) {
RemoveFields::byId(Table::REQUEST, $req->id, ['showAfter']);
}
}
// append history
$upd8Text = trim($_PATCH['requestText']);
$text = empty($upd8Text) || $upd8Text === $req->currentText ? null : $upd8Text;
$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);

View File

@ -1,10 +1,4 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
<?php declare(strict_types=1);
use BitBadger\PDODocument\RemoveFields;
use MyPrayerJournal\Table;

View File

@ -1,10 +1,4 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
<?php declare(strict_types=1);
use BitBadger\PDODocument\Patch;
use MyPrayerJournal\Table;

View File

@ -1,10 +1,4 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
<?php declare(strict_types=1);
use MyPrayerJournal\Auth;
use MyPrayerJournal\Domain\Request;
@ -20,7 +14,7 @@ $reqs = Request::active();
Layout::pageHead('Active Requests'); ?>
<article class="container mt-3">
<h2 class=pb-3>Active Requests</h2><?php
if ($reqs->hasItems) {
if ($reqs->hasItems()) {
Component::requestList($reqs);
} else {
Component::noResults('No Active Requests', '/journal', 'Return to your journal',

View File

@ -1,10 +1,4 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
<?php declare(strict_types=1);
use MyPrayerJournal\Auth;
use MyPrayerJournal\Domain\Request;
@ -20,7 +14,7 @@ $reqs = Request::answered();
Layout::pageHead('Answered Requests'); ?>
<article class="container mt-3">
<h2 class=pb-3>Answered Requests</h2><?php
if ($reqs->hasItems) {
if ($reqs->hasItems()) {
Component::requestList($reqs);
} else {
Component::noResults('No Answered Requests', '/journal', 'Return to your journal', <<<'TEXT'

View File

@ -1,10 +1,4 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
<?php declare(strict_types=1);
use MyPrayerJournal\Auth;
use MyPrayerJournal\Domain\Request;
@ -20,7 +14,7 @@ $reqs = Request::snoozed();
Layout::pageHead('Snoozed Requests'); ?>
<article class="container mt-3">
<h2 class=pb-3>Snoozed Requests</h2><?php
if ($reqs->hasItems) {
if ($reqs->hasItems()) {
Component::requestList($reqs);
} else {
Component::noResults('No Snoozed Requests', '/journal', 'Return to your journal',

1
src/public/script/htmx.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -1,7 +1,7 @@
"use strict"
/** myPrayerJournal script */
window.mpj = {
this.mpj = {
/**
* Show a message via toast
* @param {string} message The message to show

View File

@ -1,10 +1,4 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
<?php declare(strict_types=1);
use MyPrayerJournal\Auth;

View File

@ -0,0 +1,8 @@
<?php declare(strict_types=1);
use MyPrayerJournal\Auth;
require '../../start.php';
if ($_SERVER['REQUEST_METHOD'] <> 'GET') not_found();
Auth::logOn();

View File

@ -1,14 +0,0 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
use MyPrayerJournal\Auth;
require '../../../start.php';
if ($_SERVER['REQUEST_METHOD'] <> 'GET') not_found();
Auth::logOn();

View File

@ -1,10 +1,4 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
<?php declare(strict_types=1);
use MyPrayerJournal\Auth;

View File

@ -1,13 +1,7 @@
<?php
/**
* @author Daniel J. Summers <daniel@bitbadger.solutions>
* @license MIT
*/
declare(strict_types=1);
<?php declare(strict_types=1);
use Auth0\SDK\Exception\ConfigurationException;
use BitBadger\PDODocument\{AutoId, Configuration, Definition, DocumentException};
use BitBadger\PDODocument\{AutoId, Configuration, Definition, DocumentException, Mode};
use Dotenv\Dotenv;
use MyPrayerJournal\{Auth, Table};
use MyPrayerJournal\Domain\Request;
@ -15,7 +9,7 @@ use MyPrayerJournal\Domain\Request;
require __DIR__ . '/vendor/autoload.php';
/** The version of this application */
const MPJ_VERSION = '4.0.0-beta2';
const MPJ_VERSION = '4.0.0-alpha1';
(Dotenv::createImmutable(__DIR__))->load();
@ -30,7 +24,8 @@ if (php_sapi_name() != 'cli') {
$_REQUEST['time_zone'] = $_SERVER['HTTP_X_TIME_ZONE'] ?? 'Etc/UTC';
}
Configuration::useDSN('sqlite:' . implode(DIRECTORY_SEPARATOR, [__DIR__, 'data', 'mpj.db']));
Configuration::$pdoDSN = 'sqlite:' . implode(DIRECTORY_SEPARATOR, [__DIR__, 'data', 'mpj.db']);
Configuration::$mode = Mode::SQLite;
Configuration::$autoId = AutoId::RandomString;
Configuration::$idStringLength = 12;
@ -38,7 +33,7 @@ Definition::ensureTable(Table::REQUEST);
Definition::ensureFieldIndex(Table::REQUEST, 'user', ['userId']);
$_PATCH = [];
if ($_SERVER['REQUEST_METHOD'] ?? '' === 'PATCH') parse_str(file_get_contents('php://input'), $_PATCH);
if ($_SERVER['REQUEST_METHOD'] ?? '' == 'PATCH') parse_str(file_get_contents('php://input'), $_PATCH);
/**
* Return a 404 and exit
@ -78,11 +73,12 @@ function hide_modal(string $name): void
* @param array $methods The allowable HTTP methods
* @param bool $redirect Whether to redirect not-logged-on users (optional, defaults to true)
* @return Request The request (failures will not return)
* @throws ConfigurationException|DocumentException If any is encountered
* @throws ConfigurationException If any is encountered
* @throws DocumentException If any is encountered
*/
function validate_request(string $id, array $methods, bool $redirect = true): Request
{
if (empty(array_filter($methods, fn($it) => $_SERVER['REQUEST_METHOD'] === $it))) not_found();
if (sizeof(array_filter($methods, fn($it) => $_SERVER['REQUEST_METHOD'] == $it)) == 0) not_found();
Auth::requireUser($redirect);