Compare commits
No commits in common. "66d9c8cbd765f55b5b4d5f3975dbb704a471479d" and "0049248231175cc2af2d9ba8b4fa11f6c2b0732f" have entirely different histories.
66d9c8cbd7
...
0049248231
67 changed files with 147 additions and 4183 deletions
|
|
@ -69,9 +69,6 @@ intentionally unclaimed; the built-in health endpoint is `/up`.
|
||||||
relevant authorization guard instead of duplicating all business branches.
|
relevant authorization guard instead of duplicating all business branches.
|
||||||
- Place endpoint tests under `tests/Feature/<Area>/`, extend
|
- Place endpoint tests under `tests/Feature/<Area>/`, extend
|
||||||
`Tests\TestCase`, and use `RefreshDatabase` when database state is involved.
|
`Tests\TestCase`, and use `RefreshDatabase` when database state is involved.
|
||||||
- Do not keep tests that only assert an obsolete column or other removed
|
|
||||||
schema detail is absent. Such assertions may verify a removal while it is
|
|
||||||
being developed, but discard them afterward and retain behavioral coverage.
|
|
||||||
- Development and runtime use PostgreSQL through the local Unix socket.
|
- Development and runtime use PostgreSQL through the local Unix socket.
|
||||||
PHPUnit uses SQLite `:memory:` as configured in `phpunit.xml`, so feature
|
PHPUnit uses SQLite `:memory:` as configured in `phpunit.xml`, so feature
|
||||||
tests are self-contained and do not need the worktree stack.
|
tests are self-contained and do not need the worktree stack.
|
||||||
|
|
|
||||||
|
|
@ -57,9 +57,6 @@ backend and does not proxy the frontend.
|
||||||
- Keep application-wide resets and base styles in `src/styles/main.css`.
|
- Keep application-wide resets and base styles in `src/styles/main.css`.
|
||||||
Keep component and view styles scoped and match established Attainly visual
|
Keep component and view styles scoped and match established Attainly visual
|
||||||
patterns.
|
patterns.
|
||||||
- Display user-authored set level kinds verbatim. Do not capitalize, replace
|
|
||||||
separators, pluralize, or apply `text-transform` to a kind value; pluralize
|
|
||||||
surrounding interface words such as "assignment" instead.
|
|
||||||
- Inspect similar files before introducing a component, composable, store,
|
- Inspect similar files before introducing a component, composable, store,
|
||||||
route, or data-access pattern.
|
route, or data-access pattern.
|
||||||
|
|
||||||
|
|
|
||||||
10
ai/shared.md
10
ai/shared.md
|
|
@ -9,17 +9,9 @@ these rules.
|
||||||
- Attainly is in early development.
|
- Attainly is in early development.
|
||||||
- Attainly helps users break hierarchical goals into scheduled assignments,
|
- Attainly helps users break hierarchical goals into scheduled assignments,
|
||||||
complete daily work, and track progress toward a target date.
|
complete daily work, and track progress toward a target date.
|
||||||
- Sets define explicit ordered element levels. Every depth has one kind, and
|
- Schedules target whole sets. Users choose an element kind as the assignment
|
||||||
a kind can appear at only one depth within a set. Root elements use the
|
|
||||||
first level, and children use the level immediately after their parent.
|
|
||||||
- Schedules target whole sets. Users choose a set level as the assignment
|
|
||||||
granularity. To schedule a subset, create a separate named set containing
|
granularity. To schedule a subset, create a separate named set containing
|
||||||
that subset.
|
that subset.
|
||||||
- Schedules are immutable historical snapshots. Copy the set name and each
|
|
||||||
assignment's name, kind, and full path when creating a schedule.
|
|
||||||
- Persisted schedules must not reference their source set or selected level.
|
|
||||||
Persisted assignments must not reference their source elements. Later
|
|
||||||
source edits or deletions must not change an existing schedule.
|
|
||||||
- Schedule recalculation must preserve completed work while redistributing
|
- Schedule recalculation must preserve completed work while redistributing
|
||||||
unfinished assignments across the remaining dates.
|
unfinished assignments across the remaining dates.
|
||||||
- Planned features in `README.md` are future ideas, not authorized scope.
|
- Planned features in `README.md` are future ideas, not authorized scope.
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,14 @@
|
||||||
|
|
||||||
namespace App\Element;
|
namespace App\Element;
|
||||||
|
|
||||||
use App\Set\SetLevel;
|
use App\Set\Set;
|
||||||
|
|
||||||
final readonly class CreateElementDto
|
final readonly class CreateElementDto
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
|
public Set $set,
|
||||||
public string $name,
|
public string $name,
|
||||||
public SetLevel $level,
|
public string $kind,
|
||||||
public ?Element $parentElement,
|
public ?Element $parentElement,
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,14 @@
|
||||||
namespace App\Element;
|
namespace App\Element;
|
||||||
|
|
||||||
use App\Set\Set;
|
use App\Set\Set;
|
||||||
use App\Set\SetLevel;
|
|
||||||
|
|
||||||
final readonly class Element
|
final readonly class Element
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private int $id,
|
private int $id,
|
||||||
private string $name,
|
private string $name,
|
||||||
private SetLevel $level,
|
private string $kind,
|
||||||
|
private Set $set,
|
||||||
private ?Element $parentElement,
|
private ?Element $parentElement,
|
||||||
private int $position,
|
private int $position,
|
||||||
) {}
|
) {}
|
||||||
|
|
@ -27,17 +27,12 @@ final readonly class Element
|
||||||
|
|
||||||
public function getKind(): string
|
public function getKind(): string
|
||||||
{
|
{
|
||||||
return $this->level->getKind();
|
return $this->kind;
|
||||||
}
|
|
||||||
|
|
||||||
public function getLevel(): SetLevel
|
|
||||||
{
|
|
||||||
return $this->level;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getSet(): Set
|
public function getSet(): Set
|
||||||
{
|
{
|
||||||
return $this->level->getSet();
|
return $this->set;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getParentElement(): ?Element
|
public function getParentElement(): ?Element
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,9 @@ use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @property int $id
|
* @property int $id
|
||||||
* @property int $set_level_id
|
* @property int $set_id
|
||||||
* @property string $name
|
* @property string $name
|
||||||
|
* @property string $kind
|
||||||
* @property int|null $parent_element_id
|
* @property int|null $parent_element_id
|
||||||
* @property int $position
|
* @property int $position
|
||||||
*
|
*
|
||||||
|
|
@ -20,8 +21,9 @@ use Illuminate\Database\Eloquent\Model;
|
||||||
* @mixin \Eloquent
|
* @mixin \Eloquent
|
||||||
*/
|
*/
|
||||||
#[Fillable([
|
#[Fillable([
|
||||||
'set_level_id',
|
'set_id',
|
||||||
'name',
|
'name',
|
||||||
|
'kind',
|
||||||
'parent_element_id',
|
'parent_element_id',
|
||||||
'position',
|
'position',
|
||||||
])]
|
])]
|
||||||
|
|
@ -37,7 +39,7 @@ class ElementModel extends Model
|
||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'set_level_id' => 'integer',
|
'set_id' => 'integer',
|
||||||
'parent_element_id' => 'integer',
|
'parent_element_id' => 'integer',
|
||||||
'position' => 'integer',
|
'position' => 'integer',
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -3,27 +3,27 @@
|
||||||
namespace App\Element;
|
namespace App\Element;
|
||||||
|
|
||||||
use App\Set\Set;
|
use App\Set\Set;
|
||||||
use App\Set\SetLevel;
|
use App\Set\SetRepository;
|
||||||
use App\Set\SetLevelRepository;
|
|
||||||
use DomainException;
|
use DomainException;
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
|
|
||||||
class EloquentElementRepository implements ElementRepository
|
class EloquentElementRepository implements ElementRepository
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private SetLevelRepository $setLevelRepository,
|
private SetRepository $setRepository,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function create(CreateElementDto $dto): Element
|
public function create(CreateElementDto $dto): Element
|
||||||
{
|
{
|
||||||
$this->validateLevel($dto);
|
$this->validateParentSet($dto);
|
||||||
$position = $this->nextPosition(
|
$position = $this->nextPosition(
|
||||||
$dto->level,
|
$dto->set,
|
||||||
$dto->parentElement,
|
$dto->parentElement,
|
||||||
);
|
);
|
||||||
$model = ElementModel::create([
|
$model = ElementModel::create([
|
||||||
'set_level_id' => $dto->level->getId(),
|
'set_id' => $dto->set->getId(),
|
||||||
'name' => $dto->name,
|
'name' => $dto->name,
|
||||||
|
'kind' => $dto->kind,
|
||||||
'parent_element_id' => $dto->parentElement?->getId(),
|
'parent_element_id' => $dto->parentElement?->getId(),
|
||||||
'position' => $position,
|
'position' => $position,
|
||||||
]);
|
]);
|
||||||
|
|
@ -31,7 +31,8 @@ class EloquentElementRepository implements ElementRepository
|
||||||
return new Element(
|
return new Element(
|
||||||
id: $model->id,
|
id: $model->id,
|
||||||
name: $model->name,
|
name: $model->name,
|
||||||
level: $dto->level,
|
kind: $model->kind,
|
||||||
|
set: $dto->set,
|
||||||
parentElement: $dto->parentElement,
|
parentElement: $dto->parentElement,
|
||||||
position: $model->position,
|
position: $model->position,
|
||||||
);
|
);
|
||||||
|
|
@ -46,27 +47,14 @@ class EloquentElementRepository implements ElementRepository
|
||||||
|
|
||||||
public function findBySet(Set $set): array
|
public function findBySet(Set $set): array
|
||||||
{
|
{
|
||||||
$levelsById = [];
|
|
||||||
foreach ($this->setLevelRepository->findBySet($set) as $level) {
|
|
||||||
$levelsById[$level->getId()] = $level;
|
|
||||||
}
|
|
||||||
if ($levelsById === []) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$models = ElementModel::query()
|
$models = ElementModel::query()
|
||||||
->whereIn('set_level_id', array_keys($levelsById))
|
->where('set_id', $set->getId())
|
||||||
->orderBy('id')
|
->orderBy('id')
|
||||||
->get();
|
->get();
|
||||||
$elements = [];
|
$elements = [];
|
||||||
$elementsById = [];
|
$elementsById = [];
|
||||||
|
|
||||||
foreach ($models as $model) {
|
foreach ($models as $model) {
|
||||||
$level = $levelsById[$model->set_level_id] ?? null;
|
|
||||||
if ($level === null) {
|
|
||||||
throw new RuntimeException('element level not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
$parentElement = null;
|
$parentElement = null;
|
||||||
if ($model->parent_element_id !== null) {
|
if ($model->parent_element_id !== null) {
|
||||||
$parentElement = $elementsById[$model->parent_element_id]
|
$parentElement = $elementsById[$model->parent_element_id]
|
||||||
|
|
@ -78,7 +66,7 @@ class EloquentElementRepository implements ElementRepository
|
||||||
|
|
||||||
$element = $this->toDomainWithRelations(
|
$element = $this->toDomainWithRelations(
|
||||||
model: $model,
|
model: $model,
|
||||||
level: $level,
|
set: $set,
|
||||||
parentElement: $parentElement,
|
parentElement: $parentElement,
|
||||||
);
|
);
|
||||||
$elements[] = $element;
|
$elements[] = $element;
|
||||||
|
|
@ -90,14 +78,8 @@ class EloquentElementRepository implements ElementRepository
|
||||||
|
|
||||||
public function findTopLevelBySet(Set $set): array
|
public function findTopLevelBySet(Set $set): array
|
||||||
{
|
{
|
||||||
$levels = $this->setLevelRepository->findBySet($set);
|
|
||||||
if ($levels === []) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$topLevel = $levels[0];
|
|
||||||
$models = ElementModel::query()
|
$models = ElementModel::query()
|
||||||
->where('set_level_id', $topLevel->getId())
|
->where('set_id', $set->getId())
|
||||||
->whereNull('parent_element_id')
|
->whereNull('parent_element_id')
|
||||||
->orderBy('position')
|
->orderBy('position')
|
||||||
->orderBy('id')
|
->orderBy('id')
|
||||||
|
|
@ -107,7 +89,7 @@ class EloquentElementRepository implements ElementRepository
|
||||||
foreach ($models as $model) {
|
foreach ($models as $model) {
|
||||||
$elements[] = $this->toDomainWithRelations(
|
$elements[] = $this->toDomainWithRelations(
|
||||||
model: $model,
|
model: $model,
|
||||||
level: $topLevel,
|
set: $set,
|
||||||
parentElement: null,
|
parentElement: null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -118,6 +100,7 @@ class EloquentElementRepository implements ElementRepository
|
||||||
public function findByParentElement(Element $parentElement): array
|
public function findByParentElement(Element $parentElement): array
|
||||||
{
|
{
|
||||||
$models = ElementModel::query()
|
$models = ElementModel::query()
|
||||||
|
->where('set_id', $parentElement->getSet()->getId())
|
||||||
->where('parent_element_id', $parentElement->getId())
|
->where('parent_element_id', $parentElement->getId())
|
||||||
->orderBy('position')
|
->orderBy('position')
|
||||||
->orderBy('id')
|
->orderBy('id')
|
||||||
|
|
@ -125,10 +108,9 @@ class EloquentElementRepository implements ElementRepository
|
||||||
$elements = [];
|
$elements = [];
|
||||||
|
|
||||||
foreach ($models as $model) {
|
foreach ($models as $model) {
|
||||||
$level = $this->findLevel($model->set_level_id);
|
|
||||||
$elements[] = $this->toDomainWithRelations(
|
$elements[] = $this->toDomainWithRelations(
|
||||||
model: $model,
|
model: $model,
|
||||||
level: $level,
|
set: $parentElement->getSet(),
|
||||||
parentElement: $parentElement,
|
parentElement: $parentElement,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -139,44 +121,26 @@ class EloquentElementRepository implements ElementRepository
|
||||||
/**
|
/**
|
||||||
* @throws DomainException
|
* @throws DomainException
|
||||||
*/
|
*/
|
||||||
private function validateLevel(CreateElementDto $dto): void
|
private function validateParentSet(CreateElementDto $dto): void
|
||||||
{
|
{
|
||||||
$parentElement = $dto->parentElement;
|
$parentElement = $dto->parentElement;
|
||||||
if ($parentElement === null) {
|
if ($parentElement === null) {
|
||||||
if ($dto->level->getDepth() !== 0) {
|
|
||||||
throw new DomainException(
|
|
||||||
'root element must use the first set level',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if ($parentElement->getSet()->getId() !== $dto->set->getId()) {
|
||||||
$parentElement->getSet()->getId()
|
|
||||||
!== $dto->level->getSet()->getId()
|
|
||||||
) {
|
|
||||||
throw new DomainException(
|
throw new DomainException(
|
||||||
'parent element must belong to the same set',
|
'parent element must belong to the same set',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
|
||||||
$dto->level->getDepth()
|
|
||||||
!== $parentElement->getLevel()->getDepth() + 1
|
|
||||||
) {
|
|
||||||
throw new DomainException(
|
|
||||||
'child element must use the next set level',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function nextPosition(
|
private function nextPosition(
|
||||||
SetLevel $level,
|
Set $set,
|
||||||
?Element $parentElement,
|
?Element $parentElement,
|
||||||
): int {
|
): int {
|
||||||
$query = ElementModel::query()
|
$query = ElementModel::query()
|
||||||
->where('set_level_id', $level->getId());
|
->where('set_id', $set->getId());
|
||||||
if ($parentElement === null) {
|
if ($parentElement === null) {
|
||||||
$query->whereNull('parent_element_id');
|
$query->whereNull('parent_element_id');
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -193,7 +157,7 @@ class EloquentElementRepository implements ElementRepository
|
||||||
|
|
||||||
private function toDomain(ElementModel $model): Element
|
private function toDomain(ElementModel $model): Element
|
||||||
{
|
{
|
||||||
$level = $this->findLevel($model->set_level_id);
|
$set = $this->findSet($model->set_id);
|
||||||
|
|
||||||
$parentElement = null;
|
$parentElement = null;
|
||||||
if ($model->parent_element_id !== null) {
|
if ($model->parent_element_id !== null) {
|
||||||
|
|
@ -205,32 +169,33 @@ class EloquentElementRepository implements ElementRepository
|
||||||
|
|
||||||
return $this->toDomainWithRelations(
|
return $this->toDomainWithRelations(
|
||||||
model: $model,
|
model: $model,
|
||||||
level: $level,
|
set: $set,
|
||||||
parentElement: $parentElement,
|
parentElement: $parentElement,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function toDomainWithRelations(
|
private function toDomainWithRelations(
|
||||||
ElementModel $model,
|
ElementModel $model,
|
||||||
SetLevel $level,
|
Set $set,
|
||||||
?Element $parentElement,
|
?Element $parentElement,
|
||||||
): Element {
|
): Element {
|
||||||
return new Element(
|
return new Element(
|
||||||
id: $model->id,
|
id: $model->id,
|
||||||
name: $model->name,
|
name: $model->name,
|
||||||
level: $level,
|
kind: $model->kind,
|
||||||
|
set: $set,
|
||||||
parentElement: $parentElement,
|
parentElement: $parentElement,
|
||||||
position: $model->position,
|
position: $model->position,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function findLevel(int $id): SetLevel
|
private function findSet(int $id): Set
|
||||||
{
|
{
|
||||||
$level = $this->setLevelRepository->find($id);
|
$set = $this->setRepository->find($id);
|
||||||
if ($level !== null) {
|
if ($set !== null) {
|
||||||
return $level;
|
return $set;
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new RuntimeException('element level not found');
|
throw new RuntimeException('element set not found');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,175 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
|
||||||
|
|
||||||
use App\Exceptions\BadRequestException;
|
|
||||||
use App\Exceptions\NotFoundException;
|
|
||||||
use App\Schedule\Schedule;
|
|
||||||
use App\Schedule\ScheduleAssignment;
|
|
||||||
use App\Schedule\UseCases\CreateSchedule\CreateSchedule;
|
|
||||||
use App\Schedule\UseCases\CreateSchedule\CreateScheduleRequest;
|
|
||||||
use App\Schedule\UseCases\GetSchedule\GetSchedule;
|
|
||||||
use App\Schedule\UseCases\GetSchedule\GetScheduleRequest;
|
|
||||||
use App\Schedule\UseCases\ListSchedules\ListSchedules;
|
|
||||||
use App\Shared\Http\RequestInput;
|
|
||||||
use App\User\User;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
|
|
||||||
class ScheduleController extends Controller
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private CreateSchedule $createSchedule,
|
|
||||||
private ListSchedules $listSchedules,
|
|
||||||
private GetSchedule $getSchedule,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function store(Request $request): JsonResponse
|
|
||||||
{
|
|
||||||
$input = new RequestInput($request);
|
|
||||||
$user = $this->user($request);
|
|
||||||
|
|
||||||
try {
|
|
||||||
$schedule = $this->createSchedule->execute(
|
|
||||||
new CreateScheduleRequest(
|
|
||||||
user: $user,
|
|
||||||
setId: $input->integer('setId'),
|
|
||||||
levelId: $input->integer('levelId'),
|
|
||||||
startDate: $input->string('startDate'),
|
|
||||||
targetDate: $input->string('targetDate'),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} catch (BadRequestException $exception) {
|
|
||||||
return new JsonResponse(
|
|
||||||
['error' => $exception->getMessage()],
|
|
||||||
400,
|
|
||||||
);
|
|
||||||
} catch (NotFoundException $exception) {
|
|
||||||
return new JsonResponse(
|
|
||||||
['error' => $exception->getMessage()],
|
|
||||||
404,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new JsonResponse(
|
|
||||||
['schedule' => $this->detailPayload($schedule)],
|
|
||||||
201,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function index(Request $request): JsonResponse
|
|
||||||
{
|
|
||||||
$schedules = array_map(
|
|
||||||
function (Schedule $schedule): array {
|
|
||||||
return $this->summaryPayload($schedule);
|
|
||||||
},
|
|
||||||
$this->listSchedules->execute($this->user($request)),
|
|
||||||
);
|
|
||||||
|
|
||||||
return new JsonResponse(['schedules' => $schedules]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function show(Request $request, int $scheduleId): JsonResponse
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$schedule = $this->getSchedule->execute(
|
|
||||||
new GetScheduleRequest(
|
|
||||||
scheduleId: $scheduleId,
|
|
||||||
user: $this->user($request),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} catch (NotFoundException $exception) {
|
|
||||||
return new JsonResponse(
|
|
||||||
['error' => $exception->getMessage()],
|
|
||||||
404,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new JsonResponse([
|
|
||||||
'schedule' => $this->detailPayload($schedule),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
private function summaryPayload(Schedule $schedule): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'id' => $schedule->getId(),
|
|
||||||
'set' => [
|
|
||||||
'name' => $schedule->getSetName(),
|
|
||||||
],
|
|
||||||
'elementKind' => $schedule->getElementKind(),
|
|
||||||
'startDate' => $schedule->getStartDate()->format('Y-m-d'),
|
|
||||||
'targetDate' => $schedule->getTargetDate()->format('Y-m-d'),
|
|
||||||
'assignmentCount' => count($schedule->getAssignments()),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
private function detailPayload(Schedule $schedule): array
|
|
||||||
{
|
|
||||||
$payload = $this->summaryPayload($schedule);
|
|
||||||
$payload['days'] = $this->dayPayloads($schedule);
|
|
||||||
|
|
||||||
return $payload;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return list<array{date: string, assignments: list<mixed>}>
|
|
||||||
*/
|
|
||||||
private function dayPayloads(Schedule $schedule): array
|
|
||||||
{
|
|
||||||
$assignmentsByDate = [];
|
|
||||||
foreach ($schedule->getAssignments() as $assignment) {
|
|
||||||
$date = $assignment->getScheduledDate()->format('Y-m-d');
|
|
||||||
$assignmentsByDate[$date][] = $this->assignmentPayload(
|
|
||||||
$assignment,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$days = [];
|
|
||||||
$date = $schedule->getStartDate();
|
|
||||||
while ($date <= $schedule->getTargetDate()) {
|
|
||||||
$formattedDate = $date->format('Y-m-d');
|
|
||||||
$days[] = [
|
|
||||||
'date' => $formattedDate,
|
|
||||||
'assignments' => $assignmentsByDate[$formattedDate] ?? [],
|
|
||||||
];
|
|
||||||
$date = $date->modify('+1 day');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $days;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{id: int, element: array{
|
|
||||||
* name: string,
|
|
||||||
* kind: string,
|
|
||||||
* path: list<string>
|
|
||||||
* }}
|
|
||||||
*/
|
|
||||||
private function assignmentPayload(
|
|
||||||
ScheduleAssignment $assignment,
|
|
||||||
): array {
|
|
||||||
return [
|
|
||||||
'id' => $assignment->getId(),
|
|
||||||
'element' => [
|
|
||||||
'name' => $assignment->getName(),
|
|
||||||
'kind' => $assignment->getKind(),
|
|
||||||
'path' => $assignment->getPath(),
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function user(Request $request): User
|
|
||||||
{
|
|
||||||
/** @var User $user */
|
|
||||||
$user = $request->attributes->get('user');
|
|
||||||
|
|
||||||
return $user;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -7,7 +7,6 @@ use App\Set\Set;
|
||||||
use App\Set\UseCases\GetSetLayout\ElementLayoutNode;
|
use App\Set\UseCases\GetSetLayout\ElementLayoutNode;
|
||||||
use App\Set\UseCases\GetSetLayout\GetSetLayout;
|
use App\Set\UseCases\GetSetLayout\GetSetLayout;
|
||||||
use App\Set\UseCases\GetSetLayout\GetSetLayoutRequest;
|
use App\Set\UseCases\GetSetLayout\GetSetLayoutRequest;
|
||||||
use App\Set\UseCases\GetSetLayout\SetLayoutLevel;
|
|
||||||
use App\Set\UseCases\ListSets\ListSets;
|
use App\Set\UseCases\ListSets\ListSets;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
|
|
@ -50,19 +49,6 @@ class SetController extends Controller
|
||||||
'id' => $set->getId(),
|
'id' => $set->getId(),
|
||||||
'name' => $set->getName(),
|
'name' => $set->getName(),
|
||||||
],
|
],
|
||||||
'levels' => array_map(
|
|
||||||
function (SetLayoutLevel $layoutLevel): array {
|
|
||||||
$level = $layoutLevel->getLevel();
|
|
||||||
|
|
||||||
return [
|
|
||||||
'id' => $level->getId(),
|
|
||||||
'kind' => $level->getKind(),
|
|
||||||
'depth' => $level->getDepth(),
|
|
||||||
'elementCount' => $layoutLevel->getElementCount(),
|
|
||||||
];
|
|
||||||
},
|
|
||||||
$layout->getLevels(),
|
|
||||||
),
|
|
||||||
'elements' => array_map(
|
'elements' => array_map(
|
||||||
function (ElementLayoutNode $node): array {
|
function (ElementLayoutNode $node): array {
|
||||||
return $this->elementPayload($node);
|
return $this->elementPayload($node);
|
||||||
|
|
@ -77,7 +63,6 @@ class SetController extends Controller
|
||||||
* id: int,
|
* id: int,
|
||||||
* name: string,
|
* name: string,
|
||||||
* kind: string,
|
* kind: string,
|
||||||
* levelId: int,
|
|
||||||
* children: list<mixed>
|
* children: list<mixed>
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
|
|
@ -89,7 +74,6 @@ class SetController extends Controller
|
||||||
'id' => $element->getId(),
|
'id' => $element->getId(),
|
||||||
'name' => $element->getName(),
|
'name' => $element->getName(),
|
||||||
'kind' => $element->getKind(),
|
'kind' => $element->getKind(),
|
||||||
'levelId' => $element->getLevel()->getId(),
|
|
||||||
'children' => array_map(
|
'children' => array_map(
|
||||||
function (ElementLayoutNode $childNode): array {
|
function (ElementLayoutNode $childNode): array {
|
||||||
return $this->elementPayload($childNode);
|
return $this->elementPayload($childNode);
|
||||||
|
|
|
||||||
|
|
@ -19,11 +19,7 @@ use App\Email\LaravelEmailFactory;
|
||||||
use App\Element\ElementRepository;
|
use App\Element\ElementRepository;
|
||||||
use App\Element\EloquentElementRepository;
|
use App\Element\EloquentElementRepository;
|
||||||
use App\Set\EloquentSetRepository;
|
use App\Set\EloquentSetRepository;
|
||||||
use App\Set\EloquentSetLevelRepository;
|
|
||||||
use App\Set\SetRepository;
|
use App\Set\SetRepository;
|
||||||
use App\Set\SetLevelRepository;
|
|
||||||
use App\Schedule\EloquentScheduleRepository;
|
|
||||||
use App\Schedule\ScheduleRepository;
|
|
||||||
use App\User\EloquentUserRepository;
|
use App\User\EloquentUserRepository;
|
||||||
use App\User\UserRepository;
|
use App\User\UserRepository;
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
|
|
@ -57,18 +53,10 @@ class AppServiceProvider extends ServiceProvider
|
||||||
SetRepository::class,
|
SetRepository::class,
|
||||||
EloquentSetRepository::class,
|
EloquentSetRepository::class,
|
||||||
);
|
);
|
||||||
$this->app->bind(
|
|
||||||
SetLevelRepository::class,
|
|
||||||
EloquentSetLevelRepository::class,
|
|
||||||
);
|
|
||||||
$this->app->bind(
|
$this->app->bind(
|
||||||
ElementRepository::class,
|
ElementRepository::class,
|
||||||
EloquentElementRepository::class,
|
EloquentElementRepository::class,
|
||||||
);
|
);
|
||||||
$this->app->bind(
|
|
||||||
ScheduleRepository::class,
|
|
||||||
EloquentScheduleRepository::class,
|
|
||||||
);
|
|
||||||
$this->app->bind(PasswordHasher::class, BcryptPasswordHasher::class);
|
$this->app->bind(PasswordHasher::class, BcryptPasswordHasher::class);
|
||||||
$this->app->bind(TokenGenerator::class, RandomTokenGenerator::class);
|
$this->app->bind(TokenGenerator::class, RandomTokenGenerator::class);
|
||||||
$this->app->bind(Clock::class, SystemClock::class);
|
$this->app->bind(Clock::class, SystemClock::class);
|
||||||
|
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Schedule;
|
|
||||||
|
|
||||||
use DateTimeImmutable;
|
|
||||||
|
|
||||||
final readonly class CreateScheduleAssignmentDto
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @param list<string> $path
|
|
||||||
*/
|
|
||||||
public function __construct(
|
|
||||||
public string $name,
|
|
||||||
public string $kind,
|
|
||||||
public array $path,
|
|
||||||
public DateTimeImmutable $scheduledDate,
|
|
||||||
public int $position,
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Schedule;
|
|
||||||
|
|
||||||
use App\User\User;
|
|
||||||
use DateTimeImmutable;
|
|
||||||
|
|
||||||
final readonly class CreateScheduleDto
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @param list<CreateScheduleAssignmentDto> $assignments
|
|
||||||
*/
|
|
||||||
public function __construct(
|
|
||||||
public User $user,
|
|
||||||
public string $setName,
|
|
||||||
public string $elementKind,
|
|
||||||
public DateTimeImmutable $startDate,
|
|
||||||
public DateTimeImmutable $targetDate,
|
|
||||||
public array $assignments,
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
|
|
@ -1,99 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Schedule;
|
|
||||||
|
|
||||||
use App\User\User;
|
|
||||||
use DateTimeImmutable;
|
|
||||||
use DateTimeZone;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
|
|
||||||
class EloquentScheduleRepository implements ScheduleRepository
|
|
||||||
{
|
|
||||||
public function create(CreateScheduleDto $dto): Schedule
|
|
||||||
{
|
|
||||||
return DB::transaction(function () use ($dto): Schedule {
|
|
||||||
$model = ScheduleModel::create([
|
|
||||||
'user_id' => $dto->user->getId(),
|
|
||||||
'set_name' => $dto->setName,
|
|
||||||
'element_kind' => $dto->elementKind,
|
|
||||||
'start_date' => $dto->startDate->format('Y-m-d'),
|
|
||||||
'target_date' => $dto->targetDate->format('Y-m-d'),
|
|
||||||
]);
|
|
||||||
|
|
||||||
foreach ($dto->assignments as $assignmentDto) {
|
|
||||||
ScheduleAssignmentModel::create([
|
|
||||||
'schedule_id' => $model->id,
|
|
||||||
'element_name' => $assignmentDto->name,
|
|
||||||
'element_kind' => $assignmentDto->kind,
|
|
||||||
'element_path' => $assignmentDto->path,
|
|
||||||
'scheduled_date' => $assignmentDto->scheduledDate
|
|
||||||
->format('Y-m-d'),
|
|
||||||
'position' => $assignmentDto->position,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->toDomain($model, $dto->user);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public function findForUser(int $id, User $user): ?Schedule
|
|
||||||
{
|
|
||||||
$model = ScheduleModel::query()
|
|
||||||
->where('id', $id)
|
|
||||||
->where('user_id', $user->getId())
|
|
||||||
->first();
|
|
||||||
|
|
||||||
return $model === null ? null : $this->toDomain($model, $user);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function findAllForUser(User $user): array
|
|
||||||
{
|
|
||||||
$models = ScheduleModel::query()
|
|
||||||
->where('user_id', $user->getId())
|
|
||||||
->orderByDesc('id')
|
|
||||||
->get();
|
|
||||||
$schedules = [];
|
|
||||||
|
|
||||||
foreach ($models as $model) {
|
|
||||||
$schedules[] = $this->toDomain($model, $user);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $schedules;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function toDomain(ScheduleModel $model, User $user): Schedule
|
|
||||||
{
|
|
||||||
$assignmentModels = ScheduleAssignmentModel::query()
|
|
||||||
->where('schedule_id', $model->id)
|
|
||||||
->orderBy('position')
|
|
||||||
->orderBy('id')
|
|
||||||
->get();
|
|
||||||
$assignments = [];
|
|
||||||
|
|
||||||
foreach ($assignmentModels as $assignmentModel) {
|
|
||||||
$assignments[] = new ScheduleAssignment(
|
|
||||||
id: $assignmentModel->id,
|
|
||||||
name: $assignmentModel->element_name,
|
|
||||||
kind: $assignmentModel->element_kind,
|
|
||||||
path: $assignmentModel->element_path,
|
|
||||||
scheduledDate: $this->date($assignmentModel->scheduled_date),
|
|
||||||
position: $assignmentModel->position,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Schedule(
|
|
||||||
id: $model->id,
|
|
||||||
user: $user,
|
|
||||||
setName: $model->set_name,
|
|
||||||
elementKind: $model->element_kind,
|
|
||||||
startDate: $this->date($model->start_date),
|
|
||||||
targetDate: $this->date($model->target_date),
|
|
||||||
assignments: $assignments,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function date(string $value): DateTimeImmutable
|
|
||||||
{
|
|
||||||
return new DateTimeImmutable($value, new DateTimeZone('UTC'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Schedule;
|
|
||||||
|
|
||||||
use App\User\User;
|
|
||||||
use DateTimeImmutable;
|
|
||||||
|
|
||||||
final readonly class Schedule
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @param list<ScheduleAssignment> $assignments
|
|
||||||
*/
|
|
||||||
public function __construct(
|
|
||||||
private int $id,
|
|
||||||
private User $user,
|
|
||||||
private string $setName,
|
|
||||||
private string $elementKind,
|
|
||||||
private DateTimeImmutable $startDate,
|
|
||||||
private DateTimeImmutable $targetDate,
|
|
||||||
private array $assignments,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function getId(): int
|
|
||||||
{
|
|
||||||
return $this->id;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getUser(): User
|
|
||||||
{
|
|
||||||
return $this->user;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getSetName(): string
|
|
||||||
{
|
|
||||||
return $this->setName;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getElementKind(): string
|
|
||||||
{
|
|
||||||
return $this->elementKind;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getStartDate(): DateTimeImmutable
|
|
||||||
{
|
|
||||||
return $this->startDate;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getTargetDate(): DateTimeImmutable
|
|
||||||
{
|
|
||||||
return $this->targetDate;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return list<ScheduleAssignment>
|
|
||||||
*/
|
|
||||||
public function getAssignments(): array
|
|
||||||
{
|
|
||||||
return $this->assignments;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Schedule;
|
|
||||||
|
|
||||||
use DateTimeImmutable;
|
|
||||||
|
|
||||||
final readonly class ScheduleAssignment
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @param list<string> $path
|
|
||||||
*/
|
|
||||||
public function __construct(
|
|
||||||
private int $id,
|
|
||||||
private string $name,
|
|
||||||
private string $kind,
|
|
||||||
private array $path,
|
|
||||||
private DateTimeImmutable $scheduledDate,
|
|
||||||
private int $position,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function getId(): int
|
|
||||||
{
|
|
||||||
return $this->id;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getName(): string
|
|
||||||
{
|
|
||||||
return $this->name;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getKind(): string
|
|
||||||
{
|
|
||||||
return $this->kind;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return list<string>
|
|
||||||
*/
|
|
||||||
public function getPath(): array
|
|
||||||
{
|
|
||||||
return $this->path;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getScheduledDate(): DateTimeImmutable
|
|
||||||
{
|
|
||||||
return $this->scheduledDate;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getPosition(): int
|
|
||||||
{
|
|
||||||
return $this->position;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Schedule;
|
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @property int $id
|
|
||||||
* @property int $schedule_id
|
|
||||||
* @property string $element_name
|
|
||||||
* @property string $element_kind
|
|
||||||
* @property list<string> $element_path
|
|
||||||
* @property string $scheduled_date
|
|
||||||
* @property int $position
|
|
||||||
*
|
|
||||||
* @method static Builder<static>|ScheduleAssignmentModel newModelQuery()
|
|
||||||
* @method static Builder<static>|ScheduleAssignmentModel newQuery()
|
|
||||||
* @method static Builder<static>|ScheduleAssignmentModel query()
|
|
||||||
*
|
|
||||||
* @mixin \Eloquent
|
|
||||||
*/
|
|
||||||
#[Fillable([
|
|
||||||
'schedule_id',
|
|
||||||
'element_name',
|
|
||||||
'element_kind',
|
|
||||||
'element_path',
|
|
||||||
'scheduled_date',
|
|
||||||
'position',
|
|
||||||
])]
|
|
||||||
class ScheduleAssignmentModel extends Model
|
|
||||||
{
|
|
||||||
protected $table = 'schedule_assignments';
|
|
||||||
|
|
||||||
public $timestamps = false;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, string>
|
|
||||||
*/
|
|
||||||
protected function casts(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'schedule_id' => 'integer',
|
|
||||||
'element_path' => 'array',
|
|
||||||
'position' => 'integer',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Schedule;
|
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @property int $id
|
|
||||||
* @property int $user_id
|
|
||||||
* @property string $set_name
|
|
||||||
* @property string $element_kind
|
|
||||||
* @property string $start_date
|
|
||||||
* @property string $target_date
|
|
||||||
*
|
|
||||||
* @method static Builder<static>|ScheduleModel newModelQuery()
|
|
||||||
* @method static Builder<static>|ScheduleModel newQuery()
|
|
||||||
* @method static Builder<static>|ScheduleModel query()
|
|
||||||
*
|
|
||||||
* @mixin \Eloquent
|
|
||||||
*/
|
|
||||||
#[Fillable([
|
|
||||||
'user_id',
|
|
||||||
'set_name',
|
|
||||||
'element_kind',
|
|
||||||
'start_date',
|
|
||||||
'target_date',
|
|
||||||
])]
|
|
||||||
class ScheduleModel extends Model
|
|
||||||
{
|
|
||||||
protected $table = 'schedules';
|
|
||||||
|
|
||||||
public $timestamps = false;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, string>
|
|
||||||
*/
|
|
||||||
protected function casts(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'user_id' => 'integer',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Schedule;
|
|
||||||
|
|
||||||
use App\User\User;
|
|
||||||
|
|
||||||
interface ScheduleRepository
|
|
||||||
{
|
|
||||||
public function create(CreateScheduleDto $dto): Schedule;
|
|
||||||
|
|
||||||
public function findForUser(int $id, User $user): ?Schedule;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return list<Schedule>
|
|
||||||
*/
|
|
||||||
public function findAllForUser(User $user): array;
|
|
||||||
}
|
|
||||||
|
|
@ -1,240 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Schedule\UseCases\CreateSchedule;
|
|
||||||
|
|
||||||
use App\Element\Element;
|
|
||||||
use App\Element\ElementRepository;
|
|
||||||
use App\Exceptions\BadRequestException;
|
|
||||||
use App\Exceptions\NotFoundException;
|
|
||||||
use App\Schedule\CreateScheduleAssignmentDto;
|
|
||||||
use App\Schedule\CreateScheduleDto;
|
|
||||||
use App\Schedule\Schedule;
|
|
||||||
use App\Schedule\ScheduleRepository;
|
|
||||||
use App\Set\Set;
|
|
||||||
use App\Set\SetLevelRepository;
|
|
||||||
use App\Set\SetRepository;
|
|
||||||
use DateTimeImmutable;
|
|
||||||
use DateTimeZone;
|
|
||||||
|
|
||||||
class CreateSchedule
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private SetRepository $setRepository,
|
|
||||||
private SetLevelRepository $setLevelRepository,
|
|
||||||
private ElementRepository $elementRepository,
|
|
||||||
private ScheduleRepository $scheduleRepository,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @throws BadRequestException
|
|
||||||
* @throws NotFoundException
|
|
||||||
*/
|
|
||||||
public function execute(CreateScheduleRequest $request): Schedule
|
|
||||||
{
|
|
||||||
if ($request->setId === null || $request->setId < 1) {
|
|
||||||
throw new BadRequestException('setId is required');
|
|
||||||
}
|
|
||||||
|
|
||||||
$set = $this->setRepository->find($request->setId);
|
|
||||||
if ($set === null) {
|
|
||||||
throw new NotFoundException('set not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($request->levelId === null || $request->levelId < 1) {
|
|
||||||
throw new BadRequestException('levelId is required');
|
|
||||||
}
|
|
||||||
|
|
||||||
$level = $this->setLevelRepository->find($request->levelId);
|
|
||||||
if (
|
|
||||||
$level === null
|
|
||||||
|| $level->getSet()->getId() !== $set->getId()
|
|
||||||
) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
'level is not available for set',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$startDate = $this->parseDate($request->startDate, 'startDate');
|
|
||||||
$targetDate = $this->parseDate($request->targetDate, 'targetDate');
|
|
||||||
if ($targetDate < $startDate) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
'targetDate must not be before startDate',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$elements = array_values(array_filter(
|
|
||||||
$this->orderedElements($set),
|
|
||||||
function (Element $element) use ($level): bool {
|
|
||||||
return $element->getLevel()->getId() === $level->getId();
|
|
||||||
},
|
|
||||||
));
|
|
||||||
if ($elements === []) {
|
|
||||||
throw new BadRequestException('level has no elements');
|
|
||||||
}
|
|
||||||
|
|
||||||
$assignments = $this->assignments(
|
|
||||||
elements: $elements,
|
|
||||||
startDate: $startDate,
|
|
||||||
targetDate: $targetDate,
|
|
||||||
);
|
|
||||||
|
|
||||||
return $this->scheduleRepository->create(new CreateScheduleDto(
|
|
||||||
user: $request->user,
|
|
||||||
setName: $set->getName(),
|
|
||||||
elementKind: $level->getKind(),
|
|
||||||
startDate: $startDate,
|
|
||||||
targetDate: $targetDate,
|
|
||||||
assignments: $assignments,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return list<Element>
|
|
||||||
*/
|
|
||||||
private function orderedElements(Set $set): array
|
|
||||||
{
|
|
||||||
$childrenByParentId = [];
|
|
||||||
|
|
||||||
foreach ($this->elementRepository->findBySet($set) as $element) {
|
|
||||||
$parentId = $element->getParentElement()?->getId() ?? 0;
|
|
||||||
$childrenByParentId[$parentId][] = $element;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($childrenByParentId as &$children) {
|
|
||||||
usort($children, function (Element $first, Element $second): int {
|
|
||||||
$positionComparison = $first->getPosition()
|
|
||||||
<=> $second->getPosition();
|
|
||||||
if ($positionComparison !== 0) {
|
|
||||||
return $positionComparison;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $first->getId() <=> $second->getId();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
unset($children);
|
|
||||||
|
|
||||||
$orderedElements = [];
|
|
||||||
$this->appendChildren(
|
|
||||||
parentId: 0,
|
|
||||||
childrenByParentId: $childrenByParentId,
|
|
||||||
orderedElements: $orderedElements,
|
|
||||||
);
|
|
||||||
|
|
||||||
return $orderedElements;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<int, list<Element>> $childrenByParentId
|
|
||||||
* @param list<Element> $orderedElements
|
|
||||||
*/
|
|
||||||
private function appendChildren(
|
|
||||||
int $parentId,
|
|
||||||
array $childrenByParentId,
|
|
||||||
array &$orderedElements,
|
|
||||||
): void {
|
|
||||||
foreach ($childrenByParentId[$parentId] ?? [] as $element) {
|
|
||||||
$orderedElements[] = $element;
|
|
||||||
$this->appendChildren(
|
|
||||||
parentId: $element->getId(),
|
|
||||||
childrenByParentId: $childrenByParentId,
|
|
||||||
orderedElements: $orderedElements,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param list<Element> $elements
|
|
||||||
* @return list<CreateScheduleAssignmentDto>
|
|
||||||
*/
|
|
||||||
private function assignments(
|
|
||||||
array $elements,
|
|
||||||
DateTimeImmutable $startDate,
|
|
||||||
DateTimeImmutable $targetDate,
|
|
||||||
): array {
|
|
||||||
$differenceInDays = $startDate->diff($targetDate)->days;
|
|
||||||
$dayCount = $differenceInDays + 1;
|
|
||||||
$elementCount = count($elements);
|
|
||||||
$assignments = [];
|
|
||||||
|
|
||||||
foreach ($elements as $index => $element) {
|
|
||||||
$dayIndex = $this->dayIndex(
|
|
||||||
elementIndex: $index,
|
|
||||||
elementCount: $elementCount,
|
|
||||||
dayCount: $dayCount,
|
|
||||||
);
|
|
||||||
$assignments[] = new CreateScheduleAssignmentDto(
|
|
||||||
name: $element->getName(),
|
|
||||||
kind: $element->getKind(),
|
|
||||||
path: $this->elementPath($element),
|
|
||||||
scheduledDate: $startDate->modify("+{$dayIndex} days"),
|
|
||||||
position: $index + 1,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $assignments;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return list<string>
|
|
||||||
*/
|
|
||||||
private function elementPath(Element $element): array
|
|
||||||
{
|
|
||||||
$path = [];
|
|
||||||
$currentElement = $element;
|
|
||||||
|
|
||||||
while ($currentElement !== null) {
|
|
||||||
array_unshift($path, $currentElement->getName());
|
|
||||||
$currentElement = $currentElement->getParentElement();
|
|
||||||
}
|
|
||||||
|
|
||||||
return $path;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function dayIndex(
|
|
||||||
int $elementIndex,
|
|
||||||
int $elementCount,
|
|
||||||
int $dayCount,
|
|
||||||
): int {
|
|
||||||
if ($elementCount === 1 || $dayCount === 1) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($elementCount < $dayCount) {
|
|
||||||
$scaledIndex = $elementIndex * ($dayCount - 1)
|
|
||||||
/ ($elementCount - 1);
|
|
||||||
|
|
||||||
return (int) floor($scaledIndex + 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
return intdiv($elementIndex * $dayCount, $elementCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @throws BadRequestException
|
|
||||||
*/
|
|
||||||
private function parseDate(?string $value, string $field): DateTimeImmutable
|
|
||||||
{
|
|
||||||
if ($value === null || $value === '') {
|
|
||||||
throw new BadRequestException("{$field} is required");
|
|
||||||
}
|
|
||||||
|
|
||||||
$date = DateTimeImmutable::createFromFormat(
|
|
||||||
'!Y-m-d',
|
|
||||||
$value,
|
|
||||||
new DateTimeZone('UTC'),
|
|
||||||
);
|
|
||||||
$errors = DateTimeImmutable::getLastErrors();
|
|
||||||
if (
|
|
||||||
$date === false
|
|
||||||
|| $date->format('Y-m-d') !== $value
|
|
||||||
|| ($errors !== false
|
|
||||||
&& ($errors['warning_count'] > 0 || $errors['error_count'] > 0))
|
|
||||||
) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
"{$field} must be a valid date in YYYY-MM-DD format",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $date;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Schedule\UseCases\CreateSchedule;
|
|
||||||
|
|
||||||
use App\User\User;
|
|
||||||
|
|
||||||
final readonly class CreateScheduleRequest
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
public User $user,
|
|
||||||
public ?int $setId,
|
|
||||||
public ?int $levelId,
|
|
||||||
public ?string $startDate,
|
|
||||||
public ?string $targetDate,
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Schedule\UseCases\GetSchedule;
|
|
||||||
|
|
||||||
use App\Exceptions\NotFoundException;
|
|
||||||
use App\Schedule\Schedule;
|
|
||||||
use App\Schedule\ScheduleRepository;
|
|
||||||
|
|
||||||
class GetSchedule
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private ScheduleRepository $scheduleRepository,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @throws NotFoundException
|
|
||||||
*/
|
|
||||||
public function execute(GetScheduleRequest $request): Schedule
|
|
||||||
{
|
|
||||||
$schedule = $this->scheduleRepository->findForUser(
|
|
||||||
$request->scheduleId,
|
|
||||||
$request->user,
|
|
||||||
);
|
|
||||||
if ($schedule === null) {
|
|
||||||
throw new NotFoundException('schedule not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $schedule;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Schedule\UseCases\GetSchedule;
|
|
||||||
|
|
||||||
use App\User\User;
|
|
||||||
|
|
||||||
final readonly class GetScheduleRequest
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
public int $scheduleId,
|
|
||||||
public User $user,
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Schedule\UseCases\ListSchedules;
|
|
||||||
|
|
||||||
use App\Schedule\Schedule;
|
|
||||||
use App\Schedule\ScheduleRepository;
|
|
||||||
use App\User\User;
|
|
||||||
|
|
||||||
class ListSchedules
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private ScheduleRepository $scheduleRepository,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return list<Schedule>
|
|
||||||
*/
|
|
||||||
public function execute(User $user): array
|
|
||||||
{
|
|
||||||
return $this->scheduleRepository->findAllForUser($user);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Set;
|
|
||||||
|
|
||||||
final readonly class CreateSetLevelDto
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
public Set $set,
|
|
||||||
public string $kind,
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
|
|
@ -1,88 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Set;
|
|
||||||
|
|
||||||
use DomainException;
|
|
||||||
use RuntimeException;
|
|
||||||
|
|
||||||
class EloquentSetLevelRepository implements SetLevelRepository
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private SetRepository $setRepository,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function create(CreateSetLevelDto $dto): SetLevel
|
|
||||||
{
|
|
||||||
$kindExists = SetLevelModel::query()
|
|
||||||
->where('set_id', $dto->set->getId())
|
|
||||||
->where('kind', $dto->kind)
|
|
||||||
->exists();
|
|
||||||
if ($kindExists) {
|
|
||||||
throw new DomainException(
|
|
||||||
'level kind must be unique within set',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$currentMaximum = SetLevelModel::query()
|
|
||||||
->where('set_id', $dto->set->getId())
|
|
||||||
->max('depth');
|
|
||||||
$depth = $currentMaximum === null
|
|
||||||
? 0
|
|
||||||
: (int) $currentMaximum + 1;
|
|
||||||
$model = SetLevelModel::create([
|
|
||||||
'set_id' => $dto->set->getId(),
|
|
||||||
'kind' => $dto->kind,
|
|
||||||
'depth' => $depth,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return new SetLevel(
|
|
||||||
id: $model->id,
|
|
||||||
set: $dto->set,
|
|
||||||
kind: $model->kind,
|
|
||||||
depth: $model->depth,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function find(int $id): ?SetLevel
|
|
||||||
{
|
|
||||||
$model = SetLevelModel::find($id);
|
|
||||||
|
|
||||||
return $model === null ? null : $this->toDomain($model);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function findBySet(Set $set): array
|
|
||||||
{
|
|
||||||
$models = SetLevelModel::query()
|
|
||||||
->where('set_id', $set->getId())
|
|
||||||
->orderBy('depth')
|
|
||||||
->orderBy('id')
|
|
||||||
->get();
|
|
||||||
$levels = [];
|
|
||||||
|
|
||||||
foreach ($models as $model) {
|
|
||||||
$levels[] = new SetLevel(
|
|
||||||
id: $model->id,
|
|
||||||
set: $set,
|
|
||||||
kind: $model->kind,
|
|
||||||
depth: $model->depth,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $levels;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function toDomain(SetLevelModel $model): SetLevel
|
|
||||||
{
|
|
||||||
$set = $this->setRepository->find($model->set_id);
|
|
||||||
if ($set === null) {
|
|
||||||
throw new RuntimeException('set level set not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
return new SetLevel(
|
|
||||||
id: $model->id,
|
|
||||||
set: $set,
|
|
||||||
kind: $model->kind,
|
|
||||||
depth: $model->depth,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Set;
|
|
||||||
|
|
||||||
final readonly class SetLevel
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private int $id,
|
|
||||||
private Set $set,
|
|
||||||
private string $kind,
|
|
||||||
private int $depth,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function getId(): int
|
|
||||||
{
|
|
||||||
return $this->id;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getSet(): Set
|
|
||||||
{
|
|
||||||
return $this->set;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getKind(): string
|
|
||||||
{
|
|
||||||
return $this->kind;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getDepth(): int
|
|
||||||
{
|
|
||||||
return $this->depth;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Set;
|
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @property int $id
|
|
||||||
* @property int $set_id
|
|
||||||
* @property string $kind
|
|
||||||
* @property int $depth
|
|
||||||
*
|
|
||||||
* @method static Builder<static>|SetLevelModel newModelQuery()
|
|
||||||
* @method static Builder<static>|SetLevelModel newQuery()
|
|
||||||
* @method static Builder<static>|SetLevelModel query()
|
|
||||||
*
|
|
||||||
* @mixin \Eloquent
|
|
||||||
*/
|
|
||||||
#[Fillable(['set_id', 'kind', 'depth'])]
|
|
||||||
class SetLevelModel extends Model
|
|
||||||
{
|
|
||||||
protected $table = 'set_levels';
|
|
||||||
|
|
||||||
public $timestamps = false;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, string>
|
|
||||||
*/
|
|
||||||
protected function casts(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'set_id' => 'integer',
|
|
||||||
'depth' => 'integer',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Set;
|
|
||||||
|
|
||||||
use DomainException;
|
|
||||||
|
|
||||||
interface SetLevelRepository
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @throws DomainException
|
|
||||||
*/
|
|
||||||
public function create(CreateSetLevelDto $dto): SetLevel;
|
|
||||||
|
|
||||||
public function find(int $id): ?SetLevel;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return list<SetLevel>
|
|
||||||
*/
|
|
||||||
public function findBySet(Set $set): array;
|
|
||||||
}
|
|
||||||
|
|
@ -6,13 +6,11 @@ use App\Element\Element;
|
||||||
use App\Element\ElementRepository;
|
use App\Element\ElementRepository;
|
||||||
use App\Exceptions\NotFoundException;
|
use App\Exceptions\NotFoundException;
|
||||||
use App\Set\SetRepository;
|
use App\Set\SetRepository;
|
||||||
use App\Set\SetLevelRepository;
|
|
||||||
|
|
||||||
class GetSetLayout
|
class GetSetLayout
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private SetRepository $setRepository,
|
private SetRepository $setRepository,
|
||||||
private SetLevelRepository $setLevelRepository,
|
|
||||||
private ElementRepository $elementRepository,
|
private ElementRepository $elementRepository,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
|
@ -26,15 +24,10 @@ class GetSetLayout
|
||||||
throw new NotFoundException('set not found');
|
throw new NotFoundException('set not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
$elements = $this->elementRepository->findBySet($set);
|
|
||||||
$elementsByParentId = [];
|
$elementsByParentId = [];
|
||||||
$elementCountsByLevelId = [];
|
foreach ($this->elementRepository->findBySet($set) as $element) {
|
||||||
foreach ($elements as $element) {
|
|
||||||
$parentId = $element->getParentElement()?->getId() ?? 0;
|
$parentId = $element->getParentElement()?->getId() ?? 0;
|
||||||
$elementsByParentId[$parentId][] = $element;
|
$elementsByParentId[$parentId][] = $element;
|
||||||
$levelId = $element->getLevel()->getId();
|
|
||||||
$elementCountsByLevelId[$levelId]
|
|
||||||
= ($elementCountsByLevelId[$levelId] ?? 0) + 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($elementsByParentId as &$siblings) {
|
foreach ($elementsByParentId as &$siblings) {
|
||||||
|
|
@ -53,17 +46,8 @@ class GetSetLayout
|
||||||
}
|
}
|
||||||
unset($siblings);
|
unset($siblings);
|
||||||
|
|
||||||
$layoutLevels = [];
|
|
||||||
foreach ($this->setLevelRepository->findBySet($set) as $level) {
|
|
||||||
$layoutLevels[] = new SetLayoutLevel(
|
|
||||||
level: $level,
|
|
||||||
elementCount: $elementCountsByLevelId[$level->getId()] ?? 0,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new SetLayout(
|
return new SetLayout(
|
||||||
set: $set,
|
set: $set,
|
||||||
levels: $layoutLevels,
|
|
||||||
elements: $this->buildNodes(0, $elementsByParentId),
|
elements: $this->buildNodes(0, $elementsByParentId),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,10 @@ use App\Set\Set;
|
||||||
final readonly class SetLayout
|
final readonly class SetLayout
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* @param list<SetLayoutLevel> $levels
|
|
||||||
* @param list<ElementLayoutNode> $elements
|
* @param list<ElementLayoutNode> $elements
|
||||||
*/
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private Set $set,
|
private Set $set,
|
||||||
private array $levels,
|
|
||||||
private array $elements,
|
private array $elements,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
|
@ -21,14 +19,6 @@ final readonly class SetLayout
|
||||||
return $this->set;
|
return $this->set;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return list<SetLayoutLevel>
|
|
||||||
*/
|
|
||||||
public function getLevels(): array
|
|
||||||
{
|
|
||||||
return $this->levels;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return list<ElementLayoutNode>
|
* @return list<ElementLayoutNode>
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Set\UseCases\GetSetLayout;
|
|
||||||
|
|
||||||
use App\Set\SetLevel;
|
|
||||||
|
|
||||||
final readonly class SetLayoutLevel
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private SetLevel $level,
|
|
||||||
private int $elementCount,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function getLevel(): SetLevel
|
|
||||||
{
|
|
||||||
return $this->level;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getElementCount(): int
|
|
||||||
{
|
|
||||||
return $this->elementCount;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -20,11 +20,4 @@ class RequestInput
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function integer(string $key): ?int
|
|
||||||
{
|
|
||||||
$value = $this->request->input($key);
|
|
||||||
|
|
||||||
return is_int($value) ? $value : null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
|
|
||||||
return new class extends Migration
|
|
||||||
{
|
|
||||||
public function up(): void
|
|
||||||
{
|
|
||||||
Schema::create('set_levels', function (Blueprint $table): void {
|
|
||||||
$table->id();
|
|
||||||
$table->foreignId('set_id')
|
|
||||||
->constrained('sets')
|
|
||||||
->cascadeOnDelete();
|
|
||||||
$table->string('kind');
|
|
||||||
$table->unsignedInteger('depth');
|
|
||||||
$table->unique(['set_id', 'kind']);
|
|
||||||
$table->unique(['set_id', 'depth']);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
Schema::dropIfExists('set_levels');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -10,17 +10,18 @@ return new class extends Migration
|
||||||
{
|
{
|
||||||
Schema::create('elements', function (Blueprint $table): void {
|
Schema::create('elements', function (Blueprint $table): void {
|
||||||
$table->id();
|
$table->id();
|
||||||
$table->foreignId('set_level_id')
|
$table->foreignId('set_id')
|
||||||
->constrained('set_levels')
|
->constrained('sets')
|
||||||
->restrictOnDelete();
|
->restrictOnDelete();
|
||||||
$table->string('name');
|
$table->string('name');
|
||||||
|
$table->string('kind');
|
||||||
$table->foreignId('parent_element_id')
|
$table->foreignId('parent_element_id')
|
||||||
->nullable()
|
->nullable()
|
||||||
->constrained('elements')
|
->constrained('elements')
|
||||||
->restrictOnDelete();
|
->restrictOnDelete();
|
||||||
$table->unsignedInteger('position');
|
$table->unsignedInteger('position');
|
||||||
$table->index([
|
$table->index([
|
||||||
'set_level_id',
|
'set_id',
|
||||||
'parent_element_id',
|
'parent_element_id',
|
||||||
'position',
|
'position',
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
|
|
||||||
return new class extends Migration
|
|
||||||
{
|
|
||||||
public function up(): void
|
|
||||||
{
|
|
||||||
Schema::create('schedules', function (Blueprint $table): void {
|
|
||||||
$table->id();
|
|
||||||
$table->foreignId('user_id')
|
|
||||||
->constrained('users')
|
|
||||||
->restrictOnDelete();
|
|
||||||
$table->string('set_name');
|
|
||||||
$table->string('element_kind');
|
|
||||||
$table->date('start_date');
|
|
||||||
$table->date('target_date');
|
|
||||||
$table->index(['user_id', 'id']);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
Schema::dropIfExists('schedules');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
|
|
||||||
return new class extends Migration
|
|
||||||
{
|
|
||||||
public function up(): void
|
|
||||||
{
|
|
||||||
Schema::create(
|
|
||||||
'schedule_assignments',
|
|
||||||
function (Blueprint $table): void {
|
|
||||||
$table->id();
|
|
||||||
$table->foreignId('schedule_id')
|
|
||||||
->constrained('schedules')
|
|
||||||
->cascadeOnDelete();
|
|
||||||
$table->string('element_name');
|
|
||||||
$table->string('element_kind');
|
|
||||||
$table->json('element_path');
|
|
||||||
$table->date('scheduled_date');
|
|
||||||
$table->unsignedInteger('position');
|
|
||||||
$table->unique(['schedule_id', 'position']);
|
|
||||||
$table->index([
|
|
||||||
'schedule_id',
|
|
||||||
'scheduled_date',
|
|
||||||
'position',
|
|
||||||
]);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
Schema::dropIfExists('schedule_assignments');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -13,7 +13,6 @@ class DatabaseSeeder extends Seeder
|
||||||
{
|
{
|
||||||
$this->call(UserSeeder::class);
|
$this->call(UserSeeder::class);
|
||||||
$this->call(SetSeeder::class);
|
$this->call(SetSeeder::class);
|
||||||
$this->call(SetLevelSeeder::class);
|
|
||||||
$this->call(ElementSeeder::class);
|
$this->call(ElementSeeder::class);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,8 @@ use App\Element\CreateElementDto;
|
||||||
use App\Element\Element;
|
use App\Element\Element;
|
||||||
use App\Element\ElementRepository;
|
use App\Element\ElementRepository;
|
||||||
use App\Set\Set;
|
use App\Set\Set;
|
||||||
use App\Set\SetLevel;
|
|
||||||
use App\Set\SetLevelRepository;
|
|
||||||
use App\Set\SetRepository;
|
use App\Set\SetRepository;
|
||||||
use Illuminate\Database\Seeder;
|
use Illuminate\Database\Seeder;
|
||||||
use RuntimeException;
|
|
||||||
|
|
||||||
class ElementSeeder extends Seeder
|
class ElementSeeder extends Seeder
|
||||||
{
|
{
|
||||||
|
|
@ -18,31 +15,38 @@ class ElementSeeder extends Seeder
|
||||||
'Bible' => [
|
'Bible' => [
|
||||||
[
|
[
|
||||||
'name' => 'Genesis',
|
'name' => 'Genesis',
|
||||||
|
'kind' => 'book',
|
||||||
'children' => [
|
'children' => [
|
||||||
[
|
[
|
||||||
'name' => 'Creation',
|
'name' => 'Creation',
|
||||||
|
'kind' => 'portion',
|
||||||
'children' => [
|
'children' => [
|
||||||
[
|
[
|
||||||
'name' => 'Chapter 1',
|
'name' => 'Chapter 1',
|
||||||
|
'kind' => 'chapter',
|
||||||
'children' => [],
|
'children' => [],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'name' => 'Chapter 2',
|
'name' => 'Chapter 2',
|
||||||
|
'kind' => 'chapter',
|
||||||
'children' => [],
|
'children' => [],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'name' => 'Noah',
|
'name' => 'Noah',
|
||||||
|
'kind' => 'portion',
|
||||||
'children' => [],
|
'children' => [],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'name' => 'Exodus',
|
'name' => 'Exodus',
|
||||||
|
'kind' => 'book',
|
||||||
'children' => [
|
'children' => [
|
||||||
[
|
[
|
||||||
'name' => 'Shemot',
|
'name' => 'Shemot',
|
||||||
|
'kind' => 'portion',
|
||||||
'children' => [],
|
'children' => [],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
@ -51,26 +55,32 @@ class ElementSeeder extends Seeder
|
||||||
'Course' => [
|
'Course' => [
|
||||||
[
|
[
|
||||||
'name' => 'Foundations',
|
'name' => 'Foundations',
|
||||||
|
'kind' => 'module',
|
||||||
'children' => [
|
'children' => [
|
||||||
[
|
[
|
||||||
'name' => 'Welcome',
|
'name' => 'Welcome',
|
||||||
|
'kind' => 'lesson',
|
||||||
'children' => [],
|
'children' => [],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'name' => 'Core Concepts',
|
'name' => 'Core Concepts',
|
||||||
|
'kind' => 'lesson',
|
||||||
'children' => [],
|
'children' => [],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'name' => 'Applied Practice',
|
'name' => 'Applied Practice',
|
||||||
|
'kind' => 'module',
|
||||||
'children' => [
|
'children' => [
|
||||||
[
|
[
|
||||||
'name' => 'Guided Exercise',
|
'name' => 'Guided Exercise',
|
||||||
|
'kind' => 'lesson',
|
||||||
'children' => [],
|
'children' => [],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'name' => 'Final Review',
|
'name' => 'Final Review',
|
||||||
|
'kind' => 'lesson',
|
||||||
'children' => [],
|
'children' => [],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
@ -79,25 +89,31 @@ class ElementSeeder extends Seeder
|
||||||
'Fitness Program' => [
|
'Fitness Program' => [
|
||||||
[
|
[
|
||||||
'name' => 'Foundation Phase',
|
'name' => 'Foundation Phase',
|
||||||
|
'kind' => 'phase',
|
||||||
'children' => [
|
'children' => [
|
||||||
[
|
[
|
||||||
'name' => 'Strength Day',
|
'name' => 'Strength Day',
|
||||||
|
'kind' => 'workout',
|
||||||
'children' => [
|
'children' => [
|
||||||
[
|
[
|
||||||
'name' => 'Squat',
|
'name' => 'Squat',
|
||||||
|
'kind' => 'exercise',
|
||||||
'children' => [],
|
'children' => [],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'name' => 'Push-up',
|
'name' => 'Push-up',
|
||||||
|
'kind' => 'exercise',
|
||||||
'children' => [],
|
'children' => [],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'name' => 'Mobility Day',
|
'name' => 'Mobility Day',
|
||||||
|
'kind' => 'workout',
|
||||||
'children' => [
|
'children' => [
|
||||||
[
|
[
|
||||||
'name' => 'Hip Flow',
|
'name' => 'Hip Flow',
|
||||||
|
'kind' => 'exercise',
|
||||||
'children' => [],
|
'children' => [],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
@ -106,9 +122,11 @@ class ElementSeeder extends Seeder
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'name' => 'Build Phase',
|
'name' => 'Build Phase',
|
||||||
|
'kind' => 'phase',
|
||||||
'children' => [
|
'children' => [
|
||||||
[
|
[
|
||||||
'name' => 'Full Body Circuit',
|
'name' => 'Full Body Circuit',
|
||||||
|
'kind' => 'workout',
|
||||||
'children' => [],
|
'children' => [],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
@ -119,7 +137,6 @@ class ElementSeeder extends Seeder
|
||||||
public function run(): void
|
public function run(): void
|
||||||
{
|
{
|
||||||
$elementRepository = app(ElementRepository::class);
|
$elementRepository = app(ElementRepository::class);
|
||||||
$setLevelRepository = app(SetLevelRepository::class);
|
|
||||||
|
|
||||||
foreach (app(SetRepository::class)->all() as $set) {
|
foreach (app(SetRepository::class)->all() as $set) {
|
||||||
$definitions = self::ELEMENTS_BY_SET[$set->getName()] ?? null;
|
$definitions = self::ELEMENTS_BY_SET[$set->getName()] ?? null;
|
||||||
|
|
@ -130,8 +147,6 @@ class ElementSeeder extends Seeder
|
||||||
$this->seedChildren(
|
$this->seedChildren(
|
||||||
repository: $elementRepository,
|
repository: $elementRepository,
|
||||||
set: $set,
|
set: $set,
|
||||||
levels: $setLevelRepository->findBySet($set),
|
|
||||||
depth: 0,
|
|
||||||
parentElement: null,
|
parentElement: null,
|
||||||
definitions: $definitions,
|
definitions: $definitions,
|
||||||
);
|
);
|
||||||
|
|
@ -141,27 +156,16 @@ class ElementSeeder extends Seeder
|
||||||
/**
|
/**
|
||||||
* @param list<array{
|
* @param list<array{
|
||||||
* name: string,
|
* name: string,
|
||||||
|
* kind: string,
|
||||||
* children: list<mixed>
|
* children: list<mixed>
|
||||||
* }> $definitions
|
* }> $definitions
|
||||||
* @param list<SetLevel> $levels
|
|
||||||
*/
|
*/
|
||||||
private function seedChildren(
|
private function seedChildren(
|
||||||
ElementRepository $repository,
|
ElementRepository $repository,
|
||||||
Set $set,
|
Set $set,
|
||||||
array $levels,
|
|
||||||
int $depth,
|
|
||||||
?Element $parentElement,
|
?Element $parentElement,
|
||||||
array $definitions,
|
array $definitions,
|
||||||
): void {
|
): void {
|
||||||
if ($definitions === []) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$level = $levels[$depth] ?? null;
|
|
||||||
if ($level === null) {
|
|
||||||
throw new RuntimeException('element level not defined');
|
|
||||||
}
|
|
||||||
|
|
||||||
$siblings = $parentElement === null
|
$siblings = $parentElement === null
|
||||||
? $repository->findTopLevelBySet($set)
|
? $repository->findTopLevelBySet($set)
|
||||||
: $repository->findByParentElement($parentElement);
|
: $repository->findByParentElement($parentElement);
|
||||||
|
|
@ -170,12 +174,13 @@ class ElementSeeder extends Seeder
|
||||||
$element = $this->findSibling(
|
$element = $this->findSibling(
|
||||||
siblings: $siblings,
|
siblings: $siblings,
|
||||||
name: $definition['name'],
|
name: $definition['name'],
|
||||||
level: $level,
|
kind: $definition['kind'],
|
||||||
);
|
);
|
||||||
if ($element === null) {
|
if ($element === null) {
|
||||||
$element = $repository->create(new CreateElementDto(
|
$element = $repository->create(new CreateElementDto(
|
||||||
|
set: $set,
|
||||||
name: $definition['name'],
|
name: $definition['name'],
|
||||||
level: $level,
|
kind: $definition['kind'],
|
||||||
parentElement: $parentElement,
|
parentElement: $parentElement,
|
||||||
));
|
));
|
||||||
$siblings[] = $element;
|
$siblings[] = $element;
|
||||||
|
|
@ -184,8 +189,6 @@ class ElementSeeder extends Seeder
|
||||||
$this->seedChildren(
|
$this->seedChildren(
|
||||||
repository: $repository,
|
repository: $repository,
|
||||||
set: $set,
|
set: $set,
|
||||||
levels: $levels,
|
|
||||||
depth: $depth + 1,
|
|
||||||
parentElement: $element,
|
parentElement: $element,
|
||||||
definitions: $definition['children'],
|
definitions: $definition['children'],
|
||||||
);
|
);
|
||||||
|
|
@ -198,12 +201,12 @@ class ElementSeeder extends Seeder
|
||||||
private function findSibling(
|
private function findSibling(
|
||||||
array $siblings,
|
array $siblings,
|
||||||
string $name,
|
string $name,
|
||||||
SetLevel $level,
|
string $kind,
|
||||||
): ?Element {
|
): ?Element {
|
||||||
foreach ($siblings as $sibling) {
|
foreach ($siblings as $sibling) {
|
||||||
if (
|
if (
|
||||||
$sibling->getName() === $name
|
$sibling->getName() === $name
|
||||||
&& $sibling->getLevel()->getId() === $level->getId()
|
&& $sibling->getKind() === $kind
|
||||||
) {
|
) {
|
||||||
return $sibling;
|
return $sibling;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace Database\Seeders;
|
|
||||||
|
|
||||||
use App\Set\CreateSetLevelDto;
|
|
||||||
use App\Set\SetLevelRepository;
|
|
||||||
use App\Set\SetRepository;
|
|
||||||
use Illuminate\Database\Seeder;
|
|
||||||
|
|
||||||
class SetLevelSeeder extends Seeder
|
|
||||||
{
|
|
||||||
private const array KINDS_BY_SET = [
|
|
||||||
'Bible' => ['book', 'portion', 'chapter'],
|
|
||||||
'Course' => ['module', 'lesson'],
|
|
||||||
'Fitness Program' => ['phase', 'workout', 'exercise'],
|
|
||||||
];
|
|
||||||
|
|
||||||
public function run(): void
|
|
||||||
{
|
|
||||||
$repository = app(SetLevelRepository::class);
|
|
||||||
|
|
||||||
foreach (app(SetRepository::class)->all() as $set) {
|
|
||||||
$kinds = self::KINDS_BY_SET[$set->getName()] ?? null;
|
|
||||||
if ($kinds === null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$existingKinds = array_map(function ($level): string {
|
|
||||||
return $level->getKind();
|
|
||||||
}, $repository->findBySet($set));
|
|
||||||
|
|
||||||
foreach ($kinds as $kind) {
|
|
||||||
if (in_array($kind, $existingKinds, true)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$repository->create(new CreateSetLevelDto(
|
|
||||||
set: $set,
|
|
||||||
kind: $kind,
|
|
||||||
));
|
|
||||||
$existingKinds[] = $kind;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
|
|
||||||
use App\Http\Controllers\AuthController;
|
use App\Http\Controllers\AuthController;
|
||||||
use App\Http\Controllers\SetController;
|
use App\Http\Controllers\SetController;
|
||||||
use App\Http\Controllers\ScheduleController;
|
|
||||||
use App\Http\Middleware\AuthMiddleware;
|
use App\Http\Middleware\AuthMiddleware;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
|
|
@ -15,9 +14,5 @@ Route::middleware(AuthMiddleware::class)->group(function (): void {
|
||||||
Route::get('/sets', [SetController::class, 'index']);
|
Route::get('/sets', [SetController::class, 'index']);
|
||||||
Route::get('/sets/{setId}', [SetController::class, 'show'])
|
Route::get('/sets/{setId}', [SetController::class, 'show'])
|
||||||
->whereNumber('setId');
|
->whereNumber('setId');
|
||||||
Route::post('/schedules', [ScheduleController::class, 'store']);
|
|
||||||
Route::get('/schedules', [ScheduleController::class, 'index']);
|
|
||||||
Route::get('/schedules/{scheduleId}', [ScheduleController::class, 'show'])
|
|
||||||
->whereNumber('scheduleId');
|
|
||||||
Route::post('/logout', [AuthController::class, 'logout']);
|
Route::post('/logout', [AuthController::class, 'logout']);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,8 @@ class FakeElementRepository implements ElementRepository
|
||||||
$element = new Element(
|
$element = new Element(
|
||||||
id: $id,
|
id: $id,
|
||||||
name: $dto->name,
|
name: $dto->name,
|
||||||
level: $dto->level,
|
kind: $dto->kind,
|
||||||
|
set: $dto->set,
|
||||||
parentElement: $dto->parentElement,
|
parentElement: $dto->parentElement,
|
||||||
position: $this->nextPosition($dto),
|
position: $this->nextPosition($dto),
|
||||||
);
|
);
|
||||||
|
|
@ -87,32 +88,14 @@ class FakeElementRepository implements ElementRepository
|
||||||
{
|
{
|
||||||
$parentElement = $dto->parentElement;
|
$parentElement = $dto->parentElement;
|
||||||
if ($parentElement === null) {
|
if ($parentElement === null) {
|
||||||
if ($dto->level->getDepth() !== 0) {
|
|
||||||
throw new DomainException(
|
|
||||||
'root element must use the first set level',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if ($parentElement->getSet()->getId() !== $dto->set->getId()) {
|
||||||
$parentElement->getSet()->getId()
|
|
||||||
!== $dto->level->getSet()->getId()
|
|
||||||
) {
|
|
||||||
throw new DomainException(
|
throw new DomainException(
|
||||||
'parent element must belong to the same set',
|
'parent element must belong to the same set',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
|
||||||
$dto->level->getDepth()
|
|
||||||
!== $parentElement->getLevel()->getDepth() + 1
|
|
||||||
) {
|
|
||||||
throw new DomainException(
|
|
||||||
'child element must use the next set level',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function nextPosition(CreateElementDto $dto): int
|
private function nextPosition(CreateElementDto $dto): int
|
||||||
|
|
@ -121,10 +104,7 @@ class FakeElementRepository implements ElementRepository
|
||||||
$maximumPosition = 0;
|
$maximumPosition = 0;
|
||||||
|
|
||||||
foreach ($this->elements as $element) {
|
foreach ($this->elements as $element) {
|
||||||
if (
|
if ($element->getSet()->getId() !== $dto->set->getId()) {
|
||||||
$element->getSet()->getId()
|
|
||||||
!== $dto->level->getSet()->getId()
|
|
||||||
) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -170,7 +150,8 @@ class FakeElementRepository implements ElementRepository
|
||||||
return new Element(
|
return new Element(
|
||||||
id: $element->getId(),
|
id: $element->getId(),
|
||||||
name: $element->getName(),
|
name: $element->getName(),
|
||||||
level: $element->getLevel(),
|
kind: $element->getKind(),
|
||||||
|
set: $element->getSet(),
|
||||||
parentElement: $parentElement === null
|
parentElement: $parentElement === null
|
||||||
? null
|
? null
|
||||||
: $this->copy($parentElement),
|
: $this->copy($parentElement),
|
||||||
|
|
|
||||||
|
|
@ -1,101 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace Tests\Fakes;
|
|
||||||
|
|
||||||
use App\Schedule\CreateScheduleDto;
|
|
||||||
use App\Schedule\Schedule;
|
|
||||||
use App\Schedule\ScheduleAssignment;
|
|
||||||
use App\Schedule\ScheduleRepository;
|
|
||||||
use App\User\User;
|
|
||||||
|
|
||||||
class FakeScheduleRepository implements ScheduleRepository
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @var array<int, Schedule>
|
|
||||||
*/
|
|
||||||
private array $schedules = [];
|
|
||||||
|
|
||||||
public function create(CreateScheduleDto $dto): Schedule
|
|
||||||
{
|
|
||||||
$id = count($this->schedules) + 1;
|
|
||||||
$assignments = [];
|
|
||||||
|
|
||||||
foreach ($dto->assignments as $assignmentDto) {
|
|
||||||
$assignments[] = new ScheduleAssignment(
|
|
||||||
id: count($assignments) + 1,
|
|
||||||
name: $assignmentDto->name,
|
|
||||||
kind: $assignmentDto->kind,
|
|
||||||
path: $assignmentDto->path,
|
|
||||||
scheduledDate: $assignmentDto->scheduledDate,
|
|
||||||
position: $assignmentDto->position,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$schedule = new Schedule(
|
|
||||||
id: $id,
|
|
||||||
user: $dto->user,
|
|
||||||
setName: $dto->setName,
|
|
||||||
elementKind: $dto->elementKind,
|
|
||||||
startDate: $dto->startDate,
|
|
||||||
targetDate: $dto->targetDate,
|
|
||||||
assignments: $assignments,
|
|
||||||
);
|
|
||||||
$this->schedules[$id] = $schedule;
|
|
||||||
|
|
||||||
return $this->copy($schedule);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function findForUser(int $id, User $user): ?Schedule
|
|
||||||
{
|
|
||||||
$schedule = $this->schedules[$id] ?? null;
|
|
||||||
if ($schedule === null || $schedule->getUser()->getId()
|
|
||||||
!== $user->getId()
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->copy($schedule);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function findAllForUser(User $user): array
|
|
||||||
{
|
|
||||||
$schedules = array_filter(
|
|
||||||
$this->schedules,
|
|
||||||
function (Schedule $schedule) use ($user): bool {
|
|
||||||
return $schedule->getUser()->getId() === $user->getId();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
krsort($schedules);
|
|
||||||
|
|
||||||
return array_map(function (Schedule $schedule): Schedule {
|
|
||||||
return $this->copy($schedule);
|
|
||||||
}, array_values($schedules));
|
|
||||||
}
|
|
||||||
|
|
||||||
private function copy(Schedule $schedule): Schedule
|
|
||||||
{
|
|
||||||
$assignments = array_map(
|
|
||||||
function (ScheduleAssignment $assignment): ScheduleAssignment {
|
|
||||||
return new ScheduleAssignment(
|
|
||||||
id: $assignment->getId(),
|
|
||||||
name: $assignment->getName(),
|
|
||||||
kind: $assignment->getKind(),
|
|
||||||
path: $assignment->getPath(),
|
|
||||||
scheduledDate: $assignment->getScheduledDate(),
|
|
||||||
position: $assignment->getPosition(),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
$schedule->getAssignments(),
|
|
||||||
);
|
|
||||||
|
|
||||||
return new Schedule(
|
|
||||||
id: $schedule->getId(),
|
|
||||||
user: $schedule->getUser(),
|
|
||||||
setName: $schedule->getSetName(),
|
|
||||||
elementKind: $schedule->getElementKind(),
|
|
||||||
startDate: $schedule->getStartDate(),
|
|
||||||
targetDate: $schedule->getTargetDate(),
|
|
||||||
assignments: $assignments,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,76 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace Tests\Fakes;
|
|
||||||
|
|
||||||
use App\Set\CreateSetLevelDto;
|
|
||||||
use App\Set\Set;
|
|
||||||
use App\Set\SetLevel;
|
|
||||||
use App\Set\SetLevelRepository;
|
|
||||||
use DomainException;
|
|
||||||
|
|
||||||
class FakeSetLevelRepository implements SetLevelRepository
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @var array<int, SetLevel>
|
|
||||||
*/
|
|
||||||
private array $levels = [];
|
|
||||||
|
|
||||||
public function create(CreateSetLevelDto $dto): SetLevel
|
|
||||||
{
|
|
||||||
foreach ($this->levels as $level) {
|
|
||||||
if (
|
|
||||||
$level->getSet()->getId() === $dto->set->getId()
|
|
||||||
&& $level->getKind() === $dto->kind
|
|
||||||
) {
|
|
||||||
throw new DomainException(
|
|
||||||
'level kind must be unique within set',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$id = count($this->levels) + 1;
|
|
||||||
$level = new SetLevel(
|
|
||||||
id: $id,
|
|
||||||
set: $dto->set,
|
|
||||||
kind: $dto->kind,
|
|
||||||
depth: count($this->findBySet($dto->set)),
|
|
||||||
);
|
|
||||||
$this->levels[$id] = $level;
|
|
||||||
|
|
||||||
return $this->copy($level);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function find(int $id): ?SetLevel
|
|
||||||
{
|
|
||||||
$level = $this->levels[$id] ?? null;
|
|
||||||
|
|
||||||
return $level === null ? null : $this->copy($level);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function findBySet(Set $set): array
|
|
||||||
{
|
|
||||||
$levels = array_filter(
|
|
||||||
$this->levels,
|
|
||||||
function (SetLevel $level) use ($set): bool {
|
|
||||||
return $level->getSet()->getId() === $set->getId();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
usort($levels, function (SetLevel $first, SetLevel $second): int {
|
|
||||||
return $first->getDepth() <=> $second->getDepth();
|
|
||||||
});
|
|
||||||
|
|
||||||
return array_map(function (SetLevel $level): SetLevel {
|
|
||||||
return $this->copy($level);
|
|
||||||
}, $levels);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function copy(SetLevel $level): SetLevel
|
|
||||||
{
|
|
||||||
return new SetLevel(
|
|
||||||
id: $level->getId(),
|
|
||||||
set: $level->getSet(),
|
|
||||||
kind: $level->getKind(),
|
|
||||||
depth: $level->getDepth(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -6,7 +6,6 @@ use App\Auth\PasswordHasher;
|
||||||
use App\Element\Element;
|
use App\Element\Element;
|
||||||
use App\Element\ElementRepository;
|
use App\Element\ElementRepository;
|
||||||
use App\Set\SetRepository;
|
use App\Set\SetRepository;
|
||||||
use App\Set\SetLevelRepository;
|
|
||||||
use App\Shared\ValueObject\EmailAddress;
|
use App\Shared\ValueObject\EmailAddress;
|
||||||
use App\User\UserRepository;
|
use App\User\UserRepository;
|
||||||
use Database\Seeders\UserSeeder;
|
use Database\Seeders\UserSeeder;
|
||||||
|
|
@ -71,12 +70,6 @@ class DatabaseSeederTest extends TestCase
|
||||||
|
|
||||||
$sets = app(SetRepository::class)->all();
|
$sets = app(SetRepository::class)->all();
|
||||||
$elementRepository = app(ElementRepository::class);
|
$elementRepository = app(ElementRepository::class);
|
||||||
$setLevelRepository = app(SetLevelRepository::class);
|
|
||||||
$expectedLevels = [
|
|
||||||
'Bible' => ['book', 'portion', 'chapter'],
|
|
||||||
'Course' => ['module', 'lesson'],
|
|
||||||
'Fitness Program' => ['phase', 'workout', 'exercise'],
|
|
||||||
];
|
|
||||||
$expectedElements = [
|
$expectedElements = [
|
||||||
'Bible' => [
|
'Bible' => [
|
||||||
'Chapter 1:chapter:Creation',
|
'Chapter 1:chapter:Creation',
|
||||||
|
|
@ -107,16 +100,9 @@ class DatabaseSeederTest extends TestCase
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
$this->assertDatabaseCount('set_levels', 8);
|
|
||||||
$this->assertDatabaseCount('elements', 21);
|
$this->assertDatabaseCount('elements', 21);
|
||||||
|
|
||||||
foreach ($sets as $set) {
|
foreach ($sets as $set) {
|
||||||
$this->assertSame(
|
|
||||||
$expectedLevels[$set->getName()],
|
|
||||||
array_map(function ($level): string {
|
|
||||||
return $level->getKind();
|
|
||||||
}, $setLevelRepository->findBySet($set)),
|
|
||||||
);
|
|
||||||
$signatures = array_map(function (Element $element): string {
|
$signatures = array_map(function (Element $element): string {
|
||||||
$parentName = $element->getParentElement()?->getName()
|
$parentName = $element->getParentElement()?->getName()
|
||||||
?? 'root';
|
?? 'root';
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,7 @@ namespace Tests\Feature\Element;
|
||||||
use App\Element\CreateElementDto;
|
use App\Element\CreateElementDto;
|
||||||
use App\Element\ElementRepository;
|
use App\Element\ElementRepository;
|
||||||
use App\Set\CreateSetDto;
|
use App\Set\CreateSetDto;
|
||||||
use App\Set\CreateSetLevelDto;
|
|
||||||
use App\Set\Set;
|
use App\Set\Set;
|
||||||
use App\Set\SetLevel;
|
|
||||||
use App\Set\SetLevelRepository;
|
|
||||||
use App\Set\SetRepository;
|
use App\Set\SetRepository;
|
||||||
use App\Shared\ValueObject\EmailAddress;
|
use App\Shared\ValueObject\EmailAddress;
|
||||||
use App\User\CreateUserDto;
|
use App\User\CreateUserDto;
|
||||||
|
|
@ -25,26 +22,28 @@ class EloquentElementRepositoryTest extends TestCase
|
||||||
{
|
{
|
||||||
$set = $this->createSet('Bible');
|
$set = $this->createSet('Bible');
|
||||||
$repository = app(ElementRepository::class);
|
$repository = app(ElementRepository::class);
|
||||||
$bookLevel = $this->createLevel($set, 'book');
|
|
||||||
$portionLevel = $this->createLevel($set, 'portion');
|
|
||||||
$genesisBook = $repository->create(new CreateElementDto(
|
$genesisBook = $repository->create(new CreateElementDto(
|
||||||
|
set: $set,
|
||||||
name: 'Genesis',
|
name: 'Genesis',
|
||||||
level: $bookLevel,
|
kind: 'book',
|
||||||
parentElement: null,
|
parentElement: null,
|
||||||
));
|
));
|
||||||
$exodusBook = $repository->create(new CreateElementDto(
|
$exodusBook = $repository->create(new CreateElementDto(
|
||||||
|
set: $set,
|
||||||
name: 'Exodus',
|
name: 'Exodus',
|
||||||
level: $bookLevel,
|
kind: 'book',
|
||||||
parentElement: null,
|
parentElement: null,
|
||||||
));
|
));
|
||||||
$genesisPortion = $repository->create(new CreateElementDto(
|
$genesisPortion = $repository->create(new CreateElementDto(
|
||||||
|
set: $set,
|
||||||
name: 'Genesis',
|
name: 'Genesis',
|
||||||
level: $portionLevel,
|
kind: 'portion',
|
||||||
parentElement: $genesisBook,
|
parentElement: $genesisBook,
|
||||||
));
|
));
|
||||||
$noahPortion = $repository->create(new CreateElementDto(
|
$noahPortion = $repository->create(new CreateElementDto(
|
||||||
|
set: $set,
|
||||||
name: 'Noah',
|
name: 'Noah',
|
||||||
level: $portionLevel,
|
kind: 'portion',
|
||||||
parentElement: $genesisBook,
|
parentElement: $genesisBook,
|
||||||
));
|
));
|
||||||
|
|
||||||
|
|
@ -54,8 +53,9 @@ class EloquentElementRepositoryTest extends TestCase
|
||||||
$this->assertSame(2, $noahPortion->getPosition());
|
$this->assertSame(2, $noahPortion->getPosition());
|
||||||
$this->assertDatabaseHas('elements', [
|
$this->assertDatabaseHas('elements', [
|
||||||
'id' => $genesisPortion->getId(),
|
'id' => $genesisPortion->getId(),
|
||||||
'set_level_id' => $portionLevel->getId(),
|
'set_id' => $set->getId(),
|
||||||
'name' => 'Genesis',
|
'name' => 'Genesis',
|
||||||
|
'kind' => 'portion',
|
||||||
'parent_element_id' => $genesisBook->getId(),
|
'parent_element_id' => $genesisBook->getId(),
|
||||||
'position' => 1,
|
'position' => 1,
|
||||||
]);
|
]);
|
||||||
|
|
@ -65,10 +65,6 @@ class EloquentElementRepositoryTest extends TestCase
|
||||||
$this->assertNotNull($foundElement);
|
$this->assertNotNull($foundElement);
|
||||||
$this->assertSame('Noah', $foundElement->getName());
|
$this->assertSame('Noah', $foundElement->getName());
|
||||||
$this->assertSame('portion', $foundElement->getKind());
|
$this->assertSame('portion', $foundElement->getKind());
|
||||||
$this->assertSame(
|
|
||||||
$portionLevel->getId(),
|
|
||||||
$foundElement->getLevel()->getId(),
|
|
||||||
);
|
|
||||||
$this->assertSame($set->getId(), $foundElement->getSet()->getId());
|
$this->assertSame($set->getId(), $foundElement->getSet()->getId());
|
||||||
$this->assertSame(
|
$this->assertSame(
|
||||||
$genesisBook->getId(),
|
$genesisBook->getId(),
|
||||||
|
|
@ -81,11 +77,10 @@ class EloquentElementRepositoryTest extends TestCase
|
||||||
$bible = $this->createSet('Bible');
|
$bible = $this->createSet('Bible');
|
||||||
$course = $this->createSet('Course');
|
$course = $this->createSet('Course');
|
||||||
$repository = app(ElementRepository::class);
|
$repository = app(ElementRepository::class);
|
||||||
$bookLevel = $this->createLevel($bible, 'book');
|
|
||||||
$moduleLevel = $this->createLevel($course, 'module');
|
|
||||||
$book = $repository->create(new CreateElementDto(
|
$book = $repository->create(new CreateElementDto(
|
||||||
|
set: $bible,
|
||||||
name: 'Genesis',
|
name: 'Genesis',
|
||||||
level: $bookLevel,
|
kind: 'book',
|
||||||
parentElement: null,
|
parentElement: null,
|
||||||
));
|
));
|
||||||
|
|
||||||
|
|
@ -95,51 +90,9 @@ class EloquentElementRepositoryTest extends TestCase
|
||||||
);
|
);
|
||||||
|
|
||||||
$repository->create(new CreateElementDto(
|
$repository->create(new CreateElementDto(
|
||||||
|
set: $course,
|
||||||
name: 'Invalid lesson',
|
name: 'Invalid lesson',
|
||||||
level: $moduleLevel,
|
kind: 'lesson',
|
||||||
parentElement: $book,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testItRejectsARootOutsideTheFirstLevel(): void
|
|
||||||
{
|
|
||||||
$set = $this->createSet('Bible');
|
|
||||||
$this->createLevel($set, 'book');
|
|
||||||
$portionLevel = $this->createLevel($set, 'portion');
|
|
||||||
|
|
||||||
$this->expectException(DomainException::class);
|
|
||||||
$this->expectExceptionMessage(
|
|
||||||
'root element must use the first set level',
|
|
||||||
);
|
|
||||||
|
|
||||||
app(ElementRepository::class)->create(new CreateElementDto(
|
|
||||||
name: 'Creation',
|
|
||||||
level: $portionLevel,
|
|
||||||
parentElement: null,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testItRejectsAChildOutsideTheNextLevel(): void
|
|
||||||
{
|
|
||||||
$set = $this->createSet('Bible');
|
|
||||||
$bookLevel = $this->createLevel($set, 'book');
|
|
||||||
$this->createLevel($set, 'portion');
|
|
||||||
$chapterLevel = $this->createLevel($set, 'chapter');
|
|
||||||
$repository = app(ElementRepository::class);
|
|
||||||
$book = $repository->create(new CreateElementDto(
|
|
||||||
name: 'Genesis',
|
|
||||||
level: $bookLevel,
|
|
||||||
parentElement: null,
|
|
||||||
));
|
|
||||||
|
|
||||||
$this->expectException(DomainException::class);
|
|
||||||
$this->expectExceptionMessage(
|
|
||||||
'child element must use the next set level',
|
|
||||||
);
|
|
||||||
|
|
||||||
$repository->create(new CreateElementDto(
|
|
||||||
name: 'Chapter 1',
|
|
||||||
level: $chapterLevel,
|
|
||||||
parentElement: $book,
|
parentElement: $book,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
@ -149,37 +102,40 @@ class EloquentElementRepositoryTest extends TestCase
|
||||||
$bible = $this->createSet('Bible');
|
$bible = $this->createSet('Bible');
|
||||||
$course = $this->createSet('Course');
|
$course = $this->createSet('Course');
|
||||||
$repository = app(ElementRepository::class);
|
$repository = app(ElementRepository::class);
|
||||||
$bookLevel = $this->createLevel($bible, 'book');
|
|
||||||
$portionLevel = $this->createLevel($bible, 'portion');
|
|
||||||
$moduleLevel = $this->createLevel($course, 'module');
|
|
||||||
$genesisBook = $repository->create(new CreateElementDto(
|
$genesisBook = $repository->create(new CreateElementDto(
|
||||||
|
set: $bible,
|
||||||
name: 'Genesis',
|
name: 'Genesis',
|
||||||
level: $bookLevel,
|
kind: 'book',
|
||||||
parentElement: null,
|
parentElement: null,
|
||||||
));
|
));
|
||||||
$exodusBook = $repository->create(new CreateElementDto(
|
$exodusBook = $repository->create(new CreateElementDto(
|
||||||
|
set: $bible,
|
||||||
name: 'Exodus',
|
name: 'Exodus',
|
||||||
level: $bookLevel,
|
kind: 'book',
|
||||||
parentElement: null,
|
parentElement: null,
|
||||||
));
|
));
|
||||||
$repository->create(new CreateElementDto(
|
$repository->create(new CreateElementDto(
|
||||||
|
set: $course,
|
||||||
name: 'Module 1',
|
name: 'Module 1',
|
||||||
level: $moduleLevel,
|
kind: 'module',
|
||||||
parentElement: null,
|
parentElement: null,
|
||||||
));
|
));
|
||||||
$repository->create(new CreateElementDto(
|
$repository->create(new CreateElementDto(
|
||||||
|
set: $bible,
|
||||||
name: 'Genesis',
|
name: 'Genesis',
|
||||||
level: $portionLevel,
|
kind: 'portion',
|
||||||
parentElement: $genesisBook,
|
parentElement: $genesisBook,
|
||||||
));
|
));
|
||||||
$repository->create(new CreateElementDto(
|
$repository->create(new CreateElementDto(
|
||||||
|
set: $bible,
|
||||||
name: 'Noah',
|
name: 'Noah',
|
||||||
level: $portionLevel,
|
kind: 'portion',
|
||||||
parentElement: $genesisBook,
|
parentElement: $genesisBook,
|
||||||
));
|
));
|
||||||
$repository->create(new CreateElementDto(
|
$repository->create(new CreateElementDto(
|
||||||
|
set: $bible,
|
||||||
name: 'Shemot',
|
name: 'Shemot',
|
||||||
level: $portionLevel,
|
kind: 'portion',
|
||||||
parentElement: $exodusBook,
|
parentElement: $exodusBook,
|
||||||
));
|
));
|
||||||
|
|
||||||
|
|
@ -205,22 +161,22 @@ class EloquentElementRepositoryTest extends TestCase
|
||||||
$bible = $this->createSet('Bible');
|
$bible = $this->createSet('Bible');
|
||||||
$course = $this->createSet('Course');
|
$course = $this->createSet('Course');
|
||||||
$repository = app(ElementRepository::class);
|
$repository = app(ElementRepository::class);
|
||||||
$bookLevel = $this->createLevel($bible, 'book');
|
|
||||||
$portionLevel = $this->createLevel($bible, 'portion');
|
|
||||||
$moduleLevel = $this->createLevel($course, 'module');
|
|
||||||
$genesis = $repository->create(new CreateElementDto(
|
$genesis = $repository->create(new CreateElementDto(
|
||||||
|
set: $bible,
|
||||||
name: 'Genesis',
|
name: 'Genesis',
|
||||||
level: $bookLevel,
|
kind: 'book',
|
||||||
parentElement: null,
|
parentElement: null,
|
||||||
));
|
));
|
||||||
$repository->create(new CreateElementDto(
|
$repository->create(new CreateElementDto(
|
||||||
|
set: $bible,
|
||||||
name: 'Creation',
|
name: 'Creation',
|
||||||
level: $portionLevel,
|
kind: 'portion',
|
||||||
parentElement: $genesis,
|
parentElement: $genesis,
|
||||||
));
|
));
|
||||||
$repository->create(new CreateElementDto(
|
$repository->create(new CreateElementDto(
|
||||||
|
set: $course,
|
||||||
name: 'Foundations',
|
name: 'Foundations',
|
||||||
level: $moduleLevel,
|
kind: 'module',
|
||||||
parentElement: null,
|
parentElement: null,
|
||||||
));
|
));
|
||||||
|
|
||||||
|
|
@ -251,12 +207,4 @@ class EloquentElementRepositoryTest extends TestCase
|
||||||
creator: $creator,
|
creator: $creator,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
private function createLevel(Set $set, string $kind): SetLevel
|
|
||||||
{
|
|
||||||
return app(SetLevelRepository::class)->create(new CreateSetLevelDto(
|
|
||||||
set: $set,
|
|
||||||
kind: $kind,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,391 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace Tests\Feature\Schedule;
|
|
||||||
|
|
||||||
use App\Auth\CreateSessionDto;
|
|
||||||
use App\Auth\SessionRepository;
|
|
||||||
use App\Element\CreateElementDto;
|
|
||||||
use App\Element\ElementModel;
|
|
||||||
use App\Element\ElementRepository;
|
|
||||||
use App\Http\Middleware\AuthMiddleware;
|
|
||||||
use App\Set\CreateSetDto;
|
|
||||||
use App\Set\CreateSetLevelDto;
|
|
||||||
use App\Set\Set;
|
|
||||||
use App\Set\SetLevel;
|
|
||||||
use App\Set\SetLevelModel;
|
|
||||||
use App\Set\SetLevelRepository;
|
|
||||||
use App\Set\SetModel;
|
|
||||||
use App\Set\SetRepository;
|
|
||||||
use App\Shared\ValueObject\EmailAddress;
|
|
||||||
use App\User\CreateUserDto;
|
|
||||||
use App\User\User;
|
|
||||||
use App\User\UserRepository;
|
|
||||||
use DateTimeImmutable;
|
|
||||||
use DateTimeZone;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
use Illuminate\Testing\TestResponse;
|
|
||||||
use Tests\TestCase;
|
|
||||||
|
|
||||||
class ScheduleEndpointTest extends TestCase
|
|
||||||
{
|
|
||||||
use RefreshDatabase;
|
|
||||||
|
|
||||||
public function test_it_creates_and_returns_a_schedule(): void
|
|
||||||
{
|
|
||||||
$user = $this->createUser('reader@example.com');
|
|
||||||
$creator = $this->createUser('creator@example.com');
|
|
||||||
$set = $this->createSet($creator, 'Bible');
|
|
||||||
$bookLevel = $this->createLevel($set, 'book');
|
|
||||||
$portionLevel = $this->createLevel($set, 'portion');
|
|
||||||
$chapterLevel = $this->createLevel($set, 'chapter');
|
|
||||||
$repository = app(ElementRepository::class);
|
|
||||||
$genesis = $repository->create(new CreateElementDto(
|
|
||||||
name: 'Genesis',
|
|
||||||
level: $bookLevel,
|
|
||||||
parentElement: null,
|
|
||||||
));
|
|
||||||
$creation = $repository->create(new CreateElementDto(
|
|
||||||
name: 'Creation',
|
|
||||||
level: $portionLevel,
|
|
||||||
parentElement: $genesis,
|
|
||||||
));
|
|
||||||
$repository->create(new CreateElementDto(
|
|
||||||
name: 'Chapter 1',
|
|
||||||
level: $chapterLevel,
|
|
||||||
parentElement: $creation,
|
|
||||||
));
|
|
||||||
$exodus = $repository->create(new CreateElementDto(
|
|
||||||
name: 'Exodus',
|
|
||||||
level: $bookLevel,
|
|
||||||
parentElement: null,
|
|
||||||
));
|
|
||||||
$shemot = $repository->create(new CreateElementDto(
|
|
||||||
name: 'Shemot',
|
|
||||||
level: $portionLevel,
|
|
||||||
parentElement: $exodus,
|
|
||||||
));
|
|
||||||
$repository->create(new CreateElementDto(
|
|
||||||
name: 'Chapter 1',
|
|
||||||
level: $chapterLevel,
|
|
||||||
parentElement: $shemot,
|
|
||||||
));
|
|
||||||
$this->createSession($user, 'valid-token');
|
|
||||||
|
|
||||||
$response = $this->credentialedPost('/api/schedules', [
|
|
||||||
'setId' => $set->getId(),
|
|
||||||
'levelId' => $chapterLevel->getId(),
|
|
||||||
'startDate' => '2026-08-10',
|
|
||||||
'targetDate' => '2026-08-12',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertCreated()->assertExactJson([
|
|
||||||
'schedule' => [
|
|
||||||
'id' => 1,
|
|
||||||
'set' => [
|
|
||||||
'name' => 'Bible',
|
|
||||||
],
|
|
||||||
'elementKind' => 'chapter',
|
|
||||||
'startDate' => '2026-08-10',
|
|
||||||
'targetDate' => '2026-08-12',
|
|
||||||
'assignmentCount' => 2,
|
|
||||||
'days' => [
|
|
||||||
[
|
|
||||||
'date' => '2026-08-10',
|
|
||||||
'assignments' => [
|
|
||||||
[
|
|
||||||
'id' => 1,
|
|
||||||
'element' => [
|
|
||||||
'name' => 'Chapter 1',
|
|
||||||
'kind' => 'chapter',
|
|
||||||
'path' => [
|
|
||||||
'Genesis',
|
|
||||||
'Creation',
|
|
||||||
'Chapter 1',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'date' => '2026-08-11',
|
|
||||||
'assignments' => [],
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'date' => '2026-08-12',
|
|
||||||
'assignments' => [
|
|
||||||
[
|
|
||||||
'id' => 2,
|
|
||||||
'element' => [
|
|
||||||
'name' => 'Chapter 1',
|
|
||||||
'kind' => 'chapter',
|
|
||||||
'path' => [
|
|
||||||
'Exodus',
|
|
||||||
'Shemot',
|
|
||||||
'Chapter 1',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
$this->assertDatabaseHas('schedules', [
|
|
||||||
'user_id' => $user->getId(),
|
|
||||||
'set_name' => 'Bible',
|
|
||||||
'element_kind' => 'chapter',
|
|
||||||
'start_date' => '2026-08-10',
|
|
||||||
'target_date' => '2026-08-12',
|
|
||||||
]);
|
|
||||||
$this->assertDatabaseHas('schedule_assignments', [
|
|
||||||
'schedule_id' => 1,
|
|
||||||
'element_name' => 'Chapter 1',
|
|
||||||
'element_kind' => 'chapter',
|
|
||||||
'element_path' => json_encode([
|
|
||||||
'Genesis',
|
|
||||||
'Creation',
|
|
||||||
'Chapter 1',
|
|
||||||
]),
|
|
||||||
'scheduled_date' => '2026-08-10',
|
|
||||||
'position' => 1,
|
|
||||||
]);
|
|
||||||
$this->assertDatabaseCount('schedule_assignments', 2);
|
|
||||||
|
|
||||||
$this->credentialedGet('/api/schedules/1')
|
|
||||||
->assertOk()
|
|
||||||
->assertExactJson($response->json());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_it_lists_only_the_users_schedules_newest_first(): void
|
|
||||||
{
|
|
||||||
$user = $this->createUser('reader@example.com');
|
|
||||||
$otherUser = $this->createUser('other@example.com');
|
|
||||||
$set = $this->createSet($user, 'Course');
|
|
||||||
$lessonLevel = $this->createLevel($set, 'lesson');
|
|
||||||
app(ElementRepository::class)->create(new CreateElementDto(
|
|
||||||
name: 'Welcome',
|
|
||||||
level: $lessonLevel,
|
|
||||||
parentElement: null,
|
|
||||||
));
|
|
||||||
$this->createSession($user, 'valid-token');
|
|
||||||
$this->createSession($otherUser, 'other-token');
|
|
||||||
|
|
||||||
$this->credentialedPost('/api/schedules', [
|
|
||||||
'setId' => $set->getId(),
|
|
||||||
'levelId' => $lessonLevel->getId(),
|
|
||||||
'startDate' => '2026-08-01',
|
|
||||||
'targetDate' => '2026-08-01',
|
|
||||||
])->assertCreated();
|
|
||||||
$this->credentialedPost('/api/schedules', [
|
|
||||||
'setId' => $set->getId(),
|
|
||||||
'levelId' => $lessonLevel->getId(),
|
|
||||||
'startDate' => '2026-09-01',
|
|
||||||
'targetDate' => '2026-09-01',
|
|
||||||
])->assertCreated();
|
|
||||||
$this->withCredentials()
|
|
||||||
->withUnencryptedCookie(
|
|
||||||
AuthMiddleware::COOKIE_NAME,
|
|
||||||
'other-token',
|
|
||||||
)->postJson('/api/schedules', [
|
|
||||||
'setId' => $set->getId(),
|
|
||||||
'levelId' => $lessonLevel->getId(),
|
|
||||||
'startDate' => '2026-10-01',
|
|
||||||
'targetDate' => '2026-10-01',
|
|
||||||
])->assertCreated();
|
|
||||||
|
|
||||||
$this->credentialedGet('/api/schedules')
|
|
||||||
->assertOk()
|
|
||||||
->assertExactJson([
|
|
||||||
'schedules' => [
|
|
||||||
[
|
|
||||||
'id' => 2,
|
|
||||||
'set' => [
|
|
||||||
'name' => 'Course',
|
|
||||||
],
|
|
||||||
'elementKind' => 'lesson',
|
|
||||||
'startDate' => '2026-09-01',
|
|
||||||
'targetDate' => '2026-09-01',
|
|
||||||
'assignmentCount' => 1,
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'id' => 1,
|
|
||||||
'set' => [
|
|
||||||
'name' => 'Course',
|
|
||||||
],
|
|
||||||
'elementKind' => 'lesson',
|
|
||||||
'startDate' => '2026-08-01',
|
|
||||||
'targetDate' => '2026-08-01',
|
|
||||||
'assignmentCount' => 1,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_it_is_stable_after_sources_change_or_are_deleted(): void
|
|
||||||
{
|
|
||||||
$user = $this->createUser('reader@example.com');
|
|
||||||
$set = $this->createSet($user, 'Original set');
|
|
||||||
$groupLevel = $this->createLevel($set, 'group');
|
|
||||||
$taskLevel = $this->createLevel($set, 'task');
|
|
||||||
$repository = app(ElementRepository::class);
|
|
||||||
$parent = $repository->create(new CreateElementDto(
|
|
||||||
name: 'Original parent',
|
|
||||||
level: $groupLevel,
|
|
||||||
parentElement: null,
|
|
||||||
));
|
|
||||||
$element = $repository->create(new CreateElementDto(
|
|
||||||
name: 'Original item',
|
|
||||||
level: $taskLevel,
|
|
||||||
parentElement: $parent,
|
|
||||||
));
|
|
||||||
$this->createSession($user, 'valid-token');
|
|
||||||
$createdResponse = $this->credentialedPost('/api/schedules', [
|
|
||||||
'setId' => $set->getId(),
|
|
||||||
'levelId' => $taskLevel->getId(),
|
|
||||||
'startDate' => '2026-08-10',
|
|
||||||
'targetDate' => '2026-08-10',
|
|
||||||
])->assertCreated();
|
|
||||||
|
|
||||||
SetModel::query()->whereKey($set->getId())->update([
|
|
||||||
'name' => 'Changed set',
|
|
||||||
]);
|
|
||||||
SetLevelModel::query()->whereKey($taskLevel->getId())->update([
|
|
||||||
'kind' => 'changed-kind',
|
|
||||||
]);
|
|
||||||
ElementModel::query()->whereKey($parent->getId())->update([
|
|
||||||
'name' => 'Changed parent',
|
|
||||||
]);
|
|
||||||
ElementModel::query()->whereKey($element->getId())->update([
|
|
||||||
'name' => 'Changed item',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->credentialedGet('/api/schedules/1')
|
|
||||||
->assertOk()
|
|
||||||
->assertExactJson($createdResponse->json());
|
|
||||||
|
|
||||||
ElementModel::query()->whereKey($element->getId())->delete();
|
|
||||||
ElementModel::query()->whereKey($parent->getId())->delete();
|
|
||||||
SetModel::query()->whereKey($set->getId())->delete();
|
|
||||||
|
|
||||||
$this->credentialedGet('/api/schedules/1')
|
|
||||||
->assertOk()
|
|
||||||
->assertExactJson($createdResponse->json());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_it_hides_another_users_schedule(): void
|
|
||||||
{
|
|
||||||
$owner = $this->createUser('owner@example.com');
|
|
||||||
$viewer = $this->createUser('viewer@example.com');
|
|
||||||
$set = $this->createSet($owner, 'Course');
|
|
||||||
$lessonLevel = $this->createLevel($set, 'lesson');
|
|
||||||
app(ElementRepository::class)->create(new CreateElementDto(
|
|
||||||
name: 'Welcome',
|
|
||||||
level: $lessonLevel,
|
|
||||||
parentElement: null,
|
|
||||||
));
|
|
||||||
$this->createSession($owner, 'owner-token');
|
|
||||||
$this->createSession($viewer, 'viewer-token');
|
|
||||||
|
|
||||||
$this->withCredentials()
|
|
||||||
->withUnencryptedCookie(
|
|
||||||
AuthMiddleware::COOKIE_NAME,
|
|
||||||
'owner-token',
|
|
||||||
)->postJson('/api/schedules', [
|
|
||||||
'setId' => $set->getId(),
|
|
||||||
'levelId' => $lessonLevel->getId(),
|
|
||||||
'startDate' => '2026-08-01',
|
|
||||||
'targetDate' => '2026-08-01',
|
|
||||||
])->assertCreated();
|
|
||||||
|
|
||||||
$this->withCredentials()
|
|
||||||
->withUnencryptedCookie(
|
|
||||||
AuthMiddleware::COOKIE_NAME,
|
|
||||||
'viewer-token',
|
|
||||||
)->getJson('/api/schedules/1')
|
|
||||||
->assertNotFound()
|
|
||||||
->assertExactJson(['error' => 'schedule not found']);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_it_rejects_invalid_creation_input(): void
|
|
||||||
{
|
|
||||||
$user = $this->createUser('reader@example.com');
|
|
||||||
$this->createSession($user, 'valid-token');
|
|
||||||
|
|
||||||
$this->credentialedPost('/api/schedules', [
|
|
||||||
'setId' => 999,
|
|
||||||
'levelId' => 1,
|
|
||||||
'startDate' => '2026-08-12',
|
|
||||||
'targetDate' => '2026-08-10',
|
|
||||||
])->assertNotFound()->assertExactJson([
|
|
||||||
'error' => 'set not found',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_schedule_endpoints_require_authentication(): void
|
|
||||||
{
|
|
||||||
$this->getJson('/api/schedules')->assertStatus(401);
|
|
||||||
$this->getJson('/api/schedules/1')->assertStatus(401);
|
|
||||||
$this->postJson('/api/schedules', [])->assertStatus(401);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function createUser(string $email): User
|
|
||||||
{
|
|
||||||
return app(UserRepository::class)->create(new CreateUserDto(
|
|
||||||
email: new EmailAddress($email),
|
|
||||||
passwordHash: 'hashed-password',
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
private function createSet(User $user, string $name): Set
|
|
||||||
{
|
|
||||||
return app(SetRepository::class)->create(new CreateSetDto(
|
|
||||||
name: $name,
|
|
||||||
creator: $user,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
private function createLevel(Set $set, string $kind): SetLevel
|
|
||||||
{
|
|
||||||
return app(SetLevelRepository::class)->create(new CreateSetLevelDto(
|
|
||||||
set: $set,
|
|
||||||
kind: $kind,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
private function createSession(User $user, string $token): void
|
|
||||||
{
|
|
||||||
$createdAt = new DateTimeImmutable(
|
|
||||||
'2026-08-03T12:00:00',
|
|
||||||
new DateTimeZone('UTC'),
|
|
||||||
);
|
|
||||||
app(SessionRepository::class)->create(new CreateSessionDto(
|
|
||||||
token: $token,
|
|
||||||
user: $user,
|
|
||||||
createdAt: $createdAt,
|
|
||||||
expiresAt: $createdAt->modify('+10 years'),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $payload
|
|
||||||
*/
|
|
||||||
private function credentialedPost(
|
|
||||||
string $uri,
|
|
||||||
array $payload,
|
|
||||||
): TestResponse {
|
|
||||||
return $this->withCredentials()
|
|
||||||
->withUnencryptedCookie(
|
|
||||||
AuthMiddleware::COOKIE_NAME,
|
|
||||||
'valid-token',
|
|
||||||
)->postJson($uri, $payload);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function credentialedGet(string $uri): TestResponse
|
|
||||||
{
|
|
||||||
return $this->withCredentials()
|
|
||||||
->withUnencryptedCookie(
|
|
||||||
AuthMiddleware::COOKIE_NAME,
|
|
||||||
'valid-token',
|
|
||||||
)->getJson($uri);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,92 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace Tests\Feature\Set;
|
|
||||||
|
|
||||||
use App\Set\CreateSetDto;
|
|
||||||
use App\Set\CreateSetLevelDto;
|
|
||||||
use App\Set\Set;
|
|
||||||
use App\Set\SetLevelRepository;
|
|
||||||
use App\Set\SetRepository;
|
|
||||||
use App\Shared\ValueObject\EmailAddress;
|
|
||||||
use App\User\CreateUserDto;
|
|
||||||
use App\User\UserRepository;
|
|
||||||
use DomainException;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
use Tests\TestCase;
|
|
||||||
|
|
||||||
class EloquentSetLevelRepositoryTest extends TestCase
|
|
||||||
{
|
|
||||||
use RefreshDatabase;
|
|
||||||
|
|
||||||
public function testItCreatesOrderedLevelsWithinEachSet(): void
|
|
||||||
{
|
|
||||||
$bible = $this->createSet('Bible');
|
|
||||||
$course = $this->createSet('Course');
|
|
||||||
$repository = app(SetLevelRepository::class);
|
|
||||||
|
|
||||||
$book = $repository->create(new CreateSetLevelDto(
|
|
||||||
set: $bible,
|
|
||||||
kind: 'book',
|
|
||||||
));
|
|
||||||
$portion = $repository->create(new CreateSetLevelDto(
|
|
||||||
set: $bible,
|
|
||||||
kind: 'portion',
|
|
||||||
));
|
|
||||||
$module = $repository->create(new CreateSetLevelDto(
|
|
||||||
set: $course,
|
|
||||||
kind: 'module',
|
|
||||||
));
|
|
||||||
|
|
||||||
$this->assertSame(0, $book->getDepth());
|
|
||||||
$this->assertSame(1, $portion->getDepth());
|
|
||||||
$this->assertSame(0, $module->getDepth());
|
|
||||||
$this->assertSame(
|
|
||||||
['book', 'portion'],
|
|
||||||
array_map(function ($level): string {
|
|
||||||
return $level->getKind();
|
|
||||||
}, $repository->findBySet($bible)),
|
|
||||||
);
|
|
||||||
$this->assertSame(
|
|
||||||
$portion->getId(),
|
|
||||||
$repository->find($portion->getId())?->getId(),
|
|
||||||
);
|
|
||||||
$this->assertDatabaseHas('set_levels', [
|
|
||||||
'set_id' => $bible->getId(),
|
|
||||||
'kind' => 'portion',
|
|
||||||
'depth' => 1,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testItRejectsARepeatedKindWithinASet(): void
|
|
||||||
{
|
|
||||||
$set = $this->createSet('Bible');
|
|
||||||
$repository = app(SetLevelRepository::class);
|
|
||||||
$repository->create(new CreateSetLevelDto(
|
|
||||||
set: $set,
|
|
||||||
kind: 'book',
|
|
||||||
));
|
|
||||||
|
|
||||||
$this->expectException(DomainException::class);
|
|
||||||
$this->expectExceptionMessage(
|
|
||||||
'level kind must be unique within set',
|
|
||||||
);
|
|
||||||
|
|
||||||
$repository->create(new CreateSetLevelDto(
|
|
||||||
set: $set,
|
|
||||||
kind: 'book',
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
private function createSet(string $name): Set
|
|
||||||
{
|
|
||||||
$user = app(UserRepository::class)->create(new CreateUserDto(
|
|
||||||
email: new EmailAddress(strtolower($name).'@example.com'),
|
|
||||||
passwordHash: 'hashed-password',
|
|
||||||
));
|
|
||||||
|
|
||||||
return app(SetRepository::class)->create(new CreateSetDto(
|
|
||||||
name: $name,
|
|
||||||
creator: $user,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -8,10 +8,7 @@ use App\Element\CreateElementDto;
|
||||||
use App\Element\ElementRepository;
|
use App\Element\ElementRepository;
|
||||||
use App\Http\Middleware\AuthMiddleware;
|
use App\Http\Middleware\AuthMiddleware;
|
||||||
use App\Set\CreateSetDto;
|
use App\Set\CreateSetDto;
|
||||||
use App\Set\CreateSetLevelDto;
|
|
||||||
use App\Set\Set;
|
use App\Set\Set;
|
||||||
use App\Set\SetLevel;
|
|
||||||
use App\Set\SetLevelRepository;
|
|
||||||
use App\Set\SetRepository;
|
use App\Set\SetRepository;
|
||||||
use App\Shared\ValueObject\EmailAddress;
|
use App\Shared\ValueObject\EmailAddress;
|
||||||
use App\User\CreateUserDto;
|
use App\User\CreateUserDto;
|
||||||
|
|
@ -32,27 +29,28 @@ class GetSetLayoutEndpointTest extends TestCase
|
||||||
$user = $this->createUser();
|
$user = $this->createUser();
|
||||||
$set = $this->createSet($user, 'Bible');
|
$set = $this->createSet($user, 'Bible');
|
||||||
$repository = app(ElementRepository::class);
|
$repository = app(ElementRepository::class);
|
||||||
$bookLevel = $this->createLevel($set, 'book');
|
|
||||||
$portionLevel = $this->createLevel($set, 'portion');
|
|
||||||
$chapterLevel = $this->createLevel($set, 'chapter');
|
|
||||||
$genesis = $repository->create(new CreateElementDto(
|
$genesis = $repository->create(new CreateElementDto(
|
||||||
|
set: $set,
|
||||||
name: 'Genesis',
|
name: 'Genesis',
|
||||||
level: $bookLevel,
|
kind: 'book',
|
||||||
parentElement: null,
|
parentElement: null,
|
||||||
));
|
));
|
||||||
$repository->create(new CreateElementDto(
|
$repository->create(new CreateElementDto(
|
||||||
|
set: $set,
|
||||||
name: 'Exodus',
|
name: 'Exodus',
|
||||||
level: $bookLevel,
|
kind: 'book',
|
||||||
parentElement: null,
|
parentElement: null,
|
||||||
));
|
));
|
||||||
$creation = $repository->create(new CreateElementDto(
|
$creation = $repository->create(new CreateElementDto(
|
||||||
|
set: $set,
|
||||||
name: 'Creation',
|
name: 'Creation',
|
||||||
level: $portionLevel,
|
kind: 'portion',
|
||||||
parentElement: $genesis,
|
parentElement: $genesis,
|
||||||
));
|
));
|
||||||
$chapter = $repository->create(new CreateElementDto(
|
$chapter = $repository->create(new CreateElementDto(
|
||||||
|
set: $set,
|
||||||
name: 'Chapter 1',
|
name: 'Chapter 1',
|
||||||
level: $chapterLevel,
|
kind: 'chapter',
|
||||||
parentElement: $creation,
|
parentElement: $creation,
|
||||||
));
|
));
|
||||||
$this->createSession($user);
|
$this->createSession($user);
|
||||||
|
|
@ -64,44 +62,21 @@ class GetSetLayoutEndpointTest extends TestCase
|
||||||
'id' => $set->getId(),
|
'id' => $set->getId(),
|
||||||
'name' => 'Bible',
|
'name' => 'Bible',
|
||||||
],
|
],
|
||||||
'levels' => [
|
|
||||||
[
|
|
||||||
'id' => $bookLevel->getId(),
|
|
||||||
'kind' => 'book',
|
|
||||||
'depth' => 0,
|
|
||||||
'elementCount' => 2,
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'id' => $portionLevel->getId(),
|
|
||||||
'kind' => 'portion',
|
|
||||||
'depth' => 1,
|
|
||||||
'elementCount' => 1,
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'id' => $chapterLevel->getId(),
|
|
||||||
'kind' => 'chapter',
|
|
||||||
'depth' => 2,
|
|
||||||
'elementCount' => 1,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
'elements' => [
|
'elements' => [
|
||||||
[
|
[
|
||||||
'id' => $genesis->getId(),
|
'id' => $genesis->getId(),
|
||||||
'name' => 'Genesis',
|
'name' => 'Genesis',
|
||||||
'kind' => 'book',
|
'kind' => 'book',
|
||||||
'levelId' => $bookLevel->getId(),
|
|
||||||
'children' => [
|
'children' => [
|
||||||
[
|
[
|
||||||
'id' => $creation->getId(),
|
'id' => $creation->getId(),
|
||||||
'name' => 'Creation',
|
'name' => 'Creation',
|
||||||
'kind' => 'portion',
|
'kind' => 'portion',
|
||||||
'levelId' => $portionLevel->getId(),
|
|
||||||
'children' => [
|
'children' => [
|
||||||
[
|
[
|
||||||
'id' => $chapter->getId(),
|
'id' => $chapter->getId(),
|
||||||
'name' => 'Chapter 1',
|
'name' => 'Chapter 1',
|
||||||
'kind' => 'chapter',
|
'kind' => 'chapter',
|
||||||
'levelId' => $chapterLevel->getId(),
|
|
||||||
'children' => [],
|
'children' => [],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
@ -112,7 +87,6 @@ class GetSetLayoutEndpointTest extends TestCase
|
||||||
'id' => $genesis->getId() + 1,
|
'id' => $genesis->getId() + 1,
|
||||||
'name' => 'Exodus',
|
'name' => 'Exodus',
|
||||||
'kind' => 'book',
|
'kind' => 'book',
|
||||||
'levelId' => $bookLevel->getId(),
|
|
||||||
'children' => [],
|
'children' => [],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
@ -123,7 +97,6 @@ class GetSetLayoutEndpointTest extends TestCase
|
||||||
{
|
{
|
||||||
$user = $this->createUser();
|
$user = $this->createUser();
|
||||||
$set = $this->createSet($user, 'Empty set');
|
$set = $this->createSet($user, 'Empty set');
|
||||||
$taskLevel = $this->createLevel($set, 'task');
|
|
||||||
$this->createSession($user);
|
$this->createSession($user);
|
||||||
|
|
||||||
$response = $this->credentialedGet("/api/sets/{$set->getId()}");
|
$response = $this->credentialedGet("/api/sets/{$set->getId()}");
|
||||||
|
|
@ -133,12 +106,6 @@ class GetSetLayoutEndpointTest extends TestCase
|
||||||
'id' => $set->getId(),
|
'id' => $set->getId(),
|
||||||
'name' => 'Empty set',
|
'name' => 'Empty set',
|
||||||
],
|
],
|
||||||
'levels' => [[
|
|
||||||
'id' => $taskLevel->getId(),
|
|
||||||
'kind' => 'task',
|
|
||||||
'depth' => 0,
|
|
||||||
'elementCount' => 0,
|
|
||||||
]],
|
|
||||||
'elements' => [],
|
'elements' => [],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
@ -180,14 +147,6 @@ class GetSetLayoutEndpointTest extends TestCase
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
private function createLevel(Set $set, string $kind): SetLevel
|
|
||||||
{
|
|
||||||
return app(SetLevelRepository::class)->create(new CreateSetLevelDto(
|
|
||||||
set: $set,
|
|
||||||
kind: $kind,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
private function createSession(User $user): void
|
private function createSession(User $user): void
|
||||||
{
|
{
|
||||||
$createdAt = new DateTimeImmutable(
|
$createdAt = new DateTimeImmutable(
|
||||||
|
|
@ -198,7 +157,7 @@ class GetSetLayoutEndpointTest extends TestCase
|
||||||
token: 'valid-token',
|
token: 'valid-token',
|
||||||
user: $user,
|
user: $user,
|
||||||
createdAt: $createdAt,
|
createdAt: $createdAt,
|
||||||
expiresAt: $createdAt->modify('+10 years'),
|
expiresAt: $createdAt->modify('+7 days'),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -96,7 +96,7 @@ class ListSetsEndpointTest extends TestCase
|
||||||
token: 'valid-token',
|
token: 'valid-token',
|
||||||
user: $user,
|
user: $user,
|
||||||
createdAt: $createdAt,
|
createdAt: $createdAt,
|
||||||
expiresAt: $createdAt->modify('+10 years'),
|
expiresAt: $createdAt->modify('+7 days'),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ namespace Tests\Unit\Element;
|
||||||
|
|
||||||
use App\Element\Element;
|
use App\Element\Element;
|
||||||
use App\Set\Set;
|
use App\Set\Set;
|
||||||
use App\Set\SetLevel;
|
|
||||||
use App\Shared\ValueObject\EmailAddress;
|
use App\Shared\ValueObject\EmailAddress;
|
||||||
use App\User\User;
|
use App\User\User;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
@ -23,29 +22,19 @@ class ElementTest extends TestCase
|
||||||
name: 'Bible',
|
name: 'Bible',
|
||||||
creator: $creator,
|
creator: $creator,
|
||||||
);
|
);
|
||||||
$bookLevel = new SetLevel(
|
|
||||||
id: 31,
|
|
||||||
set: $set,
|
|
||||||
kind: 'book',
|
|
||||||
depth: 0,
|
|
||||||
);
|
|
||||||
$portionLevel = new SetLevel(
|
|
||||||
id: 32,
|
|
||||||
set: $set,
|
|
||||||
kind: 'portion',
|
|
||||||
depth: 1,
|
|
||||||
);
|
|
||||||
$parentElement = new Element(
|
$parentElement = new Element(
|
||||||
id: 21,
|
id: 21,
|
||||||
name: 'Genesis',
|
name: 'Genesis',
|
||||||
level: $bookLevel,
|
kind: 'book',
|
||||||
|
set: $set,
|
||||||
parentElement: null,
|
parentElement: null,
|
||||||
position: 1,
|
position: 1,
|
||||||
);
|
);
|
||||||
$element = new Element(
|
$element = new Element(
|
||||||
id: 22,
|
id: 22,
|
||||||
name: 'Genesis',
|
name: 'Genesis',
|
||||||
level: $portionLevel,
|
kind: 'portion',
|
||||||
|
set: $set,
|
||||||
parentElement: $parentElement,
|
parentElement: $parentElement,
|
||||||
position: 2,
|
position: 2,
|
||||||
);
|
);
|
||||||
|
|
@ -53,7 +42,6 @@ class ElementTest extends TestCase
|
||||||
$this->assertSame(22, $element->getId());
|
$this->assertSame(22, $element->getId());
|
||||||
$this->assertSame('Genesis', $element->getName());
|
$this->assertSame('Genesis', $element->getName());
|
||||||
$this->assertSame('portion', $element->getKind());
|
$this->assertSame('portion', $element->getKind());
|
||||||
$this->assertSame($portionLevel, $element->getLevel());
|
|
||||||
$this->assertSame($set, $element->getSet());
|
$this->assertSame($set, $element->getSet());
|
||||||
$this->assertSame($parentElement, $element->getParentElement());
|
$this->assertSame($parentElement, $element->getParentElement());
|
||||||
$this->assertSame(2, $element->getPosition());
|
$this->assertSame(2, $element->getPosition());
|
||||||
|
|
|
||||||
|
|
@ -1,441 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace Tests\Unit\Schedule\UseCases;
|
|
||||||
|
|
||||||
use App\Element\CreateElementDto;
|
|
||||||
use App\Exceptions\BadRequestException;
|
|
||||||
use App\Exceptions\NotFoundException;
|
|
||||||
use App\Schedule\UseCases\CreateSchedule\CreateSchedule;
|
|
||||||
use App\Schedule\UseCases\CreateSchedule\CreateScheduleRequest;
|
|
||||||
use App\Set\CreateSetDto;
|
|
||||||
use App\Set\CreateSetLevelDto;
|
|
||||||
use App\Set\Set;
|
|
||||||
use App\Set\SetLevel;
|
|
||||||
use App\Shared\ValueObject\EmailAddress;
|
|
||||||
use App\User\User;
|
|
||||||
use PHPUnit\Framework\TestCase;
|
|
||||||
use Tests\Fakes\FakeElementRepository;
|
|
||||||
use Tests\Fakes\FakeScheduleRepository;
|
|
||||||
use Tests\Fakes\FakeSetRepository;
|
|
||||||
use Tests\Fakes\FakeSetLevelRepository;
|
|
||||||
|
|
||||||
class CreateScheduleTest extends TestCase
|
|
||||||
{
|
|
||||||
public function test_it_schedules_every_element_on_the_selected_level(): void
|
|
||||||
{
|
|
||||||
$user = $this->user();
|
|
||||||
$setRepository = new FakeSetRepository;
|
|
||||||
$setLevelRepository = new FakeSetLevelRepository;
|
|
||||||
$elementRepository = new FakeElementRepository;
|
|
||||||
$scheduleRepository = new FakeScheduleRepository;
|
|
||||||
$set = $setRepository->create(new CreateSetDto(
|
|
||||||
name: 'Bible',
|
|
||||||
creator: $user,
|
|
||||||
));
|
|
||||||
$bookLevel = $this->createLevel(
|
|
||||||
$setLevelRepository,
|
|
||||||
$set,
|
|
||||||
'book',
|
|
||||||
);
|
|
||||||
$portionLevel = $this->createLevel(
|
|
||||||
$setLevelRepository,
|
|
||||||
$set,
|
|
||||||
'portion',
|
|
||||||
);
|
|
||||||
$chapterLevel = $this->createLevel(
|
|
||||||
$setLevelRepository,
|
|
||||||
$set,
|
|
||||||
'chapter',
|
|
||||||
);
|
|
||||||
$genesis = $elementRepository->create(new CreateElementDto(
|
|
||||||
name: 'Genesis',
|
|
||||||
level: $bookLevel,
|
|
||||||
parentElement: null,
|
|
||||||
));
|
|
||||||
$exodus = $elementRepository->create(new CreateElementDto(
|
|
||||||
name: 'Exodus',
|
|
||||||
level: $bookLevel,
|
|
||||||
parentElement: null,
|
|
||||||
));
|
|
||||||
$creation = $elementRepository->create(new CreateElementDto(
|
|
||||||
name: 'Creation',
|
|
||||||
level: $portionLevel,
|
|
||||||
parentElement: $genesis,
|
|
||||||
));
|
|
||||||
$shemot = $elementRepository->create(new CreateElementDto(
|
|
||||||
name: 'Shemot',
|
|
||||||
level: $portionLevel,
|
|
||||||
parentElement: $exodus,
|
|
||||||
));
|
|
||||||
$genesisChapterOne = $elementRepository->create(
|
|
||||||
new CreateElementDto(
|
|
||||||
name: 'Genesis 1',
|
|
||||||
level: $chapterLevel,
|
|
||||||
parentElement: $creation,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
$genesisChapterTwo = $elementRepository->create(
|
|
||||||
new CreateElementDto(
|
|
||||||
name: 'Genesis 2',
|
|
||||||
level: $chapterLevel,
|
|
||||||
parentElement: $creation,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
$genesisChapterThree = $elementRepository->create(
|
|
||||||
new CreateElementDto(
|
|
||||||
name: 'Genesis 3',
|
|
||||||
level: $chapterLevel,
|
|
||||||
parentElement: $creation,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
$exodusChapterOne = $elementRepository->create(
|
|
||||||
new CreateElementDto(
|
|
||||||
name: 'Exodus 1',
|
|
||||||
level: $chapterLevel,
|
|
||||||
parentElement: $shemot,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
$exodusChapterTwo = $elementRepository->create(
|
|
||||||
new CreateElementDto(
|
|
||||||
name: 'Exodus 2',
|
|
||||||
level: $chapterLevel,
|
|
||||||
parentElement: $shemot,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
$schedule = (new CreateSchedule(
|
|
||||||
$setRepository,
|
|
||||||
$setLevelRepository,
|
|
||||||
$elementRepository,
|
|
||||||
$scheduleRepository,
|
|
||||||
))->execute(new CreateScheduleRequest(
|
|
||||||
user: $user,
|
|
||||||
setId: $set->getId(),
|
|
||||||
levelId: $chapterLevel->getId(),
|
|
||||||
startDate: '2026-08-10',
|
|
||||||
targetDate: '2026-08-12',
|
|
||||||
));
|
|
||||||
|
|
||||||
$this->assertSame('chapter', $schedule->getElementKind());
|
|
||||||
$this->assertSame('2026-08-10', $schedule->getStartDate()->format(
|
|
||||||
'Y-m-d',
|
|
||||||
));
|
|
||||||
$this->assertSame('2026-08-12', $schedule->getTargetDate()->format(
|
|
||||||
'Y-m-d',
|
|
||||||
));
|
|
||||||
$this->assertSame(
|
|
||||||
[
|
|
||||||
$genesisChapterOne->getName(),
|
|
||||||
$genesisChapterTwo->getName(),
|
|
||||||
$genesisChapterThree->getName(),
|
|
||||||
$exodusChapterOne->getName(),
|
|
||||||
$exodusChapterTwo->getName(),
|
|
||||||
],
|
|
||||||
array_map(function ($assignment): string {
|
|
||||||
return $assignment->getName();
|
|
||||||
}, $schedule->getAssignments()),
|
|
||||||
);
|
|
||||||
$this->assertSame(
|
|
||||||
['Genesis', 'Creation', 'Genesis 1'],
|
|
||||||
$schedule->getAssignments()[0]->getPath(),
|
|
||||||
);
|
|
||||||
$this->assertSame(
|
|
||||||
[
|
|
||||||
'2026-08-10',
|
|
||||||
'2026-08-10',
|
|
||||||
'2026-08-11',
|
|
||||||
'2026-08-11',
|
|
||||||
'2026-08-12',
|
|
||||||
],
|
|
||||||
array_map(function ($assignment): string {
|
|
||||||
return $assignment->getScheduledDate()->format('Y-m-d');
|
|
||||||
}, $schedule->getAssignments()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_it_spreads_sparse_work_across_the_full_range(): void
|
|
||||||
{
|
|
||||||
$user = $this->user();
|
|
||||||
$setRepository = new FakeSetRepository;
|
|
||||||
$setLevelRepository = new FakeSetLevelRepository;
|
|
||||||
$elementRepository = new FakeElementRepository;
|
|
||||||
$set = $setRepository->create(new CreateSetDto(
|
|
||||||
name: 'Course',
|
|
||||||
creator: $user,
|
|
||||||
));
|
|
||||||
$lessonLevel = $this->createLevel(
|
|
||||||
$setLevelRepository,
|
|
||||||
$set,
|
|
||||||
'lesson',
|
|
||||||
);
|
|
||||||
|
|
||||||
foreach (['First', 'Second', 'Third'] as $name) {
|
|
||||||
$elementRepository->create(new CreateElementDto(
|
|
||||||
name: $name,
|
|
||||||
level: $lessonLevel,
|
|
||||||
parentElement: null,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
$schedule = (new CreateSchedule(
|
|
||||||
$setRepository,
|
|
||||||
$setLevelRepository,
|
|
||||||
$elementRepository,
|
|
||||||
new FakeScheduleRepository,
|
|
||||||
))->execute(new CreateScheduleRequest(
|
|
||||||
user: $user,
|
|
||||||
setId: $set->getId(),
|
|
||||||
levelId: $lessonLevel->getId(),
|
|
||||||
startDate: '2026-08-10',
|
|
||||||
targetDate: '2026-08-16',
|
|
||||||
));
|
|
||||||
|
|
||||||
$this->assertSame(
|
|
||||||
['2026-08-10', '2026-08-13', '2026-08-16'],
|
|
||||||
array_map(function ($assignment): string {
|
|
||||||
return $assignment->getScheduledDate()->format('Y-m-d');
|
|
||||||
}, $schedule->getAssignments()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_it_places_one_element_on_the_start_date(): void
|
|
||||||
{
|
|
||||||
$user = $this->user();
|
|
||||||
$setRepository = new FakeSetRepository;
|
|
||||||
$setLevelRepository = new FakeSetLevelRepository;
|
|
||||||
$elementRepository = new FakeElementRepository;
|
|
||||||
$set = $setRepository->create(new CreateSetDto(
|
|
||||||
name: 'Project',
|
|
||||||
creator: $user,
|
|
||||||
));
|
|
||||||
$milestoneLevel = $this->createLevel(
|
|
||||||
$setLevelRepository,
|
|
||||||
$set,
|
|
||||||
'milestone',
|
|
||||||
);
|
|
||||||
$elementRepository->create(new CreateElementDto(
|
|
||||||
name: 'Ship it',
|
|
||||||
level: $milestoneLevel,
|
|
||||||
parentElement: null,
|
|
||||||
));
|
|
||||||
|
|
||||||
$schedule = (new CreateSchedule(
|
|
||||||
$setRepository,
|
|
||||||
$setLevelRepository,
|
|
||||||
$elementRepository,
|
|
||||||
new FakeScheduleRepository,
|
|
||||||
))->execute(new CreateScheduleRequest(
|
|
||||||
user: $user,
|
|
||||||
setId: $set->getId(),
|
|
||||||
levelId: $milestoneLevel->getId(),
|
|
||||||
startDate: '2020-01-01',
|
|
||||||
targetDate: '2030-01-01',
|
|
||||||
));
|
|
||||||
|
|
||||||
$this->assertSame(
|
|
||||||
'2020-01-01',
|
|
||||||
$schedule->getAssignments()[0]
|
|
||||||
->getScheduledDate()
|
|
||||||
->format('Y-m-d'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_it_rejects_an_unknown_set(): void
|
|
||||||
{
|
|
||||||
$this->expectException(NotFoundException::class);
|
|
||||||
$this->expectExceptionMessage('set not found');
|
|
||||||
|
|
||||||
(new CreateSchedule(
|
|
||||||
new FakeSetRepository,
|
|
||||||
new FakeSetLevelRepository,
|
|
||||||
new FakeElementRepository,
|
|
||||||
new FakeScheduleRepository,
|
|
||||||
))->execute(new CreateScheduleRequest(
|
|
||||||
user: $this->user(),
|
|
||||||
setId: 999,
|
|
||||||
levelId: 1,
|
|
||||||
startDate: '2026-08-10',
|
|
||||||
targetDate: '2026-08-12',
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_it_rejects_a_missing_level(): void
|
|
||||||
{
|
|
||||||
$user = $this->user();
|
|
||||||
$setRepository = new FakeSetRepository;
|
|
||||||
$set = $setRepository->create(new CreateSetDto(
|
|
||||||
name: 'Bible',
|
|
||||||
creator: $user,
|
|
||||||
));
|
|
||||||
|
|
||||||
$this->expectException(BadRequestException::class);
|
|
||||||
$this->expectExceptionMessage('levelId is required');
|
|
||||||
|
|
||||||
(new CreateSchedule(
|
|
||||||
$setRepository,
|
|
||||||
new FakeSetLevelRepository,
|
|
||||||
new FakeElementRepository,
|
|
||||||
new FakeScheduleRepository,
|
|
||||||
))->execute(new CreateScheduleRequest(
|
|
||||||
user: $user,
|
|
||||||
setId: $set->getId(),
|
|
||||||
levelId: null,
|
|
||||||
startDate: '2026-08-10',
|
|
||||||
targetDate: '2026-08-12',
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_it_rejects_a_level_from_another_set(): void
|
|
||||||
{
|
|
||||||
$user = $this->user();
|
|
||||||
$setRepository = new FakeSetRepository;
|
|
||||||
$setLevelRepository = new FakeSetLevelRepository;
|
|
||||||
$bible = $setRepository->create(new CreateSetDto(
|
|
||||||
name: 'Bible',
|
|
||||||
creator: $user,
|
|
||||||
));
|
|
||||||
$course = $setRepository->create(new CreateSetDto(
|
|
||||||
name: 'Course',
|
|
||||||
creator: $user,
|
|
||||||
));
|
|
||||||
$moduleLevel = $this->createLevel(
|
|
||||||
$setLevelRepository,
|
|
||||||
$course,
|
|
||||||
'module',
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->expectException(BadRequestException::class);
|
|
||||||
$this->expectExceptionMessage('level is not available for set');
|
|
||||||
|
|
||||||
(new CreateSchedule(
|
|
||||||
$setRepository,
|
|
||||||
$setLevelRepository,
|
|
||||||
new FakeElementRepository,
|
|
||||||
new FakeScheduleRepository,
|
|
||||||
))->execute(new CreateScheduleRequest(
|
|
||||||
user: $user,
|
|
||||||
setId: $bible->getId(),
|
|
||||||
levelId: $moduleLevel->getId(),
|
|
||||||
startDate: '2026-08-10',
|
|
||||||
targetDate: '2026-08-12',
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_it_rejects_a_level_without_elements(): void
|
|
||||||
{
|
|
||||||
$user = $this->user();
|
|
||||||
$setRepository = new FakeSetRepository;
|
|
||||||
$setLevelRepository = new FakeSetLevelRepository;
|
|
||||||
$set = $setRepository->create(new CreateSetDto(
|
|
||||||
name: 'Bible',
|
|
||||||
creator: $user,
|
|
||||||
));
|
|
||||||
$chapterLevel = $this->createLevel(
|
|
||||||
$setLevelRepository,
|
|
||||||
$set,
|
|
||||||
'chapter',
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->expectException(BadRequestException::class);
|
|
||||||
$this->expectExceptionMessage('level has no elements');
|
|
||||||
|
|
||||||
(new CreateSchedule(
|
|
||||||
$setRepository,
|
|
||||||
$setLevelRepository,
|
|
||||||
new FakeElementRepository,
|
|
||||||
new FakeScheduleRepository,
|
|
||||||
))->execute(new CreateScheduleRequest(
|
|
||||||
user: $user,
|
|
||||||
setId: $set->getId(),
|
|
||||||
levelId: $chapterLevel->getId(),
|
|
||||||
startDate: '2026-08-10',
|
|
||||||
targetDate: '2026-08-12',
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_it_rejects_invalid_dates(): void
|
|
||||||
{
|
|
||||||
$user = $this->user();
|
|
||||||
$setRepository = new FakeSetRepository;
|
|
||||||
$setLevelRepository = new FakeSetLevelRepository;
|
|
||||||
$set = $setRepository->create(new CreateSetDto(
|
|
||||||
name: 'Bible',
|
|
||||||
creator: $user,
|
|
||||||
));
|
|
||||||
$chapterLevel = $this->createLevel(
|
|
||||||
$setLevelRepository,
|
|
||||||
$set,
|
|
||||||
'chapter',
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->expectException(BadRequestException::class);
|
|
||||||
$this->expectExceptionMessage('startDate must be a valid date');
|
|
||||||
|
|
||||||
(new CreateSchedule(
|
|
||||||
$setRepository,
|
|
||||||
$setLevelRepository,
|
|
||||||
new FakeElementRepository,
|
|
||||||
new FakeScheduleRepository,
|
|
||||||
))->execute(new CreateScheduleRequest(
|
|
||||||
user: $user,
|
|
||||||
setId: $set->getId(),
|
|
||||||
levelId: $chapterLevel->getId(),
|
|
||||||
startDate: '2026-02-30',
|
|
||||||
targetDate: '2026-08-12',
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_it_rejects_a_target_before_the_start(): void
|
|
||||||
{
|
|
||||||
$user = $this->user();
|
|
||||||
$setRepository = new FakeSetRepository;
|
|
||||||
$setLevelRepository = new FakeSetLevelRepository;
|
|
||||||
$set = $setRepository->create(new CreateSetDto(
|
|
||||||
name: 'Bible',
|
|
||||||
creator: $user,
|
|
||||||
));
|
|
||||||
$chapterLevel = $this->createLevel(
|
|
||||||
$setLevelRepository,
|
|
||||||
$set,
|
|
||||||
'chapter',
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->expectException(BadRequestException::class);
|
|
||||||
$this->expectExceptionMessage(
|
|
||||||
'targetDate must not be before startDate',
|
|
||||||
);
|
|
||||||
|
|
||||||
(new CreateSchedule(
|
|
||||||
$setRepository,
|
|
||||||
$setLevelRepository,
|
|
||||||
new FakeElementRepository,
|
|
||||||
new FakeScheduleRepository,
|
|
||||||
))->execute(new CreateScheduleRequest(
|
|
||||||
user: $user,
|
|
||||||
setId: $set->getId(),
|
|
||||||
levelId: $chapterLevel->getId(),
|
|
||||||
startDate: '2026-08-12',
|
|
||||||
targetDate: '2026-08-10',
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
private function createLevel(
|
|
||||||
FakeSetLevelRepository $repository,
|
|
||||||
Set $set,
|
|
||||||
string $kind,
|
|
||||||
): SetLevel {
|
|
||||||
return $repository->create(new CreateSetLevelDto(
|
|
||||||
set: $set,
|
|
||||||
kind: $kind,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
private function user(): User
|
|
||||||
{
|
|
||||||
return new User(
|
|
||||||
id: 7,
|
|
||||||
email: new EmailAddress('reader@example.com'),
|
|
||||||
passwordHash: 'hashed-password',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -5,7 +5,6 @@ namespace Tests\Unit\Set\UseCases;
|
||||||
use App\Element\CreateElementDto;
|
use App\Element\CreateElementDto;
|
||||||
use App\Exceptions\NotFoundException;
|
use App\Exceptions\NotFoundException;
|
||||||
use App\Set\CreateSetDto;
|
use App\Set\CreateSetDto;
|
||||||
use App\Set\CreateSetLevelDto;
|
|
||||||
use App\Set\UseCases\GetSetLayout\ElementLayoutNode;
|
use App\Set\UseCases\GetSetLayout\ElementLayoutNode;
|
||||||
use App\Set\UseCases\GetSetLayout\GetSetLayout;
|
use App\Set\UseCases\GetSetLayout\GetSetLayout;
|
||||||
use App\Set\UseCases\GetSetLayout\GetSetLayoutRequest;
|
use App\Set\UseCases\GetSetLayout\GetSetLayoutRequest;
|
||||||
|
|
@ -14,7 +13,6 @@ use App\User\User;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
use Tests\Fakes\FakeElementRepository;
|
use Tests\Fakes\FakeElementRepository;
|
||||||
use Tests\Fakes\FakeSetRepository;
|
use Tests\Fakes\FakeSetRepository;
|
||||||
use Tests\Fakes\FakeSetLevelRepository;
|
|
||||||
|
|
||||||
class GetSetLayoutTest extends TestCase
|
class GetSetLayoutTest extends TestCase
|
||||||
{
|
{
|
||||||
|
|
@ -26,7 +24,6 @@ class GetSetLayoutTest extends TestCase
|
||||||
passwordHash: 'hashed-password',
|
passwordHash: 'hashed-password',
|
||||||
);
|
);
|
||||||
$setRepository = new FakeSetRepository;
|
$setRepository = new FakeSetRepository;
|
||||||
$setLevelRepository = new FakeSetLevelRepository;
|
|
||||||
$elementRepository = new FakeElementRepository;
|
$elementRepository = new FakeElementRepository;
|
||||||
$bible = $setRepository->create(new CreateSetDto(
|
$bible = $setRepository->create(new CreateSetDto(
|
||||||
name: 'Bible',
|
name: 'Bible',
|
||||||
|
|
@ -36,69 +33,51 @@ class GetSetLayoutTest extends TestCase
|
||||||
name: 'Course',
|
name: 'Course',
|
||||||
creator: $creator,
|
creator: $creator,
|
||||||
));
|
));
|
||||||
$bookLevel = $setLevelRepository->create(new CreateSetLevelDto(
|
|
||||||
set: $bible,
|
|
||||||
kind: 'book',
|
|
||||||
));
|
|
||||||
$portionLevel = $setLevelRepository->create(new CreateSetLevelDto(
|
|
||||||
set: $bible,
|
|
||||||
kind: 'portion',
|
|
||||||
));
|
|
||||||
$chapterLevel = $setLevelRepository->create(new CreateSetLevelDto(
|
|
||||||
set: $bible,
|
|
||||||
kind: 'chapter',
|
|
||||||
));
|
|
||||||
$moduleLevel = $setLevelRepository->create(new CreateSetLevelDto(
|
|
||||||
set: $course,
|
|
||||||
kind: 'module',
|
|
||||||
));
|
|
||||||
$genesis = $elementRepository->create(new CreateElementDto(
|
$genesis = $elementRepository->create(new CreateElementDto(
|
||||||
|
set: $bible,
|
||||||
name: 'Genesis',
|
name: 'Genesis',
|
||||||
level: $bookLevel,
|
kind: 'book',
|
||||||
parentElement: null,
|
parentElement: null,
|
||||||
));
|
));
|
||||||
$elementRepository->create(new CreateElementDto(
|
$elementRepository->create(new CreateElementDto(
|
||||||
|
set: $bible,
|
||||||
name: 'Exodus',
|
name: 'Exodus',
|
||||||
level: $bookLevel,
|
kind: 'book',
|
||||||
parentElement: null,
|
parentElement: null,
|
||||||
));
|
));
|
||||||
$creation = $elementRepository->create(new CreateElementDto(
|
$creation = $elementRepository->create(new CreateElementDto(
|
||||||
|
set: $bible,
|
||||||
name: 'Creation',
|
name: 'Creation',
|
||||||
level: $portionLevel,
|
kind: 'portion',
|
||||||
parentElement: $genesis,
|
parentElement: $genesis,
|
||||||
));
|
));
|
||||||
$elementRepository->create(new CreateElementDto(
|
$elementRepository->create(new CreateElementDto(
|
||||||
|
set: $bible,
|
||||||
name: 'Noah',
|
name: 'Noah',
|
||||||
level: $portionLevel,
|
kind: 'portion',
|
||||||
parentElement: $genesis,
|
parentElement: $genesis,
|
||||||
));
|
));
|
||||||
$elementRepository->create(new CreateElementDto(
|
$elementRepository->create(new CreateElementDto(
|
||||||
|
set: $bible,
|
||||||
name: 'Chapter 1',
|
name: 'Chapter 1',
|
||||||
level: $chapterLevel,
|
kind: 'chapter',
|
||||||
parentElement: $creation,
|
parentElement: $creation,
|
||||||
));
|
));
|
||||||
$elementRepository->create(new CreateElementDto(
|
$elementRepository->create(new CreateElementDto(
|
||||||
|
set: $course,
|
||||||
name: 'Foundations',
|
name: 'Foundations',
|
||||||
level: $moduleLevel,
|
kind: 'module',
|
||||||
parentElement: null,
|
parentElement: null,
|
||||||
));
|
));
|
||||||
|
|
||||||
$layout = (new GetSetLayout(
|
$layout = (new GetSetLayout(
|
||||||
$setRepository,
|
$setRepository,
|
||||||
$setLevelRepository,
|
|
||||||
$elementRepository,
|
$elementRepository,
|
||||||
))->execute(new GetSetLayoutRequest(
|
))->execute(new GetSetLayoutRequest(
|
||||||
setId: $bible->getId(),
|
setId: $bible->getId(),
|
||||||
));
|
));
|
||||||
|
|
||||||
$this->assertSame('Bible', $layout->getSet()->getName());
|
$this->assertSame('Bible', $layout->getSet()->getName());
|
||||||
$this->assertSame(
|
|
||||||
['book:2', 'portion:2', 'chapter:1'],
|
|
||||||
array_map(function ($level): string {
|
|
||||||
return $level->getLevel()->getKind()
|
|
||||||
. ":{$level->getElementCount()}";
|
|
||||||
}, $layout->getLevels()),
|
|
||||||
);
|
|
||||||
$this->assertSame(
|
$this->assertSame(
|
||||||
['Genesis', 'Exodus'],
|
['Genesis', 'Exodus'],
|
||||||
$this->nodeNames($layout->getElements()),
|
$this->nodeNames($layout->getElements()),
|
||||||
|
|
@ -124,26 +103,19 @@ class GetSetLayoutTest extends TestCase
|
||||||
passwordHash: 'hashed-password',
|
passwordHash: 'hashed-password',
|
||||||
);
|
);
|
||||||
$setRepository = new FakeSetRepository;
|
$setRepository = new FakeSetRepository;
|
||||||
$setLevelRepository = new FakeSetLevelRepository;
|
|
||||||
$set = $setRepository->create(new CreateSetDto(
|
$set = $setRepository->create(new CreateSetDto(
|
||||||
name: 'Empty set',
|
name: 'Empty set',
|
||||||
creator: $creator,
|
creator: $creator,
|
||||||
));
|
));
|
||||||
$setLevelRepository->create(new CreateSetLevelDto(
|
|
||||||
set: $set,
|
|
||||||
kind: 'task',
|
|
||||||
));
|
|
||||||
|
|
||||||
$layout = (new GetSetLayout(
|
$layout = (new GetSetLayout(
|
||||||
$setRepository,
|
$setRepository,
|
||||||
$setLevelRepository,
|
|
||||||
new FakeElementRepository,
|
new FakeElementRepository,
|
||||||
))->execute(new GetSetLayoutRequest(
|
))->execute(new GetSetLayoutRequest(
|
||||||
setId: $set->getId(),
|
setId: $set->getId(),
|
||||||
));
|
));
|
||||||
|
|
||||||
$this->assertSame([], $layout->getElements());
|
$this->assertSame([], $layout->getElements());
|
||||||
$this->assertSame(0, $layout->getLevels()[0]->getElementCount());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_it_rejects_an_unknown_set(): void
|
public function test_it_rejects_an_unknown_set(): void
|
||||||
|
|
@ -153,7 +125,6 @@ class GetSetLayoutTest extends TestCase
|
||||||
|
|
||||||
(new GetSetLayout(
|
(new GetSetLayout(
|
||||||
new FakeSetRepository,
|
new FakeSetRepository,
|
||||||
new FakeSetLevelRepository,
|
|
||||||
new FakeElementRepository,
|
new FakeElementRepository,
|
||||||
))->execute(new GetSetLayoutRequest(
|
))->execute(new GetSetLayoutRequest(
|
||||||
setId: 999,
|
setId: 999,
|
||||||
|
|
|
||||||
|
|
@ -13,10 +13,6 @@ describe('email confirmation', () => {
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
body: { sets: [] },
|
body: { sets: [] },
|
||||||
})
|
})
|
||||||
cy.intercept('GET', '**/api/schedules', {
|
|
||||||
statusCode: 200,
|
|
||||||
body: { schedules: [] },
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('chooses a password, confirms the account, and opens the dashboard', () => {
|
it('chooses a password, confirms the account, and opens the dashboard', () => {
|
||||||
|
|
|
||||||
|
|
@ -31,14 +31,6 @@ describe('guest authentication pages', () => {
|
||||||
cy.location('search').should('include', 'redirect=/sets/41')
|
cy.location('search').should('include', 'redirect=/sets/41')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('redirects guests away from protected schedule routes', () => {
|
|
||||||
cy.visit('/sets/41/schedules/new')
|
|
||||||
cy.location('pathname').should('equal', '/login')
|
|
||||||
|
|
||||||
cy.visit('/schedules/73')
|
|
||||||
cy.location('pathname').should('equal', '/login')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows the login form and links to signup', () => {
|
it('shows the login form and links to signup', () => {
|
||||||
cy.visit('/login')
|
cy.visit('/login')
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,10 +43,6 @@ describe('session authentication', () => {
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
body: { sets: [] },
|
body: { sets: [] },
|
||||||
})
|
})
|
||||||
cy.intercept('GET', '**/api/schedules', {
|
|
||||||
statusCode: 200,
|
|
||||||
body: { schedules: [] },
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('restores an authenticated session on a protected route', () => {
|
it('restores an authenticated session on a protected route', () => {
|
||||||
|
|
|
||||||
|
|
@ -5,37 +5,29 @@ const authenticatedUser = {
|
||||||
|
|
||||||
const bibleLayout = {
|
const bibleLayout = {
|
||||||
set: { id: 41, name: 'Bible' },
|
set: { id: 41, name: 'Bible' },
|
||||||
levels: [
|
|
||||||
{ id: 1, kind: 'book', depth: 0, elementCount: 2 },
|
|
||||||
{ id: 2, kind: 'portion', depth: 1, elementCount: 2 },
|
|
||||||
{ id: 3, kind: 'Chapter_sections-v2', depth: 2, elementCount: 1 },
|
|
||||||
],
|
|
||||||
elements: [
|
elements: [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
name: 'Genesis',
|
name: 'Genesis',
|
||||||
kind: 'book',
|
kind: 'book',
|
||||||
levelId: 1,
|
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
id: 3,
|
id: 3,
|
||||||
name: 'Creation',
|
name: 'Creation',
|
||||||
kind: 'portion',
|
kind: 'portion',
|
||||||
levelId: 2,
|
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
id: 4,
|
id: 4,
|
||||||
name: 'Chapter 1',
|
name: 'Chapter 1',
|
||||||
kind: 'Chapter_sections-v2',
|
kind: 'chapter',
|
||||||
levelId: 3,
|
|
||||||
children: [],
|
children: [],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ id: 5, name: 'Noah', kind: 'portion', levelId: 2, children: [] },
|
{ id: 5, name: 'Noah', kind: 'portion', children: [] },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ id: 2, name: 'Exodus', kind: 'book', levelId: 1, children: [] },
|
{ id: 2, name: 'Exodus', kind: 'book', children: [] },
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -90,16 +82,14 @@ describe('set element layout', () => {
|
||||||
.and('have.attr', 'data-element-id', '4')
|
.and('have.attr', 'data-element-id', '4')
|
||||||
cy.contains('.element-node__card', 'Chapter 1')
|
cy.contains('.element-node__card', 'Chapter 1')
|
||||||
.should('be.visible')
|
.should('be.visible')
|
||||||
.find('.element-node__kind')
|
.and('contain.text', 'chapter')
|
||||||
.should('have.text', 'Chapter_sections-v2')
|
|
||||||
.and('have.css', 'text-transform', 'none')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows loading and empty layout states', () => {
|
it('shows loading and empty layout states', () => {
|
||||||
cy.intercept('GET', '**/api/sets/41', {
|
cy.intercept('GET', '**/api/sets/41', {
|
||||||
delay: 500,
|
delay: 500,
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
body: { set: { id: 41, name: 'Empty set' }, levels: [], elements: [] },
|
body: { set: { id: 41, name: 'Empty set' }, elements: [] },
|
||||||
}).as('layout')
|
}).as('layout')
|
||||||
|
|
||||||
cy.visit('/sets/41')
|
cy.visit('/sets/41')
|
||||||
|
|
|
||||||
|
|
@ -1,268 +0,0 @@
|
||||||
const authenticatedUser = {
|
|
||||||
id: 7,
|
|
||||||
email: 'user@example.com',
|
|
||||||
}
|
|
||||||
|
|
||||||
const bibleLayout = {
|
|
||||||
set: { id: 41, name: 'Bible' },
|
|
||||||
levels: [
|
|
||||||
{ id: 1, kind: 'book', depth: 0, elementCount: 2 },
|
|
||||||
{ id: 2, kind: 'portion', depth: 1, elementCount: 2 },
|
|
||||||
{ id: 3, kind: 'Chapter_sections-v2', depth: 2, elementCount: 2 },
|
|
||||||
],
|
|
||||||
elements: [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
name: 'Genesis',
|
|
||||||
kind: 'book',
|
|
||||||
levelId: 1,
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
name: 'Creation',
|
|
||||||
kind: 'portion',
|
|
||||||
levelId: 2,
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
id: 4,
|
|
||||||
name: 'Chapter 1',
|
|
||||||
kind: 'Chapter_sections-v2',
|
|
||||||
levelId: 3,
|
|
||||||
children: [],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
name: 'Exodus',
|
|
||||||
kind: 'book',
|
|
||||||
levelId: 1,
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
id: 5,
|
|
||||||
name: 'Shemot',
|
|
||||||
kind: 'portion',
|
|
||||||
levelId: 2,
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
id: 6,
|
|
||||||
name: 'Chapter 1',
|
|
||||||
kind: 'Chapter_sections-v2',
|
|
||||||
levelId: 3,
|
|
||||||
children: [],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
const scheduleDetail = {
|
|
||||||
schedule: {
|
|
||||||
id: 73,
|
|
||||||
set: { name: 'Bible' },
|
|
||||||
elementKind: 'Chapter_sections-v2',
|
|
||||||
startDate: '2026-08-10',
|
|
||||||
targetDate: '2026-08-12',
|
|
||||||
assignmentCount: 2,
|
|
||||||
days: [
|
|
||||||
{
|
|
||||||
date: '2026-08-10',
|
|
||||||
assignments: [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
element: {
|
|
||||||
name: 'Chapter 1',
|
|
||||||
kind: 'Chapter_sections-v2',
|
|
||||||
path: ['Genesis', 'Creation', 'Chapter 1'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{ date: '2026-08-11', assignments: [] },
|
|
||||||
{
|
|
||||||
date: '2026-08-12',
|
|
||||||
assignments: [
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
element: {
|
|
||||||
name: 'Chapter 1',
|
|
||||||
kind: 'Chapter_sections-v2',
|
|
||||||
path: ['Exodus', 'Shemot', 'Chapter 1'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
function interceptAuthenticatedUser(): void {
|
|
||||||
cy.intercept('GET', '**/api/me', {
|
|
||||||
statusCode: 200,
|
|
||||||
body: { user: authenticatedUser },
|
|
||||||
}).as('me')
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('set scheduling', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
interceptAuthenticatedUser()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('creates a schedule from a set and shows every day', () => {
|
|
||||||
cy.intercept('GET', '**/api/sets/41', {
|
|
||||||
statusCode: 200,
|
|
||||||
body: bibleLayout,
|
|
||||||
}).as('layout')
|
|
||||||
cy.intercept('POST', '**/api/schedules', (request) => {
|
|
||||||
expect(request.headers.accept).to.equal('application/json')
|
|
||||||
expect(request.body).to.deep.equal({
|
|
||||||
setId: 41,
|
|
||||||
levelId: 3,
|
|
||||||
startDate: '2026-08-10',
|
|
||||||
targetDate: '2026-08-12',
|
|
||||||
})
|
|
||||||
request.reply({ statusCode: 201, body: scheduleDetail })
|
|
||||||
}).as('createSchedule')
|
|
||||||
|
|
||||||
cy.visit('/sets/41')
|
|
||||||
cy.wait('@me')
|
|
||||||
cy.wait('@layout')
|
|
||||||
cy.contains('a', 'Schedule this set')
|
|
||||||
.should('have.attr', 'href', '/sets/41/schedules/new')
|
|
||||||
.click()
|
|
||||||
|
|
||||||
cy.location('pathname').should('equal', '/sets/41/schedules/new')
|
|
||||||
cy.get('h1').should('have.text', 'Schedule Bible')
|
|
||||||
cy.get('#schedule-level option').then(($options) => {
|
|
||||||
expect([...$options].map((option) => option.textContent?.trim())).to.deep.equal(
|
|
||||||
['Choose a level', 'book (2)', 'portion (2)', 'Chapter_sections-v2 (2)'],
|
|
||||||
)
|
|
||||||
})
|
|
||||||
cy.get('#schedule-level').select('3')
|
|
||||||
cy.get('#schedule-start-date').type('2026-08-10')
|
|
||||||
cy.get('#schedule-target-date').type('2026-08-12')
|
|
||||||
cy.get('form').submit()
|
|
||||||
cy.wait('@createSchedule')
|
|
||||||
|
|
||||||
cy.location('pathname').should('equal', '/schedules/73')
|
|
||||||
cy.get('h1').should('have.text', 'Bible schedule')
|
|
||||||
cy.get('[data-schedule-day]').should('have.length', 3)
|
|
||||||
cy.get('[data-schedule-date="2026-08-10"]')
|
|
||||||
.should('contain.text', 'Genesis / Creation / Chapter 1')
|
|
||||||
.find('.assignment-kind')
|
|
||||||
.should('have.text', 'Chapter_sections-v2')
|
|
||||||
.and('have.css', 'text-transform', 'none')
|
|
||||||
cy.get('[data-schedule-date="2026-08-11"]')
|
|
||||||
.should('contain.text', 'Rest day')
|
|
||||||
.and('not.contain.text', 'Chapter 1')
|
|
||||||
cy.get('[data-schedule-date="2026-08-12"]').should(
|
|
||||||
'contain.text',
|
|
||||||
'Exodus / Shemot / Chapter 1',
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('validates the schedule form before submitting', () => {
|
|
||||||
cy.intercept('GET', '**/api/sets/41', {
|
|
||||||
statusCode: 200,
|
|
||||||
body: bibleLayout,
|
|
||||||
}).as('layout')
|
|
||||||
cy.intercept('POST', '**/api/schedules').as('createSchedule')
|
|
||||||
|
|
||||||
cy.visit('/sets/41/schedules/new')
|
|
||||||
cy.wait('@me')
|
|
||||||
cy.wait('@layout')
|
|
||||||
cy.get('form').submit()
|
|
||||||
|
|
||||||
cy.get('#schedule-level-error')
|
|
||||||
.should('have.text', 'Choose a level to schedule.')
|
|
||||||
.and('be.visible')
|
|
||||||
cy.get('#schedule-start-date-error')
|
|
||||||
.should('have.text', 'Choose a start date.')
|
|
||||||
.and('be.visible')
|
|
||||||
cy.get('#schedule-target-date-error')
|
|
||||||
.should('have.text', 'Choose a target date.')
|
|
||||||
.and('be.visible')
|
|
||||||
cy.get('@createSchedule.all').should('have.length', 0)
|
|
||||||
|
|
||||||
cy.get('#schedule-level').select('3')
|
|
||||||
cy.get('#schedule-start-date').type('2026-08-12')
|
|
||||||
cy.get('#schedule-target-date').type('2026-08-10')
|
|
||||||
cy.get('form').submit()
|
|
||||||
cy.get('#schedule-target-date-error').should(
|
|
||||||
'have.text',
|
|
||||||
'Target date cannot be before the start date.',
|
|
||||||
)
|
|
||||||
cy.get('@createSchedule.all').should('have.length', 0)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('loads a persisted schedule directly and handles missing schedules', () => {
|
|
||||||
cy.intercept('GET', '**/api/schedules/73', {
|
|
||||||
statusCode: 200,
|
|
||||||
body: scheduleDetail,
|
|
||||||
}).as('schedule')
|
|
||||||
|
|
||||||
cy.visit('/schedules/73')
|
|
||||||
cy.wait('@me')
|
|
||||||
cy.wait('@schedule')
|
|
||||||
cy.get('h1').should('have.text', 'Bible schedule')
|
|
||||||
cy.get('.eyebrow')
|
|
||||||
.should(($eyebrow) => {
|
|
||||||
expect($eyebrow.text().trim()).to.equal('Chapter_sections-v2 plan')
|
|
||||||
})
|
|
||||||
.find('.schedule-kind')
|
|
||||||
.should('have.text', 'Chapter_sections-v2')
|
|
||||||
.should('have.css', 'text-transform', 'none')
|
|
||||||
cy.contains('2 Chapter_sections-v2 assignments across 3 days').should('be.visible')
|
|
||||||
|
|
||||||
cy.intercept('GET', '**/api/schedules/74', {
|
|
||||||
statusCode: 404,
|
|
||||||
body: { error: 'schedule not found' },
|
|
||||||
}).as('missingSchedule')
|
|
||||||
cy.visit('/schedules/74')
|
|
||||||
cy.wait('@missingSchedule')
|
|
||||||
cy.get('h1').should('have.text', 'Schedule not found')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('lists the users schedules on the dashboard', () => {
|
|
||||||
cy.intercept('GET', '**/api/sets', {
|
|
||||||
statusCode: 200,
|
|
||||||
body: { sets: [] },
|
|
||||||
}).as('sets')
|
|
||||||
cy.intercept('GET', '**/api/schedules', (request) => {
|
|
||||||
expect(request.headers.accept).to.equal('application/json')
|
|
||||||
request.reply({
|
|
||||||
statusCode: 200,
|
|
||||||
body: {
|
|
||||||
schedules: [
|
|
||||||
{
|
|
||||||
id: 73,
|
|
||||||
set: { name: 'Bible' },
|
|
||||||
elementKind: 'Chapter_sections-v2',
|
|
||||||
startDate: '2026-08-10',
|
|
||||||
targetDate: '2026-08-12',
|
|
||||||
assignmentCount: 2,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}).as('schedules')
|
|
||||||
|
|
||||||
cy.visit('/dashboard')
|
|
||||||
cy.wait('@me')
|
|
||||||
cy.wait('@sets')
|
|
||||||
cy.wait('@schedules')
|
|
||||||
|
|
||||||
cy.get('#schedules-heading').should('have.text', 'Your schedules')
|
|
||||||
cy.get('ul[aria-label="Your schedules"]')
|
|
||||||
.should('contain.text', 'Bible')
|
|
||||||
.and('contain.text', '2 assignments')
|
|
||||||
cy.get('.schedule-card__heading p')
|
|
||||||
.should('have.text', 'Chapter_sections-v2')
|
|
||||||
.and('have.css', 'text-transform', 'none')
|
|
||||||
cy.contains('a', 'Bible').should('have.attr', 'href', '/schedules/73')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -13,10 +13,6 @@ function interceptAuthenticatedUser(): void {
|
||||||
describe('sets dashboard', () => {
|
describe('sets dashboard', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
interceptAuthenticatedUser()
|
interceptAuthenticatedUser()
|
||||||
cy.intercept('GET', '**/api/schedules', {
|
|
||||||
statusCode: 200,
|
|
||||||
body: { schedules: [] },
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows every available set as a detail link', () => {
|
it('shows every available set as a detail link', () => {
|
||||||
|
|
@ -68,10 +64,10 @@ describe('sets dashboard', () => {
|
||||||
|
|
||||||
cy.visit('/dashboard')
|
cy.visit('/dashboard')
|
||||||
cy.wait('@me')
|
cy.wait('@me')
|
||||||
cy.get('.sets-catalog [role="status"]').should('have.text', 'Loading sets...')
|
cy.get('[role="status"]').should('have.text', 'Loading sets...')
|
||||||
cy.wait('@sets')
|
cy.wait('@sets')
|
||||||
|
|
||||||
cy.get('.sets-catalog [role="status"]').should(
|
cy.get('[role="status"]').should(
|
||||||
'contain.text',
|
'contain.text',
|
||||||
'No sets are available yet.',
|
'No sets are available yet.',
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -87,7 +87,7 @@ defineProps<{
|
||||||
font-size: 0.62rem;
|
font-size: 0.62rem;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
letter-spacing: 0.1em;
|
letter-spacing: 0.1em;
|
||||||
text-transform: none;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 37.5rem) {
|
@media (max-width: 37.5rem) {
|
||||||
|
|
|
||||||
|
|
@ -71,22 +71,6 @@ const router = createRouter({
|
||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: '/sets/:setId(\\d+)/schedules/new',
|
|
||||||
name: 'schedule-create',
|
|
||||||
component: () => import('@/views/CreateScheduleView.vue'),
|
|
||||||
meta: {
|
|
||||||
requiresAuth: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/schedules/:scheduleId(\\d+)',
|
|
||||||
name: 'schedule-detail',
|
|
||||||
component: () => import('@/views/ScheduleDetailView.vue'),
|
|
||||||
meta: {
|
|
||||||
requiresAuth: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,219 +0,0 @@
|
||||||
import { ref } from 'vue'
|
|
||||||
import { defineStore } from 'pinia'
|
|
||||||
import { z } from 'zod'
|
|
||||||
|
|
||||||
import { API_BASE } from '@/utils/apiBase'
|
|
||||||
|
|
||||||
const isoDateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/)
|
|
||||||
|
|
||||||
export const scheduleSummarySchema = z.object({
|
|
||||||
id: z.number().int().positive(),
|
|
||||||
set: z.object({
|
|
||||||
name: z.string().min(1),
|
|
||||||
}),
|
|
||||||
elementKind: z.string().min(1),
|
|
||||||
startDate: isoDateSchema,
|
|
||||||
targetDate: isoDateSchema,
|
|
||||||
assignmentCount: z.number().int().nonnegative(),
|
|
||||||
})
|
|
||||||
|
|
||||||
const scheduleAssignmentSchema = z.object({
|
|
||||||
id: z.number().int().positive(),
|
|
||||||
element: z.object({
|
|
||||||
name: z.string().min(1),
|
|
||||||
kind: z.string().min(1),
|
|
||||||
path: z.array(z.string().min(1)).min(1),
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
export const scheduleDetailSchema = scheduleSummarySchema.extend({
|
|
||||||
days: z.array(
|
|
||||||
z.object({
|
|
||||||
date: isoDateSchema,
|
|
||||||
assignments: z.array(scheduleAssignmentSchema),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
})
|
|
||||||
|
|
||||||
const schedulesResponseSchema = z.object({
|
|
||||||
schedules: z.array(scheduleSummarySchema),
|
|
||||||
})
|
|
||||||
|
|
||||||
const scheduleResponseSchema = z.object({
|
|
||||||
schedule: scheduleDetailSchema,
|
|
||||||
})
|
|
||||||
|
|
||||||
const errorResponseSchema = z.object({
|
|
||||||
error: z.string().min(1),
|
|
||||||
})
|
|
||||||
|
|
||||||
export type ScheduleSummary = z.infer<typeof scheduleSummarySchema>
|
|
||||||
export type ScheduleDetail = z.infer<typeof scheduleDetailSchema>
|
|
||||||
export type CreateScheduleInput = {
|
|
||||||
setId: number
|
|
||||||
levelId: number
|
|
||||||
startDate: string
|
|
||||||
targetDate: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const LIST_ERROR = "We couldn't load your schedules."
|
|
||||||
const DETAIL_ERROR = "We couldn't load this schedule."
|
|
||||||
const CREATE_ERROR = "We couldn't create this schedule."
|
|
||||||
|
|
||||||
export const useSchedulesStore = defineStore('schedules', () => {
|
|
||||||
const schedules = ref<ScheduleSummary[]>([])
|
|
||||||
const listLoading = ref(false)
|
|
||||||
const listError = ref<string | null>(null)
|
|
||||||
const activeSchedule = ref<ScheduleDetail | null>(null)
|
|
||||||
const detailLoading = ref(false)
|
|
||||||
const detailError = ref<string | null>(null)
|
|
||||||
const detailNotFound = ref(false)
|
|
||||||
const creating = ref(false)
|
|
||||||
const createError = ref<string | null>(null)
|
|
||||||
let activeDetailRequestId = 0
|
|
||||||
|
|
||||||
async function fetchSchedules(): Promise<boolean> {
|
|
||||||
listLoading.value = true
|
|
||||||
listError.value = null
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${API_BASE}/api/schedules`, {
|
|
||||||
method: 'GET',
|
|
||||||
credentials: 'include',
|
|
||||||
headers: {
|
|
||||||
Accept: 'application/json',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if (response.status !== 200) {
|
|
||||||
schedules.value = []
|
|
||||||
listError.value = LIST_ERROR
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const responseBody: unknown = await response.json()
|
|
||||||
schedules.value = schedulesResponseSchema.parse(responseBody).schedules
|
|
||||||
|
|
||||||
return true
|
|
||||||
} catch {
|
|
||||||
schedules.value = []
|
|
||||||
listError.value = LIST_ERROR
|
|
||||||
|
|
||||||
return false
|
|
||||||
} finally {
|
|
||||||
listLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchSchedule(scheduleId: number): Promise<boolean> {
|
|
||||||
const requestId = ++activeDetailRequestId
|
|
||||||
activeSchedule.value = null
|
|
||||||
detailLoading.value = true
|
|
||||||
detailError.value = null
|
|
||||||
detailNotFound.value = false
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${API_BASE}/api/schedules/${scheduleId}`, {
|
|
||||||
method: 'GET',
|
|
||||||
credentials: 'include',
|
|
||||||
headers: {
|
|
||||||
Accept: 'application/json',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if (requestId !== activeDetailRequestId) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.status === 404) {
|
|
||||||
detailNotFound.value = true
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.status !== 200) {
|
|
||||||
detailError.value = DETAIL_ERROR
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const responseBody: unknown = await response.json()
|
|
||||||
if (requestId !== activeDetailRequestId) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsedSchedule = scheduleResponseSchema.parse(responseBody).schedule
|
|
||||||
if (parsedSchedule.id !== scheduleId) {
|
|
||||||
throw new Error('schedule response did not match requested schedule')
|
|
||||||
}
|
|
||||||
activeSchedule.value = parsedSchedule
|
|
||||||
|
|
||||||
return true
|
|
||||||
} catch {
|
|
||||||
if (requestId === activeDetailRequestId) {
|
|
||||||
activeSchedule.value = null
|
|
||||||
detailError.value = DETAIL_ERROR
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
} finally {
|
|
||||||
if (requestId === activeDetailRequestId) {
|
|
||||||
detailLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createSchedule(input: CreateScheduleInput): Promise<ScheduleDetail | null> {
|
|
||||||
creating.value = true
|
|
||||||
createError.value = null
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${API_BASE}/api/schedules`, {
|
|
||||||
method: 'POST',
|
|
||||||
credentials: 'include',
|
|
||||||
headers: {
|
|
||||||
Accept: 'application/json',
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(input),
|
|
||||||
})
|
|
||||||
const responseBody: unknown = await response.json()
|
|
||||||
|
|
||||||
if (response.status !== 201) {
|
|
||||||
const parsedError = errorResponseSchema.safeParse(responseBody)
|
|
||||||
createError.value = parsedError.success ? parsedError.data.error : CREATE_ERROR
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const createdSchedule = scheduleResponseSchema.parse(responseBody).schedule
|
|
||||||
activeSchedule.value = createdSchedule
|
|
||||||
detailError.value = null
|
|
||||||
detailNotFound.value = false
|
|
||||||
|
|
||||||
return createdSchedule
|
|
||||||
} catch {
|
|
||||||
createError.value = CREATE_ERROR
|
|
||||||
|
|
||||||
return null
|
|
||||||
} finally {
|
|
||||||
creating.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
schedules,
|
|
||||||
listLoading,
|
|
||||||
listError,
|
|
||||||
activeSchedule,
|
|
||||||
detailLoading,
|
|
||||||
detailError,
|
|
||||||
detailNotFound,
|
|
||||||
creating,
|
|
||||||
createError,
|
|
||||||
fetchSchedules,
|
|
||||||
fetchSchedule,
|
|
||||||
createSchedule,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
@ -8,7 +8,6 @@ export const setElementNodeSchema = z.object({
|
||||||
id: z.number().int().positive(),
|
id: z.number().int().positive(),
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
kind: z.string().min(1),
|
kind: z.string().min(1),
|
||||||
levelId: z.number().int().positive(),
|
|
||||||
get children() {
|
get children() {
|
||||||
return z.array(setElementNodeSchema)
|
return z.array(setElementNodeSchema)
|
||||||
},
|
},
|
||||||
|
|
@ -19,14 +18,6 @@ export const setLayoutResponseSchema = z.object({
|
||||||
id: z.number().int().positive(),
|
id: z.number().int().positive(),
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
}),
|
}),
|
||||||
levels: z.array(
|
|
||||||
z.object({
|
|
||||||
id: z.number().int().positive(),
|
|
||||||
kind: z.string().min(1),
|
|
||||||
depth: z.number().int().nonnegative(),
|
|
||||||
elementCount: z.number().int().nonnegative(),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
elements: z.array(setElementNodeSchema),
|
elements: z.array(setElementNodeSchema),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,8 +31,7 @@ body {
|
||||||
}
|
}
|
||||||
|
|
||||||
button,
|
button,
|
||||||
input,
|
input {
|
||||||
select {
|
|
||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,384 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import { storeToRefs } from 'pinia'
|
|
||||||
import { computed, reactive, ref, watch } from 'vue'
|
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
|
||||||
import { z } from 'zod'
|
|
||||||
|
|
||||||
import AuthenticatedHeader from '@/components/AuthenticatedHeader.vue'
|
|
||||||
import { useSchedulesStore } from '@/stores/schedules'
|
|
||||||
import { useSetLayoutStore } from '@/stores/setLayout'
|
|
||||||
|
|
||||||
type ScheduleField = 'levelId' | 'startDate' | 'targetDate'
|
|
||||||
type ScheduleFieldErrors = Partial<Record<ScheduleField, string>>
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const router = useRouter()
|
|
||||||
const setLayoutStore = useSetLayoutStore()
|
|
||||||
const schedulesStore = useSchedulesStore()
|
|
||||||
const { layout, loading, error, notFound } = storeToRefs(setLayoutStore)
|
|
||||||
const { creating, createError } = storeToRefs(schedulesStore)
|
|
||||||
const currentSetId = ref<number | null>(null)
|
|
||||||
const form = reactive({
|
|
||||||
levelId: null as number | null,
|
|
||||||
startDate: '',
|
|
||||||
targetDate: '',
|
|
||||||
})
|
|
||||||
const fieldErrors = ref<ScheduleFieldErrors>({})
|
|
||||||
|
|
||||||
const levels = computed(() =>
|
|
||||||
(layout.value?.levels ?? []).filter((level) => level.elementCount > 0),
|
|
||||||
)
|
|
||||||
|
|
||||||
const scheduleFormSchema = z
|
|
||||||
.object({
|
|
||||||
levelId: z.number('Choose a level to schedule.').int().positive(),
|
|
||||||
startDate: z.string().min(1, 'Choose a start date.'),
|
|
||||||
targetDate: z.string().min(1, 'Choose a target date.'),
|
|
||||||
})
|
|
||||||
.superRefine((value, context) => {
|
|
||||||
if (value.startDate !== '' && value.targetDate !== '' && value.targetDate < value.startDate) {
|
|
||||||
context.addIssue({
|
|
||||||
code: 'custom',
|
|
||||||
path: ['targetDate'],
|
|
||||||
message: 'Target date cannot be before the start date.',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => route.params.setId,
|
|
||||||
async (setIdParameter) => {
|
|
||||||
const rawSetId = Array.isArray(setIdParameter) ? setIdParameter[0] : setIdParameter
|
|
||||||
const setId = Number(rawSetId)
|
|
||||||
currentSetId.value = setId
|
|
||||||
|
|
||||||
if (layout.value?.set.id !== setId) {
|
|
||||||
await setLayoutStore.fetchSetLayout(setId)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ immediate: true },
|
|
||||||
)
|
|
||||||
|
|
||||||
async function submit(): Promise<void> {
|
|
||||||
fieldErrors.value = {}
|
|
||||||
const result = scheduleFormSchema.safeParse(form)
|
|
||||||
if (!result.success) {
|
|
||||||
const errors: ScheduleFieldErrors = {}
|
|
||||||
for (const issue of result.error.issues) {
|
|
||||||
const field = issue.path[0]
|
|
||||||
if (
|
|
||||||
(field === 'levelId' || field === 'startDate' || field === 'targetDate') &&
|
|
||||||
errors[field] === undefined
|
|
||||||
) {
|
|
||||||
errors[field] = issue.message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fieldErrors.value = errors
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
currentSetId.value === null ||
|
|
||||||
!levels.value.some((level) => level.id === result.data.levelId)
|
|
||||||
) {
|
|
||||||
fieldErrors.value = {
|
|
||||||
levelId: 'Choose an available level to schedule.',
|
|
||||||
}
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const schedule = await schedulesStore.createSchedule({
|
|
||||||
setId: currentSetId.value,
|
|
||||||
...result.data,
|
|
||||||
})
|
|
||||||
if (schedule !== null) {
|
|
||||||
await router.push({
|
|
||||||
name: 'schedule-detail',
|
|
||||||
params: { scheduleId: schedule.id },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function retry(): Promise<void> {
|
|
||||||
if (currentSetId.value !== null) {
|
|
||||||
await setLayoutStore.fetchSetLayout(currentSetId.value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<main class="schedule-form-page">
|
|
||||||
<AuthenticatedHeader />
|
|
||||||
|
|
||||||
<section class="schedule-form-shell">
|
|
||||||
<RouterLink
|
|
||||||
class="back-link"
|
|
||||||
:to="{ name: 'set-layout', params: { setId: currentSetId ?? undefined } }"
|
|
||||||
>
|
|
||||||
<span aria-hidden="true">←</span>
|
|
||||||
Back to set
|
|
||||||
</RouterLink>
|
|
||||||
|
|
||||||
<p v-if="loading" class="page-state" role="status">Loading scheduling options...</p>
|
|
||||||
|
|
||||||
<div v-else-if="notFound" class="page-state page-state--error" role="alert">
|
|
||||||
<h1>Set not found</h1>
|
|
||||||
<p>The set you want to schedule is not available.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else-if="error !== null" class="page-state page-state--error" role="alert">
|
|
||||||
<p>{{ error }}</p>
|
|
||||||
<button type="button" class="secondary-button" @click="retry">Try again</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<template v-else-if="layout !== null">
|
|
||||||
<header class="schedule-form__introduction">
|
|
||||||
<p class="eyebrow">Create schedule</p>
|
|
||||||
<h1>Schedule {{ layout.set.name }}</h1>
|
|
||||||
<p>Choose one level of the set and a date range. Every matching item will be included.</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<form class="schedule-form" novalidate @submit.prevent="submit">
|
|
||||||
<div class="form-field">
|
|
||||||
<label for="schedule-level">Level</label>
|
|
||||||
<select
|
|
||||||
id="schedule-level"
|
|
||||||
v-model="form.levelId"
|
|
||||||
:aria-invalid="fieldErrors.levelId !== undefined"
|
|
||||||
:aria-describedby="fieldErrors.levelId ? 'schedule-level-error' : undefined"
|
|
||||||
:disabled="creating"
|
|
||||||
>
|
|
||||||
<option :value="null">Choose a level</option>
|
|
||||||
<option v-for="level in levels" :key="level.id" :value="level.id">
|
|
||||||
{{ level.kind }} ({{ level.elementCount }})
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<p v-if="fieldErrors.levelId" id="schedule-level-error" class="field-error">
|
|
||||||
{{ fieldErrors.levelId }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="date-fields">
|
|
||||||
<div class="form-field">
|
|
||||||
<label for="schedule-start-date">Start date</label>
|
|
||||||
<input
|
|
||||||
id="schedule-start-date"
|
|
||||||
v-model="form.startDate"
|
|
||||||
type="date"
|
|
||||||
:aria-invalid="fieldErrors.startDate !== undefined"
|
|
||||||
:aria-describedby="fieldErrors.startDate ? 'schedule-start-date-error' : undefined"
|
|
||||||
:disabled="creating"
|
|
||||||
/>
|
|
||||||
<p v-if="fieldErrors.startDate" id="schedule-start-date-error" class="field-error">
|
|
||||||
{{ fieldErrors.startDate }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-field">
|
|
||||||
<label for="schedule-target-date">Target date</label>
|
|
||||||
<input
|
|
||||||
id="schedule-target-date"
|
|
||||||
v-model="form.targetDate"
|
|
||||||
type="date"
|
|
||||||
:aria-invalid="fieldErrors.targetDate !== undefined"
|
|
||||||
:aria-describedby="
|
|
||||||
fieldErrors.targetDate ? 'schedule-target-date-error' : undefined
|
|
||||||
"
|
|
||||||
:disabled="creating"
|
|
||||||
/>
|
|
||||||
<p v-if="fieldErrors.targetDate" id="schedule-target-date-error" class="field-error">
|
|
||||||
{{ fieldErrors.targetDate }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p v-if="createError !== null" class="form-error" role="alert">{{ createError }}</p>
|
|
||||||
|
|
||||||
<button type="submit" class="primary-button" :disabled="creating">
|
|
||||||
{{ creating ? 'Creating schedule...' : 'Create schedule' }}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</template>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.schedule-form-page {
|
|
||||||
min-height: 100vh;
|
|
||||||
min-height: 100svh;
|
|
||||||
padding: 2rem clamp(1.5rem, 6vw, 5rem) 5rem;
|
|
||||||
color: #183029;
|
|
||||||
background: #f4f1e7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-form-shell {
|
|
||||||
width: min(100%, 46rem);
|
|
||||||
margin: clamp(3.5rem, 8vh, 5.5rem) auto 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.back-link {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.45rem;
|
|
||||||
border-radius: 0.35rem;
|
|
||||||
color: #5e7067;
|
|
||||||
font-size: 0.84rem;
|
|
||||||
font-weight: 750;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.back-link:focus-visible,
|
|
||||||
.primary-button:focus-visible,
|
|
||||||
.secondary-button:focus-visible,
|
|
||||||
select:focus-visible,
|
|
||||||
input:focus-visible {
|
|
||||||
outline: 3px solid rgb(86 127 112 / 34%);
|
|
||||||
outline-offset: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-form__introduction {
|
|
||||||
margin-top: 2.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.eyebrow {
|
|
||||||
margin: 0 0 1rem;
|
|
||||||
color: #926044;
|
|
||||||
font-size: 0.72rem;
|
|
||||||
font-weight: 800;
|
|
||||||
letter-spacing: 0.14em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
margin: 0;
|
|
||||||
font-family: Georgia, 'Times New Roman', serif;
|
|
||||||
font-size: clamp(2.75rem, 7vw, 4.8rem);
|
|
||||||
font-weight: 500;
|
|
||||||
line-height: 1;
|
|
||||||
letter-spacing: -0.05em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-form__introduction > p:last-child {
|
|
||||||
max-width: 38rem;
|
|
||||||
margin: 1.4rem 0 0;
|
|
||||||
color: #68776f;
|
|
||||||
line-height: 1.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-form {
|
|
||||||
display: grid;
|
|
||||||
gap: 1.5rem;
|
|
||||||
margin-top: 2.75rem;
|
|
||||||
padding: clamp(1.3rem, 4vw, 2rem);
|
|
||||||
border: 1px solid rgb(24 48 41 / 12%);
|
|
||||||
border-radius: 1rem;
|
|
||||||
background: rgb(255 253 247 / 82%);
|
|
||||||
box-shadow: 0 1rem 2.5rem rgb(40 62 52 / 8%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-field {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.55rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.date-fields {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
label {
|
|
||||||
font-size: 0.78rem;
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
|
|
||||||
select,
|
|
||||||
input {
|
|
||||||
width: 100%;
|
|
||||||
min-height: 3rem;
|
|
||||||
padding: 0.7rem 0.8rem;
|
|
||||||
border: 1px solid rgb(24 58 49 / 22%);
|
|
||||||
border-radius: 0.7rem;
|
|
||||||
color: #183029;
|
|
||||||
background: #fffdf7;
|
|
||||||
}
|
|
||||||
|
|
||||||
select[aria-invalid='true'],
|
|
||||||
input[aria-invalid='true'] {
|
|
||||||
border-color: #a64b3c;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-error,
|
|
||||||
.form-error {
|
|
||||||
margin: 0;
|
|
||||||
color: #934033;
|
|
||||||
font-size: 0.76rem;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.primary-button,
|
|
||||||
.secondary-button {
|
|
||||||
min-height: 2.8rem;
|
|
||||||
padding: 0.7rem 1.1rem;
|
|
||||||
border-radius: 0.7rem;
|
|
||||||
font-weight: 750;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.primary-button {
|
|
||||||
justify-self: start;
|
|
||||||
border: 0;
|
|
||||||
color: #fffdf7;
|
|
||||||
background: #183a31;
|
|
||||||
}
|
|
||||||
|
|
||||||
.primary-button:disabled {
|
|
||||||
cursor: wait;
|
|
||||||
opacity: 0.65;
|
|
||||||
}
|
|
||||||
|
|
||||||
.secondary-button {
|
|
||||||
border: 1px solid rgb(24 58 49 / 28%);
|
|
||||||
color: #183a31;
|
|
||||||
background: #fffdf7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-state {
|
|
||||||
display: grid;
|
|
||||||
min-height: 10rem;
|
|
||||||
place-items: center;
|
|
||||||
margin-top: 2.75rem;
|
|
||||||
padding: 2rem;
|
|
||||||
border: 1px solid rgb(24 48 41 / 12%);
|
|
||||||
border-radius: 1rem;
|
|
||||||
color: #68776f;
|
|
||||||
background: rgb(255 253 247 / 72%);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-state--error {
|
|
||||||
align-content: center;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-state--error p {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 37.5rem) {
|
|
||||||
.schedule-form-page {
|
|
||||||
padding: 1.4rem 1.1rem 3rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-form-shell {
|
|
||||||
margin-top: 3rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.date-fields {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -3,83 +3,20 @@ import { storeToRefs } from 'pinia'
|
||||||
import { onMounted } from 'vue'
|
import { onMounted } from 'vue'
|
||||||
|
|
||||||
import AuthenticatedHeader from '@/components/AuthenticatedHeader.vue'
|
import AuthenticatedHeader from '@/components/AuthenticatedHeader.vue'
|
||||||
import { useSchedulesStore } from '@/stores/schedules'
|
|
||||||
import { useSetsStore } from '@/stores/sets'
|
import { useSetsStore } from '@/stores/sets'
|
||||||
|
|
||||||
const setsStore = useSetsStore()
|
const setsStore = useSetsStore()
|
||||||
const schedulesStore = useSchedulesStore()
|
|
||||||
const { sets, loading, error } = storeToRefs(setsStore)
|
const { sets, loading, error } = storeToRefs(setsStore)
|
||||||
const { schedules, listLoading, listError } = storeToRefs(schedulesStore)
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await Promise.all([setsStore.fetchSets(), schedulesStore.fetchSchedules()])
|
await setsStore.fetchSets()
|
||||||
})
|
})
|
||||||
|
|
||||||
function formatDate(value: string): string {
|
|
||||||
return new Intl.DateTimeFormat('en', {
|
|
||||||
dateStyle: 'medium',
|
|
||||||
timeZone: 'UTC',
|
|
||||||
}).format(new Date(`${value}T00:00:00Z`))
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main class="dashboard-page">
|
<main class="dashboard-page">
|
||||||
<AuthenticatedHeader />
|
<AuthenticatedHeader />
|
||||||
|
|
||||||
<section class="schedules-catalog" aria-labelledby="schedules-heading">
|
|
||||||
<div class="schedules-catalog__heading">
|
|
||||||
<div>
|
|
||||||
<p class="sets-catalog__eyebrow">Your plans</p>
|
|
||||||
<h2 id="schedules-heading">Your schedules</h2>
|
|
||||||
</div>
|
|
||||||
<p>Return to a plan and see what is assigned for each day.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p v-if="listLoading" class="catalog-state catalog-state--compact" role="status">
|
|
||||||
Loading schedules...
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-else-if="listError !== null"
|
|
||||||
class="catalog-state catalog-state--compact catalog-state--error"
|
|
||||||
role="alert"
|
|
||||||
>
|
|
||||||
<p>{{ listError }}</p>
|
|
||||||
<button type="button" class="retry-button" @click="schedulesStore.fetchSchedules">
|
|
||||||
Try again
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p
|
|
||||||
v-else-if="schedules.length === 0"
|
|
||||||
class="catalog-state catalog-state--compact"
|
|
||||||
role="status"
|
|
||||||
>
|
|
||||||
You have not created a schedule yet.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<ul v-else class="schedule-grid" aria-label="Your schedules">
|
|
||||||
<li v-for="schedule in schedules" :key="schedule.id">
|
|
||||||
<RouterLink
|
|
||||||
class="schedule-card-link"
|
|
||||||
:to="{ name: 'schedule-detail', params: { scheduleId: schedule.id } }"
|
|
||||||
>
|
|
||||||
<article class="schedule-card">
|
|
||||||
<div class="schedule-card__heading">
|
|
||||||
<p class="schedule-card__kind">{{ schedule.elementKind }}</p>
|
|
||||||
<span>{{ schedule.assignmentCount }} assignments</span>
|
|
||||||
</div>
|
|
||||||
<h3>{{ schedule.set.name }}</h3>
|
|
||||||
<span class="schedule-card__dates">
|
|
||||||
{{ formatDate(schedule.startDate) }} to {{ formatDate(schedule.targetDate) }}
|
|
||||||
</span>
|
|
||||||
</article>
|
|
||||||
</RouterLink>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="sets-catalog" aria-labelledby="sets-heading">
|
<section class="sets-catalog" aria-labelledby="sets-heading">
|
||||||
<div class="sets-catalog__introduction">
|
<div class="sets-catalog__introduction">
|
||||||
<p class="sets-catalog__eyebrow">Your library</p>
|
<p class="sets-catalog__eyebrow">Your library</p>
|
||||||
|
|
@ -135,34 +72,6 @@ function formatDate(value: string): string {
|
||||||
margin: clamp(4.5rem, 10vh, 7rem) auto 0;
|
margin: clamp(4.5rem, 10vh, 7rem) auto 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.schedules-catalog {
|
|
||||||
width: min(100%, 72rem);
|
|
||||||
margin: clamp(4.5rem, 10vh, 7rem) auto 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedules-catalog__heading {
|
|
||||||
display: flex;
|
|
||||||
align-items: end;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedules-catalog__heading h2 {
|
|
||||||
margin: 0;
|
|
||||||
font-family: Georgia, 'Times New Roman', serif;
|
|
||||||
font-size: clamp(2.25rem, 5vw, 3.5rem);
|
|
||||||
font-weight: 500;
|
|
||||||
line-height: 1;
|
|
||||||
letter-spacing: -0.045em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedules-catalog__heading > p {
|
|
||||||
max-width: 24rem;
|
|
||||||
margin: 0;
|
|
||||||
color: #68776f;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sets-catalog__introduction {
|
.sets-catalog__introduction {
|
||||||
max-width: 44rem;
|
max-width: 44rem;
|
||||||
}
|
}
|
||||||
|
|
@ -211,11 +120,6 @@ h1 {
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.catalog-state--compact {
|
|
||||||
min-height: 7rem;
|
|
||||||
margin-top: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.catalog-state--error p {
|
.catalog-state--error p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
@ -251,78 +155,6 @@ h1 {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.schedule-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr));
|
|
||||||
gap: 1rem;
|
|
||||||
margin: 2rem 0 0;
|
|
||||||
padding: 0;
|
|
||||||
list-style: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-card-link {
|
|
||||||
display: block;
|
|
||||||
height: 100%;
|
|
||||||
border-radius: 1rem;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-card {
|
|
||||||
height: 100%;
|
|
||||||
padding: 1.4rem;
|
|
||||||
border: 1px solid rgb(24 48 41 / 12%);
|
|
||||||
border-radius: 1rem;
|
|
||||||
background: rgb(255 253 247 / 88%);
|
|
||||||
box-shadow: 0 0.75rem 2rem rgb(40 62 52 / 7%);
|
|
||||||
transition:
|
|
||||||
border-color 160ms ease,
|
|
||||||
transform 160ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-card-link:hover .schedule-card {
|
|
||||||
border-color: rgb(40 92 78 / 35%);
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-card-link:focus-visible {
|
|
||||||
outline: 3px solid rgb(86 127 112 / 38%);
|
|
||||||
outline-offset: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-card__heading {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 1rem;
|
|
||||||
color: #926044;
|
|
||||||
font-size: 0.68rem;
|
|
||||||
font-weight: 800;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-card__heading p {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-card__kind {
|
|
||||||
text-transform: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-card h3 {
|
|
||||||
margin: 2rem 0 0;
|
|
||||||
font-family: Georgia, 'Times New Roman', serif;
|
|
||||||
font-size: 1.8rem;
|
|
||||||
font-weight: 500;
|
|
||||||
line-height: 1.1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-card__dates {
|
|
||||||
display: block;
|
|
||||||
margin-top: 1rem;
|
|
||||||
color: #68776f;
|
|
||||||
font-size: 0.78rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.set-card {
|
.set-card {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 11rem;
|
min-height: 11rem;
|
||||||
|
|
@ -393,15 +225,6 @@ h1 {
|
||||||
margin-top: 3.75rem;
|
margin-top: 3.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.schedules-catalog {
|
|
||||||
margin-top: 3.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedules-catalog__heading {
|
|
||||||
display: grid;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.set-grid {
|
.set-grid {
|
||||||
margin-top: 2.25rem;
|
margin-top: 2.25rem;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,308 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import { storeToRefs } from 'pinia'
|
|
||||||
import { ref, watch } from 'vue'
|
|
||||||
import { useRoute } from 'vue-router'
|
|
||||||
|
|
||||||
import AuthenticatedHeader from '@/components/AuthenticatedHeader.vue'
|
|
||||||
import { useSchedulesStore } from '@/stores/schedules'
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const schedulesStore = useSchedulesStore()
|
|
||||||
const { activeSchedule, detailLoading, detailError, detailNotFound } = storeToRefs(schedulesStore)
|
|
||||||
const currentScheduleId = ref<number | null>(null)
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => route.params.scheduleId,
|
|
||||||
async (scheduleIdParameter) => {
|
|
||||||
const rawScheduleId = Array.isArray(scheduleIdParameter)
|
|
||||||
? scheduleIdParameter[0]
|
|
||||||
: scheduleIdParameter
|
|
||||||
const scheduleId = Number(rawScheduleId)
|
|
||||||
currentScheduleId.value = scheduleId
|
|
||||||
|
|
||||||
if (activeSchedule.value?.id !== scheduleId) {
|
|
||||||
await schedulesStore.fetchSchedule(scheduleId)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ immediate: true },
|
|
||||||
)
|
|
||||||
|
|
||||||
async function retry(): Promise<void> {
|
|
||||||
if (currentScheduleId.value !== null) {
|
|
||||||
await schedulesStore.fetchSchedule(currentScheduleId.value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(value: string): string {
|
|
||||||
return new Intl.DateTimeFormat('en', {
|
|
||||||
dateStyle: 'medium',
|
|
||||||
timeZone: 'UTC',
|
|
||||||
}).format(new Date(`${value}T00:00:00Z`))
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<main class="schedule-page">
|
|
||||||
<AuthenticatedHeader />
|
|
||||||
|
|
||||||
<section class="schedule-shell">
|
|
||||||
<RouterLink class="back-link" :to="{ name: 'dashboard' }">
|
|
||||||
<span aria-hidden="true">←</span>
|
|
||||||
Back to dashboard
|
|
||||||
</RouterLink>
|
|
||||||
|
|
||||||
<p v-if="detailLoading" class="page-state" role="status">Loading schedule...</p>
|
|
||||||
|
|
||||||
<div v-else-if="detailNotFound" class="page-state page-state--error" role="alert">
|
|
||||||
<h1>Schedule not found</h1>
|
|
||||||
<p>This schedule is not available.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else-if="detailError !== null" class="page-state page-state--error" role="alert">
|
|
||||||
<p>{{ detailError }}</p>
|
|
||||||
<button type="button" class="retry-button" @click="retry">Try again</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<template v-else-if="activeSchedule !== null">
|
|
||||||
<header class="schedule-introduction">
|
|
||||||
<p class="eyebrow">
|
|
||||||
<span class="schedule-kind">{{ activeSchedule.elementKind }}</span> plan
|
|
||||||
</p>
|
|
||||||
<h1>{{ activeSchedule.set.name }} schedule</h1>
|
|
||||||
<p class="schedule-summary">
|
|
||||||
{{ activeSchedule.assignmentCount }}
|
|
||||||
{{ activeSchedule.elementKind }}
|
|
||||||
{{ activeSchedule.assignmentCount === 1 ? 'assignment' : 'assignments' }} across
|
|
||||||
{{ activeSchedule.days.length }}
|
|
||||||
{{ activeSchedule.days.length === 1 ? 'day' : 'days' }}
|
|
||||||
</p>
|
|
||||||
<p class="schedule-range">
|
|
||||||
{{ formatDate(activeSchedule.startDate) }} to
|
|
||||||
{{ formatDate(activeSchedule.targetDate) }}
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<ol class="schedule-days" aria-label="Scheduled days">
|
|
||||||
<li
|
|
||||||
v-for="day in activeSchedule.days"
|
|
||||||
:key="day.date"
|
|
||||||
class="schedule-day"
|
|
||||||
data-schedule-day
|
|
||||||
:data-schedule-date="day.date"
|
|
||||||
>
|
|
||||||
<header class="schedule-day__header">
|
|
||||||
<time :datetime="day.date">{{ formatDate(day.date) }}</time>
|
|
||||||
<span>{{ day.assignments.length }} assigned</span>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<p v-if="day.assignments.length === 0" class="rest-day">Rest day</p>
|
|
||||||
|
|
||||||
<ul v-else class="assignment-list">
|
|
||||||
<li v-for="assignment in day.assignments" :key="assignment.id">
|
|
||||||
<span class="assignment-path">{{ assignment.element.path.join(' / ') }}</span>
|
|
||||||
<span class="assignment-kind">{{ assignment.element.kind }}</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
</template>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.schedule-page {
|
|
||||||
min-height: 100vh;
|
|
||||||
min-height: 100svh;
|
|
||||||
padding: 2rem clamp(1.5rem, 6vw, 5rem) 5rem;
|
|
||||||
color: #183029;
|
|
||||||
background: #f4f1e7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-shell {
|
|
||||||
width: min(100%, 56rem);
|
|
||||||
margin: clamp(3.5rem, 8vh, 5.5rem) auto 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.back-link {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.45rem;
|
|
||||||
border-radius: 0.35rem;
|
|
||||||
color: #5e7067;
|
|
||||||
font-size: 0.84rem;
|
|
||||||
font-weight: 750;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.back-link:focus-visible,
|
|
||||||
.retry-button:focus-visible {
|
|
||||||
outline: 3px solid rgb(86 127 112 / 34%);
|
|
||||||
outline-offset: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-introduction {
|
|
||||||
margin-top: 2.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.eyebrow {
|
|
||||||
margin: 0 0 1rem;
|
|
||||||
color: #926044;
|
|
||||||
font-size: 0.72rem;
|
|
||||||
font-weight: 800;
|
|
||||||
letter-spacing: 0.14em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-kind {
|
|
||||||
text-transform: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
margin: 0;
|
|
||||||
font-family: Georgia, 'Times New Roman', serif;
|
|
||||||
font-size: clamp(3rem, 7vw, 5.25rem);
|
|
||||||
font-weight: 500;
|
|
||||||
line-height: 1;
|
|
||||||
letter-spacing: -0.055em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-summary {
|
|
||||||
margin: 1.35rem 0 0;
|
|
||||||
color: #344e45;
|
|
||||||
font-weight: 750;
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-range {
|
|
||||||
margin: 0.5rem 0 0;
|
|
||||||
color: #68776f;
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-days {
|
|
||||||
display: grid;
|
|
||||||
gap: 1rem;
|
|
||||||
margin: 3rem 0 0;
|
|
||||||
padding: 0;
|
|
||||||
list-style: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-day {
|
|
||||||
overflow: hidden;
|
|
||||||
border: 1px solid rgb(24 48 41 / 12%);
|
|
||||||
border-radius: 1rem;
|
|
||||||
background: rgb(255 253 247 / 82%);
|
|
||||||
box-shadow: 0 0.75rem 2rem rgb(40 62 52 / 6%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-day__header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 1rem;
|
|
||||||
padding: 0.85rem 1rem;
|
|
||||||
border-bottom: 1px solid rgb(24 48 41 / 10%);
|
|
||||||
color: #5e7067;
|
|
||||||
background: rgb(239 228 212 / 42%);
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rest-day {
|
|
||||||
margin: 0;
|
|
||||||
padding: 1.2rem 1rem;
|
|
||||||
color: #79877f;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.assignment-list {
|
|
||||||
display: grid;
|
|
||||||
gap: 0;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
list-style: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.assignment-list li {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 1rem;
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.assignment-list li + li {
|
|
||||||
border-top: 1px solid rgb(24 48 41 / 10%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.assignment-path {
|
|
||||||
min-width: 0;
|
|
||||||
font-family: Georgia, 'Times New Roman', serif;
|
|
||||||
font-size: 1.05rem;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
|
|
||||||
.assignment-kind {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
padding: 0.35rem 0.55rem;
|
|
||||||
border-radius: 999px;
|
|
||||||
color: #81533a;
|
|
||||||
background: #efe4d4;
|
|
||||||
font-size: 0.62rem;
|
|
||||||
font-weight: 800;
|
|
||||||
letter-spacing: 0.1em;
|
|
||||||
text-transform: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-state {
|
|
||||||
display: grid;
|
|
||||||
min-height: 10rem;
|
|
||||||
place-items: center;
|
|
||||||
margin-top: 2.75rem;
|
|
||||||
padding: 2rem;
|
|
||||||
border: 1px solid rgb(24 48 41 / 12%);
|
|
||||||
border-radius: 1rem;
|
|
||||||
color: #68776f;
|
|
||||||
background: rgb(255 253 247 / 72%);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-state--error {
|
|
||||||
align-content: center;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-state--error h1,
|
|
||||||
.page-state--error p {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-state--error h1 {
|
|
||||||
font-size: clamp(2.25rem, 5vw, 3.6rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
.retry-button {
|
|
||||||
min-height: 2.65rem;
|
|
||||||
padding: 0.65rem 1rem;
|
|
||||||
border: 1px solid rgb(24 58 49 / 28%);
|
|
||||||
border-radius: 0.7rem;
|
|
||||||
color: #183a31;
|
|
||||||
background: #fffdf7;
|
|
||||||
font-size: 0.82rem;
|
|
||||||
font-weight: 750;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 37.5rem) {
|
|
||||||
.schedule-page {
|
|
||||||
padding: 1.4rem 1.1rem 3rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-shell {
|
|
||||||
margin-top: 3rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.assignment-list li {
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -63,14 +63,6 @@ async function retry(): Promise<void> {
|
||||||
<p class="set-layout__description">
|
<p class="set-layout__description">
|
||||||
Explore every element in this set and see how each level fits into the whole.
|
Explore every element in this set and see how each level fits into the whole.
|
||||||
</p>
|
</p>
|
||||||
<RouterLink
|
|
||||||
v-if="layout.elements.length > 0"
|
|
||||||
class="schedule-link"
|
|
||||||
:to="{ name: 'schedule-create', params: { setId: layout.set.id } }"
|
|
||||||
>
|
|
||||||
Schedule this set
|
|
||||||
<span aria-hidden="true">→</span>
|
|
||||||
</RouterLink>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<p
|
<p
|
||||||
|
|
@ -153,31 +145,6 @@ h1 {
|
||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
.schedule-link {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.4rem;
|
|
||||||
min-height: 2.8rem;
|
|
||||||
margin-top: 1.5rem;
|
|
||||||
padding: 0.7rem 1rem;
|
|
||||||
border-radius: 0.7rem;
|
|
||||||
color: #fffdf7;
|
|
||||||
background: #183a31;
|
|
||||||
box-shadow: 0 0.55rem 1.2rem rgb(24 58 49 / 14%);
|
|
||||||
font-size: 0.82rem;
|
|
||||||
font-weight: 750;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-link:hover {
|
|
||||||
background: #285c4e;
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-link:focus-visible {
|
|
||||||
outline: 3px solid rgb(86 127 112 / 34%);
|
|
||||||
outline-offset: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layout-outline {
|
.layout-outline {
|
||||||
margin-top: 3rem;
|
margin-top: 3rem;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue