Compare commits
9 commits
66d9c8cbd7
...
0b0733cbec
| Author | SHA1 | Date | |
|---|---|---|---|
| 0b0733cbec | |||
| 75f447d23d | |||
| 7ae745162f | |||
| 021eb06460 | |||
| 85c9f4bbc9 | |||
| e1d2262579 | |||
| 1c83eb1b84 | |||
| 483ca6fc1d | |||
| 6afe7864be |
21 changed files with 1133 additions and 23 deletions
|
|
@ -4,6 +4,7 @@ namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Exceptions\BadRequestException;
|
use App\Exceptions\BadRequestException;
|
||||||
use App\Exceptions\NotFoundException;
|
use App\Exceptions\NotFoundException;
|
||||||
|
use App\Schedule\AssignmentForDate;
|
||||||
use App\Schedule\Schedule;
|
use App\Schedule\Schedule;
|
||||||
use App\Schedule\ScheduleAssignment;
|
use App\Schedule\ScheduleAssignment;
|
||||||
use App\Schedule\UseCases\CreateSchedule\CreateSchedule;
|
use App\Schedule\UseCases\CreateSchedule\CreateSchedule;
|
||||||
|
|
@ -11,6 +12,8 @@ use App\Schedule\UseCases\CreateSchedule\CreateScheduleRequest;
|
||||||
use App\Schedule\UseCases\GetSchedule\GetSchedule;
|
use App\Schedule\UseCases\GetSchedule\GetSchedule;
|
||||||
use App\Schedule\UseCases\GetSchedule\GetScheduleRequest;
|
use App\Schedule\UseCases\GetSchedule\GetScheduleRequest;
|
||||||
use App\Schedule\UseCases\ListSchedules\ListSchedules;
|
use App\Schedule\UseCases\ListSchedules\ListSchedules;
|
||||||
|
use App\Schedule\UseCases\ListAssignmentsForDate\ListAssignmentsForDate;
|
||||||
|
use App\Schedule\UseCases\ListAssignmentsForDate\ListAssignmentsForDateRequest;
|
||||||
use App\Shared\Http\RequestInput;
|
use App\Shared\Http\RequestInput;
|
||||||
use App\User\User;
|
use App\User\User;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
@ -22,6 +25,7 @@ class ScheduleController extends Controller
|
||||||
private CreateSchedule $createSchedule,
|
private CreateSchedule $createSchedule,
|
||||||
private ListSchedules $listSchedules,
|
private ListSchedules $listSchedules,
|
||||||
private GetSchedule $getSchedule,
|
private GetSchedule $getSchedule,
|
||||||
|
private ListAssignmentsForDate $listAssignmentsForDate,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function store(Request $request): JsonResponse
|
public function store(Request $request): JsonResponse
|
||||||
|
|
@ -90,6 +94,36 @@ class ScheduleController extends Controller
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function assignments(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$input = new RequestInput($request);
|
||||||
|
$date = $input->string('date');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$assignments = $this->listAssignmentsForDate->execute(
|
||||||
|
new ListAssignmentsForDateRequest(
|
||||||
|
user: $this->user($request),
|
||||||
|
date: $date,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (BadRequestException $exception) {
|
||||||
|
return new JsonResponse(
|
||||||
|
['error' => $exception->getMessage()],
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new JsonResponse([
|
||||||
|
'date' => $date,
|
||||||
|
'assignments' => array_map(
|
||||||
|
function (AssignmentForDate $assignment): array {
|
||||||
|
return $this->assignmentForDatePayload($assignment);
|
||||||
|
},
|
||||||
|
$assignments,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
|
|
@ -165,6 +199,30 @@ class ScheduleController extends Controller
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function assignmentForDatePayload(
|
||||||
|
AssignmentForDate $assignmentForDate,
|
||||||
|
): array {
|
||||||
|
$assignment = $assignmentForDate->getAssignment();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $assignment->getId(),
|
||||||
|
'schedule' => [
|
||||||
|
'id' => $assignmentForDate->getScheduleId(),
|
||||||
|
'set' => [
|
||||||
|
'name' => $assignmentForDate->getSetName(),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'element' => [
|
||||||
|
'name' => $assignment->getName(),
|
||||||
|
'kind' => $assignment->getKind(),
|
||||||
|
'path' => $assignment->getPath(),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
private function user(Request $request): User
|
private function user(Request $request): User
|
||||||
{
|
{
|
||||||
/** @var User $user */
|
/** @var User $user */
|
||||||
|
|
|
||||||
27
backend/app/Schedule/AssignmentForDate.php
Normal file
27
backend/app/Schedule/AssignmentForDate.php
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Schedule;
|
||||||
|
|
||||||
|
final readonly class AssignmentForDate
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private int $scheduleId,
|
||||||
|
private string $setName,
|
||||||
|
private ScheduleAssignment $assignment,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function getScheduleId(): int
|
||||||
|
{
|
||||||
|
return $this->scheduleId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSetName(): string
|
||||||
|
{
|
||||||
|
return $this->setName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAssignment(): ScheduleAssignment
|
||||||
|
{
|
||||||
|
return $this->assignment;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -61,6 +61,40 @@ class EloquentScheduleRepository implements ScheduleRepository
|
||||||
return $schedules;
|
return $schedules;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function findAssignmentsForUserOnDate(
|
||||||
|
User $user,
|
||||||
|
DateTimeImmutable $date,
|
||||||
|
): array {
|
||||||
|
$assignmentModels = ScheduleAssignmentModel::query()
|
||||||
|
->select('schedule_assignments.*')
|
||||||
|
->join(
|
||||||
|
'schedules',
|
||||||
|
'schedules.id',
|
||||||
|
'=',
|
||||||
|
'schedule_assignments.schedule_id',
|
||||||
|
)
|
||||||
|
->where('schedules.user_id', $user->getId())
|
||||||
|
->where('schedule_assignments.scheduled_date', $date->format(
|
||||||
|
'Y-m-d',
|
||||||
|
))
|
||||||
|
->with('schedule')
|
||||||
|
->orderByDesc('schedules.id')
|
||||||
|
->orderBy('schedule_assignments.position')
|
||||||
|
->orderBy('schedule_assignments.id')
|
||||||
|
->get();
|
||||||
|
$assignments = [];
|
||||||
|
|
||||||
|
foreach ($assignmentModels as $assignmentModel) {
|
||||||
|
$assignments[] = new AssignmentForDate(
|
||||||
|
scheduleId: $assignmentModel->schedule->id,
|
||||||
|
setName: $assignmentModel->schedule->set_name,
|
||||||
|
assignment: $this->assignmentToDomain($assignmentModel),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $assignments;
|
||||||
|
}
|
||||||
|
|
||||||
private function toDomain(ScheduleModel $model, User $user): Schedule
|
private function toDomain(ScheduleModel $model, User $user): Schedule
|
||||||
{
|
{
|
||||||
$assignmentModels = ScheduleAssignmentModel::query()
|
$assignmentModels = ScheduleAssignmentModel::query()
|
||||||
|
|
@ -71,14 +105,7 @@ class EloquentScheduleRepository implements ScheduleRepository
|
||||||
$assignments = [];
|
$assignments = [];
|
||||||
|
|
||||||
foreach ($assignmentModels as $assignmentModel) {
|
foreach ($assignmentModels as $assignmentModel) {
|
||||||
$assignments[] = new ScheduleAssignment(
|
$assignments[] = $this->assignmentToDomain($assignmentModel);
|
||||||
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(
|
return new Schedule(
|
||||||
|
|
@ -92,6 +119,19 @@ class EloquentScheduleRepository implements ScheduleRepository
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function assignmentToDomain(
|
||||||
|
ScheduleAssignmentModel $model,
|
||||||
|
): ScheduleAssignment {
|
||||||
|
return new ScheduleAssignment(
|
||||||
|
id: $model->id,
|
||||||
|
name: $model->element_name,
|
||||||
|
kind: $model->element_kind,
|
||||||
|
path: $model->element_path,
|
||||||
|
scheduledDate: $this->date($model->scheduled_date),
|
||||||
|
position: $model->position,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private function date(string $value): DateTimeImmutable
|
private function date(string $value): DateTimeImmutable
|
||||||
{
|
{
|
||||||
return new DateTimeImmutable($value, new DateTimeZone('UTC'));
|
return new DateTimeImmutable($value, new DateTimeZone('UTC'));
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ namespace App\Schedule;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @property int $id
|
* @property int $id
|
||||||
|
|
@ -14,6 +15,7 @@ use Illuminate\Database\Eloquent\Model;
|
||||||
* @property list<string> $element_path
|
* @property list<string> $element_path
|
||||||
* @property string $scheduled_date
|
* @property string $scheduled_date
|
||||||
* @property int $position
|
* @property int $position
|
||||||
|
* @property-read ScheduleModel $schedule
|
||||||
*
|
*
|
||||||
* @method static Builder<static>|ScheduleAssignmentModel newModelQuery()
|
* @method static Builder<static>|ScheduleAssignmentModel newModelQuery()
|
||||||
* @method static Builder<static>|ScheduleAssignmentModel newQuery()
|
* @method static Builder<static>|ScheduleAssignmentModel newQuery()
|
||||||
|
|
@ -46,4 +48,12 @@ class ScheduleAssignmentModel extends Model
|
||||||
'position' => 'integer',
|
'position' => 'integer',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return BelongsTo<ScheduleModel, $this>
|
||||||
|
*/
|
||||||
|
public function schedule(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(ScheduleModel::class, 'schedule_id');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
namespace App\Schedule;
|
namespace App\Schedule;
|
||||||
|
|
||||||
use App\User\User;
|
use App\User\User;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
|
||||||
interface ScheduleRepository
|
interface ScheduleRepository
|
||||||
{
|
{
|
||||||
|
|
@ -14,4 +15,12 @@ interface ScheduleRepository
|
||||||
* @return list<Schedule>
|
* @return list<Schedule>
|
||||||
*/
|
*/
|
||||||
public function findAllForUser(User $user): array;
|
public function findAllForUser(User $user): array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<AssignmentForDate>
|
||||||
|
*/
|
||||||
|
public function findAssignmentsForUserOnDate(
|
||||||
|
User $user,
|
||||||
|
DateTimeImmutable $date,
|
||||||
|
): array;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Schedule\UseCases\ListAssignmentsForDate;
|
||||||
|
|
||||||
|
use App\Exceptions\BadRequestException;
|
||||||
|
use App\Schedule\AssignmentForDate;
|
||||||
|
use App\Schedule\ScheduleRepository;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use DateTimeZone;
|
||||||
|
|
||||||
|
class ListAssignmentsForDate
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private ScheduleRepository $scheduleRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<AssignmentForDate>
|
||||||
|
* @throws BadRequestException
|
||||||
|
*/
|
||||||
|
public function execute(ListAssignmentsForDateRequest $request): array
|
||||||
|
{
|
||||||
|
$date = $this->parseDate($request->date);
|
||||||
|
|
||||||
|
return $this->scheduleRepository->findAssignmentsForUserOnDate(
|
||||||
|
$request->user,
|
||||||
|
$date,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws BadRequestException
|
||||||
|
*/
|
||||||
|
private function parseDate(?string $value): DateTimeImmutable
|
||||||
|
{
|
||||||
|
if ($value === null || $value === '') {
|
||||||
|
throw new BadRequestException('date 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(
|
||||||
|
'date must be a valid date in YYYY-MM-DD format',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $date;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Schedule\UseCases\ListAssignmentsForDate;
|
||||||
|
|
||||||
|
use App\User\User;
|
||||||
|
|
||||||
|
final readonly class ListAssignmentsForDateRequest
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public User $user,
|
||||||
|
public ?string $date,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
@ -15,6 +15,7 @@ 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::get('/assignments', [ScheduleController::class, 'assignments']);
|
||||||
Route::post('/schedules', [ScheduleController::class, 'store']);
|
Route::post('/schedules', [ScheduleController::class, 'store']);
|
||||||
Route::get('/schedules', [ScheduleController::class, 'index']);
|
Route::get('/schedules', [ScheduleController::class, 'index']);
|
||||||
Route::get('/schedules/{scheduleId}', [ScheduleController::class, 'show'])
|
Route::get('/schedules/{scheduleId}', [ScheduleController::class, 'show'])
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,12 @@
|
||||||
namespace Tests\Fakes;
|
namespace Tests\Fakes;
|
||||||
|
|
||||||
use App\Schedule\CreateScheduleDto;
|
use App\Schedule\CreateScheduleDto;
|
||||||
|
use App\Schedule\AssignmentForDate;
|
||||||
use App\Schedule\Schedule;
|
use App\Schedule\Schedule;
|
||||||
use App\Schedule\ScheduleAssignment;
|
use App\Schedule\ScheduleAssignment;
|
||||||
use App\Schedule\ScheduleRepository;
|
use App\Schedule\ScheduleRepository;
|
||||||
use App\User\User;
|
use App\User\User;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
|
||||||
class FakeScheduleRepository implements ScheduleRepository
|
class FakeScheduleRepository implements ScheduleRepository
|
||||||
{
|
{
|
||||||
|
|
@ -72,6 +74,31 @@ class FakeScheduleRepository implements ScheduleRepository
|
||||||
}, array_values($schedules));
|
}, array_values($schedules));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function findAssignmentsForUserOnDate(
|
||||||
|
User $user,
|
||||||
|
DateTimeImmutable $date,
|
||||||
|
): array {
|
||||||
|
$assignments = [];
|
||||||
|
|
||||||
|
foreach ($this->findAllForUser($user) as $schedule) {
|
||||||
|
foreach ($schedule->getAssignments() as $assignment) {
|
||||||
|
if ($assignment->getScheduledDate()->format('Y-m-d')
|
||||||
|
!== $date->format('Y-m-d')
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$assignments[] = new AssignmentForDate(
|
||||||
|
scheduleId: $schedule->getId(),
|
||||||
|
setName: $schedule->getSetName(),
|
||||||
|
assignment: $assignment,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $assignments;
|
||||||
|
}
|
||||||
|
|
||||||
private function copy(Schedule $schedule): Schedule
|
private function copy(Schedule $schedule): Schedule
|
||||||
{
|
{
|
||||||
$assignments = array_map(
|
$assignments = array_map(
|
||||||
|
|
|
||||||
|
|
@ -221,6 +221,136 @@ class ScheduleEndpointTest extends TestCase
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_it_lists_the_users_assignments_for_a_date(): void
|
||||||
|
{
|
||||||
|
$user = $this->createUser('reader@example.com');
|
||||||
|
$otherUser = $this->createUser('other@example.com');
|
||||||
|
$olderSet = $this->createSet($user, 'Older course');
|
||||||
|
$olderLevel = $this->createLevel($olderSet, 'lesson');
|
||||||
|
$olderRepository = app(ElementRepository::class);
|
||||||
|
$olderRepository->create(new CreateElementDto(
|
||||||
|
name: 'First lesson',
|
||||||
|
level: $olderLevel,
|
||||||
|
parentElement: null,
|
||||||
|
));
|
||||||
|
$olderRepository->create(new CreateElementDto(
|
||||||
|
name: 'Second lesson',
|
||||||
|
level: $olderLevel,
|
||||||
|
parentElement: null,
|
||||||
|
));
|
||||||
|
$newerSet = $this->createSet($user, 'Newer course');
|
||||||
|
$newerLevel = $this->createLevel($newerSet, 'chapter');
|
||||||
|
app(ElementRepository::class)->create(new CreateElementDto(
|
||||||
|
name: 'Only chapter',
|
||||||
|
level: $newerLevel,
|
||||||
|
parentElement: null,
|
||||||
|
));
|
||||||
|
$this->createSession($user, 'valid-token');
|
||||||
|
$this->createSession($otherUser, 'other-token');
|
||||||
|
|
||||||
|
$this->credentialedPost('/api/schedules', [
|
||||||
|
'setId' => $olderSet->getId(),
|
||||||
|
'levelId' => $olderLevel->getId(),
|
||||||
|
'startDate' => '2026-08-15',
|
||||||
|
'targetDate' => '2026-08-15',
|
||||||
|
])->assertCreated();
|
||||||
|
$this->credentialedPost('/api/schedules', [
|
||||||
|
'setId' => $newerSet->getId(),
|
||||||
|
'levelId' => $newerLevel->getId(),
|
||||||
|
'startDate' => '2026-08-15',
|
||||||
|
'targetDate' => '2026-08-15',
|
||||||
|
])->assertCreated();
|
||||||
|
$this->withCredentials()
|
||||||
|
->withUnencryptedCookie(
|
||||||
|
AuthMiddleware::COOKIE_NAME,
|
||||||
|
'other-token',
|
||||||
|
)->postJson('/api/schedules', [
|
||||||
|
'setId' => $newerSet->getId(),
|
||||||
|
'levelId' => $newerLevel->getId(),
|
||||||
|
'startDate' => '2026-08-15',
|
||||||
|
'targetDate' => '2026-08-15',
|
||||||
|
])->assertCreated();
|
||||||
|
$this->credentialedPost('/api/schedules', [
|
||||||
|
'setId' => $olderSet->getId(),
|
||||||
|
'levelId' => $olderLevel->getId(),
|
||||||
|
'startDate' => '2026-08-16',
|
||||||
|
'targetDate' => '2026-08-16',
|
||||||
|
])->assertCreated();
|
||||||
|
|
||||||
|
$this->credentialedGet('/api/assignments?date=2026-08-15')
|
||||||
|
->assertOk()
|
||||||
|
->assertExactJson([
|
||||||
|
'date' => '2026-08-15',
|
||||||
|
'assignments' => [
|
||||||
|
[
|
||||||
|
'id' => 3,
|
||||||
|
'schedule' => [
|
||||||
|
'id' => 2,
|
||||||
|
'set' => [
|
||||||
|
'name' => 'Newer course',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'element' => [
|
||||||
|
'name' => 'Only chapter',
|
||||||
|
'kind' => 'chapter',
|
||||||
|
'path' => ['Only chapter'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => 1,
|
||||||
|
'schedule' => [
|
||||||
|
'id' => 1,
|
||||||
|
'set' => [
|
||||||
|
'name' => 'Older course',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'element' => [
|
||||||
|
'name' => 'First lesson',
|
||||||
|
'kind' => 'lesson',
|
||||||
|
'path' => ['First lesson'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => 2,
|
||||||
|
'schedule' => [
|
||||||
|
'id' => 1,
|
||||||
|
'set' => [
|
||||||
|
'name' => 'Older course',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'element' => [
|
||||||
|
'name' => 'Second lesson',
|
||||||
|
'kind' => 'lesson',
|
||||||
|
'path' => ['Second lesson'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_returns_an_empty_assignment_list_for_a_date(): void
|
||||||
|
{
|
||||||
|
$user = $this->createUser('reader@example.com');
|
||||||
|
$this->createSession($user, 'valid-token');
|
||||||
|
|
||||||
|
$this->credentialedGet('/api/assignments?date=2026-08-15')
|
||||||
|
->assertOk()
|
||||||
|
->assertExactJson([
|
||||||
|
'date' => '2026-08-15',
|
||||||
|
'assignments' => [],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_rejects_invalid_assignment_dates(): void
|
||||||
|
{
|
||||||
|
$user = $this->createUser('reader@example.com');
|
||||||
|
$this->createSession($user, 'valid-token');
|
||||||
|
|
||||||
|
$this->credentialedGet('/api/assignments')
|
||||||
|
->assertBadRequest()
|
||||||
|
->assertExactJson(['error' => 'date is required']);
|
||||||
|
}
|
||||||
|
|
||||||
public function test_it_is_stable_after_sources_change_or_are_deleted(): void
|
public function test_it_is_stable_after_sources_change_or_are_deleted(): void
|
||||||
{
|
{
|
||||||
$user = $this->createUser('reader@example.com');
|
$user = $this->createUser('reader@example.com');
|
||||||
|
|
@ -323,6 +453,8 @@ class ScheduleEndpointTest extends TestCase
|
||||||
|
|
||||||
public function test_schedule_endpoints_require_authentication(): void
|
public function test_schedule_endpoints_require_authentication(): void
|
||||||
{
|
{
|
||||||
|
$this->getJson('/api/assignments?date=2026-08-15')
|
||||||
|
->assertStatus(401);
|
||||||
$this->getJson('/api/schedules')->assertStatus(401);
|
$this->getJson('/api/schedules')->assertStatus(401);
|
||||||
$this->getJson('/api/schedules/1')->assertStatus(401);
|
$this->getJson('/api/schedules/1')->assertStatus(401);
|
||||||
$this->postJson('/api/schedules', [])->assertStatus(401);
|
$this->postJson('/api/schedules', [])->assertStatus(401);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,143 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit\Schedule\UseCases;
|
||||||
|
|
||||||
|
use App\Exceptions\BadRequestException;
|
||||||
|
use App\Schedule\CreateScheduleAssignmentDto;
|
||||||
|
use App\Schedule\CreateScheduleDto;
|
||||||
|
use App\Schedule\ScheduleAssignment;
|
||||||
|
use App\Schedule\UseCases\ListAssignmentsForDate\ListAssignmentsForDate;
|
||||||
|
use App\Schedule\UseCases\ListAssignmentsForDate\ListAssignmentsForDateRequest;
|
||||||
|
use App\Shared\ValueObject\EmailAddress;
|
||||||
|
use App\User\User;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use DateTimeZone;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Tests\Fakes\FakeScheduleRepository;
|
||||||
|
|
||||||
|
class ListAssignmentsForDateTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_it_lists_the_users_assignments_for_the_date(): void
|
||||||
|
{
|
||||||
|
$user = $this->user(1, 'reader@example.com');
|
||||||
|
$otherUser = $this->user(2, 'other@example.com');
|
||||||
|
$repository = new FakeScheduleRepository;
|
||||||
|
$repository->create($this->schedule(
|
||||||
|
user: $user,
|
||||||
|
setName: 'Older plan',
|
||||||
|
date: '2026-08-15',
|
||||||
|
assignmentNames: ['First', 'Second'],
|
||||||
|
));
|
||||||
|
$repository->create($this->schedule(
|
||||||
|
user: $user,
|
||||||
|
setName: 'Newer plan',
|
||||||
|
date: '2026-08-15',
|
||||||
|
assignmentNames: ['Third'],
|
||||||
|
));
|
||||||
|
$repository->create($this->schedule(
|
||||||
|
user: $otherUser,
|
||||||
|
setName: 'Private plan',
|
||||||
|
date: '2026-08-15',
|
||||||
|
assignmentNames: ['Hidden'],
|
||||||
|
));
|
||||||
|
$repository->create($this->schedule(
|
||||||
|
user: $user,
|
||||||
|
setName: 'Tomorrow plan',
|
||||||
|
date: '2026-08-16',
|
||||||
|
assignmentNames: ['Later'],
|
||||||
|
));
|
||||||
|
|
||||||
|
$assignments = (new ListAssignmentsForDate($repository))->execute(
|
||||||
|
new ListAssignmentsForDateRequest(
|
||||||
|
user: $user,
|
||||||
|
date: '2026-08-15',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertSame(
|
||||||
|
['Newer plan', 'Older plan', 'Older plan'],
|
||||||
|
array_map(function ($assignment): string {
|
||||||
|
return $assignment->getSetName();
|
||||||
|
}, $assignments),
|
||||||
|
);
|
||||||
|
$this->assertSame(
|
||||||
|
['Third', 'First', 'Second'],
|
||||||
|
array_map(function ($assignment): string {
|
||||||
|
return $assignment->getAssignment()->getName();
|
||||||
|
}, $assignments),
|
||||||
|
);
|
||||||
|
$this->assertSame(2, $assignments[0]->getScheduleId());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_rejects_a_missing_date(): void
|
||||||
|
{
|
||||||
|
$this->expectException(BadRequestException::class);
|
||||||
|
$this->expectExceptionMessage('date is required');
|
||||||
|
|
||||||
|
(new ListAssignmentsForDate(new FakeScheduleRepository))->execute(
|
||||||
|
new ListAssignmentsForDateRequest(
|
||||||
|
user: $this->user(1, 'reader@example.com'),
|
||||||
|
date: null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_rejects_an_invalid_date(): void
|
||||||
|
{
|
||||||
|
$this->expectException(BadRequestException::class);
|
||||||
|
$this->expectExceptionMessage(
|
||||||
|
'date must be a valid date in YYYY-MM-DD format',
|
||||||
|
);
|
||||||
|
|
||||||
|
(new ListAssignmentsForDate(new FakeScheduleRepository))->execute(
|
||||||
|
new ListAssignmentsForDateRequest(
|
||||||
|
user: $this->user(1, 'reader@example.com'),
|
||||||
|
date: '2026-02-30',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function user(int $id, string $email): User
|
||||||
|
{
|
||||||
|
return new User(
|
||||||
|
id: $id,
|
||||||
|
email: new EmailAddress($email),
|
||||||
|
passwordHash: 'hashed-password',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $assignmentNames
|
||||||
|
*/
|
||||||
|
private function schedule(
|
||||||
|
User $user,
|
||||||
|
string $setName,
|
||||||
|
string $date,
|
||||||
|
array $assignmentNames,
|
||||||
|
): CreateScheduleDto {
|
||||||
|
$scheduledDate = new DateTimeImmutable(
|
||||||
|
$date,
|
||||||
|
new DateTimeZone('UTC'),
|
||||||
|
);
|
||||||
|
$assignments = [];
|
||||||
|
|
||||||
|
foreach ($assignmentNames as $index => $name) {
|
||||||
|
$assignments[] = new CreateScheduleAssignmentDto(
|
||||||
|
name: $name,
|
||||||
|
kind: 'lesson',
|
||||||
|
path: [$setName, $name],
|
||||||
|
scheduledDate: $scheduledDate,
|
||||||
|
position: $index + 1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new CreateScheduleDto(
|
||||||
|
user: $user,
|
||||||
|
setName: $setName,
|
||||||
|
elementKind: 'lesson',
|
||||||
|
startDate: $scheduledDate,
|
||||||
|
targetDate: $scheduledDate,
|
||||||
|
assignments: $assignments,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -17,6 +17,12 @@ describe('email confirmation', () => {
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
body: { schedules: [] },
|
body: { schedules: [] },
|
||||||
})
|
})
|
||||||
|
cy.intercept('GET', '**/api/assignments?date=*', (request) => {
|
||||||
|
request.reply({
|
||||||
|
statusCode: 200,
|
||||||
|
body: { date: request.query.date, assignments: [] },
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('chooses a password, confirms the account, and opens the dashboard', () => {
|
it('chooses a password, confirms the account, and opens the dashboard', () => {
|
||||||
|
|
@ -39,7 +45,7 @@ describe('email confirmation', () => {
|
||||||
cy.wait('@confirmEmail')
|
cy.wait('@confirmEmail')
|
||||||
|
|
||||||
cy.location('pathname').should('equal', '/dashboard')
|
cy.location('pathname').should('equal', '/dashboard')
|
||||||
cy.get('h1').should('have.text', 'Available sets')
|
cy.get('#sets-heading').should('have.text', 'Available sets')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('validates password length and confirmation before submitting', () => {
|
it('validates password length and confirmation before submitting', () => {
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,12 @@ describe('session authentication', () => {
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
body: { schedules: [] },
|
body: { schedules: [] },
|
||||||
})
|
})
|
||||||
|
cy.intercept('GET', '**/api/assignments?date=*', (request) => {
|
||||||
|
request.reply({
|
||||||
|
statusCode: 200,
|
||||||
|
body: { date: request.query.date, assignments: [] },
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('restores an authenticated session on a protected route', () => {
|
it('restores an authenticated session on a protected route', () => {
|
||||||
|
|
@ -59,7 +65,7 @@ describe('session authentication', () => {
|
||||||
cy.wait('@me')
|
cy.wait('@me')
|
||||||
|
|
||||||
cy.location('pathname').should('equal', '/dashboard')
|
cy.location('pathname').should('equal', '/dashboard')
|
||||||
cy.get('h1').should('have.text', 'Available sets')
|
cy.get('#sets-heading').should('have.text', 'Available sets')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('redirects an unauthenticated protected route to login', () => {
|
it('redirects an unauthenticated protected route to login', () => {
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@ describe('set element layout', () => {
|
||||||
interceptAuthenticatedUser()
|
interceptAuthenticatedUser()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('opens a set from the dashboard and shows its full hierarchy', () => {
|
it('opens a set with a foldable element hierarchy', () => {
|
||||||
cy.intercept('GET', '**/api/sets', {
|
cy.intercept('GET', '**/api/sets', {
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
body: { sets: [{ id: 41, name: 'Bible' }] },
|
body: { sets: [{ id: 41, name: 'Bible' }] },
|
||||||
|
|
@ -79,13 +79,35 @@ describe('set element layout', () => {
|
||||||
cy.get('[data-element-id="1"] > .element-node__card')
|
cy.get('[data-element-id="1"] > .element-node__card')
|
||||||
.should('contain.text', 'Genesis')
|
.should('contain.text', 'Genesis')
|
||||||
.and('contain.text', 'book')
|
.and('contain.text', 'book')
|
||||||
|
.and('have.attr', 'aria-expanded', 'false')
|
||||||
|
.and('have.attr', 'aria-controls', 'element-children-1')
|
||||||
|
cy.get('[data-element-id="2"] > .element-node__card').should(
|
||||||
|
'not.match',
|
||||||
|
'button',
|
||||||
|
)
|
||||||
|
cy.get('[data-element-id="3"]').should('not.exist')
|
||||||
|
|
||||||
|
cy.get('[data-element-id="1"] > button.element-node__card').click()
|
||||||
|
cy.get('[data-element-id="1"] > .element-node__card').should(
|
||||||
|
'have.attr',
|
||||||
|
'aria-expanded',
|
||||||
|
'true',
|
||||||
|
)
|
||||||
cy.get('[data-element-id="1"] > ol > li').then(($nodes) => {
|
cy.get('[data-element-id="1"] > ol > li').then(($nodes) => {
|
||||||
expect([...$nodes].map((node) => node.dataset.elementId)).to.deep.equal([
|
expect([...$nodes].map((node) => node.dataset.elementId)).to.deep.equal([
|
||||||
'3',
|
'3',
|
||||||
'5',
|
'5',
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
cy.get('[data-element-id="3"] > ol > li')
|
cy.get('[data-element-id="4"]').should('not.exist')
|
||||||
|
|
||||||
|
cy.get('[data-element-id="3"] > button.element-node__card').click()
|
||||||
|
cy.get('[data-element-id="3"] > .element-node__card').should(
|
||||||
|
'have.attr',
|
||||||
|
'aria-expanded',
|
||||||
|
'true',
|
||||||
|
)
|
||||||
|
cy.get('#element-children-3 > li')
|
||||||
.should('have.length', 1)
|
.should('have.length', 1)
|
||||||
.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')
|
||||||
|
|
@ -93,6 +115,15 @@ describe('set element layout', () => {
|
||||||
.find('.element-node__kind')
|
.find('.element-node__kind')
|
||||||
.should('have.text', 'Chapter_sections-v2')
|
.should('have.text', 'Chapter_sections-v2')
|
||||||
.and('have.css', 'text-transform', 'none')
|
.and('have.css', 'text-transform', 'none')
|
||||||
|
|
||||||
|
cy.get('[data-element-id="1"] > button.element-node__card').click()
|
||||||
|
cy.get('[data-element-id="3"]').should('not.exist')
|
||||||
|
|
||||||
|
cy.get('[data-element-id="1"] > button.element-node__card').click()
|
||||||
|
cy.get('[data-element-id="4"] > .element-node__card').should(
|
||||||
|
'contain.text',
|
||||||
|
'Chapter 1',
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows loading and empty layout states', () => {
|
it('shows loading and empty layout states', () => {
|
||||||
|
|
@ -183,6 +214,8 @@ describe('set element layout', () => {
|
||||||
cy.wait('@me')
|
cy.wait('@me')
|
||||||
cy.wait('@layout')
|
cy.wait('@layout')
|
||||||
|
|
||||||
|
cy.get('[data-element-id="1"] > button.element-node__card').click()
|
||||||
|
cy.get('[data-element-id="3"] > button.element-node__card').click()
|
||||||
cy.contains('.element-node__card', 'Chapter 1').should('be.visible')
|
cy.contains('.element-node__card', 'Chapter 1').should('be.visible')
|
||||||
cy.document().then((document) => {
|
cy.document().then((document) => {
|
||||||
expect(document.documentElement.scrollWidth).to.be.at.most(
|
expect(document.documentElement.scrollWidth).to.be.at.most(
|
||||||
|
|
|
||||||
|
|
@ -250,6 +250,12 @@ describe('set scheduling', () => {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}).as('schedules')
|
}).as('schedules')
|
||||||
|
cy.intercept('GET', '**/api/assignments?date=*', (request) => {
|
||||||
|
request.reply({
|
||||||
|
statusCode: 200,
|
||||||
|
body: { date: request.query.date, assignments: [] },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
cy.visit('/dashboard')
|
cy.visit('/dashboard')
|
||||||
cy.wait('@me')
|
cy.wait('@me')
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,12 @@ describe('sets dashboard', () => {
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
body: { schedules: [] },
|
body: { schedules: [] },
|
||||||
})
|
})
|
||||||
|
cy.intercept('GET', '**/api/assignments?date=*', (request) => {
|
||||||
|
request.reply({
|
||||||
|
statusCode: 200,
|
||||||
|
body: { date: request.query.date, assignments: [] },
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows every available set as a detail link', () => {
|
it('shows every available set as a detail link', () => {
|
||||||
|
|
@ -38,7 +44,7 @@ describe('sets dashboard', () => {
|
||||||
cy.wait('@me')
|
cy.wait('@me')
|
||||||
cy.wait('@sets')
|
cy.wait('@sets')
|
||||||
|
|
||||||
cy.get('h1').should('have.text', 'Available sets')
|
cy.get('#sets-heading').should('have.text', 'Available sets')
|
||||||
cy.get('ul[aria-label="Available sets"] h2').then(($headings) => {
|
cy.get('ul[aria-label="Available sets"] h2').then(($headings) => {
|
||||||
expect([...$headings].map((heading) => heading.textContent)).to.deep.equal([
|
expect([...$headings].map((heading) => heading.textContent)).to.deep.equal([
|
||||||
'Bible',
|
'Bible',
|
||||||
|
|
|
||||||
157
frontend/website/cypress/e2e/today-assignments.cy.ts
Normal file
157
frontend/website/cypress/e2e/today-assignments.cy.ts
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
const authenticatedUser = {
|
||||||
|
id: 7,
|
||||||
|
email: 'user@example.com',
|
||||||
|
}
|
||||||
|
|
||||||
|
const browserToday = '2026-08-15'
|
||||||
|
|
||||||
|
function interceptDashboardRequests(): void {
|
||||||
|
cy.intercept('GET', '**/api/me', {
|
||||||
|
statusCode: 200,
|
||||||
|
body: { user: authenticatedUser },
|
||||||
|
}).as('me')
|
||||||
|
cy.intercept('GET', '**/api/sets', {
|
||||||
|
statusCode: 200,
|
||||||
|
body: { sets: [] },
|
||||||
|
}).as('sets')
|
||||||
|
cy.intercept('GET', '**/api/schedules', {
|
||||||
|
statusCode: 200,
|
||||||
|
body: { schedules: [] },
|
||||||
|
}).as('schedules')
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("today's assignments", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
cy.clock(new Date(2026, 7, 15, 0, 30).getTime())
|
||||||
|
interceptDashboardRequests()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses the browser date and links every assignment to its schedule', () => {
|
||||||
|
cy.intercept('GET', `**/api/assignments?date=${browserToday}`, (request) => {
|
||||||
|
expect(request.headers.accept).to.equal('application/json')
|
||||||
|
request.reply({
|
||||||
|
statusCode: 200,
|
||||||
|
body: {
|
||||||
|
date: browserToday,
|
||||||
|
assignments: [
|
||||||
|
{
|
||||||
|
id: 12,
|
||||||
|
schedule: {
|
||||||
|
id: 73,
|
||||||
|
set: { name: 'Bible' },
|
||||||
|
},
|
||||||
|
element: {
|
||||||
|
name: 'Chapter 1',
|
||||||
|
kind: 'Chapter_sections-v2',
|
||||||
|
path: ['Genesis', 'Creation', 'Chapter 1'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 13,
|
||||||
|
schedule: {
|
||||||
|
id: 81,
|
||||||
|
set: { name: 'Course' },
|
||||||
|
},
|
||||||
|
element: {
|
||||||
|
name: 'Introduction',
|
||||||
|
kind: 'lesson',
|
||||||
|
path: ['Introduction'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}).as('todayAssignments')
|
||||||
|
|
||||||
|
cy.visit('/dashboard')
|
||||||
|
cy.wait('@me')
|
||||||
|
cy.wait('@todayAssignments')
|
||||||
|
|
||||||
|
cy.get('#today-heading').should('have.text', 'Today')
|
||||||
|
cy.get('[data-today-date]').should(
|
||||||
|
'have.attr',
|
||||||
|
'data-today-date',
|
||||||
|
browserToday,
|
||||||
|
)
|
||||||
|
cy.get('ul[aria-label="Today\'s assignments"] > li').should(
|
||||||
|
'have.length',
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
cy.contains('a', 'Genesis / Creation / Chapter 1')
|
||||||
|
.should('contain.text', 'Bible')
|
||||||
|
.and('have.attr', 'href', '/schedules/73')
|
||||||
|
cy.contains('a', 'Introduction')
|
||||||
|
.should('contain.text', 'Course')
|
||||||
|
.and('have.attr', 'href', '/schedules/81')
|
||||||
|
cy.get('.today-assignment__kind')
|
||||||
|
.first()
|
||||||
|
.should('have.text', 'Chapter_sections-v2')
|
||||||
|
.and('have.css', 'text-transform', 'none')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows loading and empty states', () => {
|
||||||
|
cy.intercept('GET', `**/api/assignments?date=${browserToday}`, {
|
||||||
|
delay: 2500,
|
||||||
|
statusCode: 200,
|
||||||
|
body: {
|
||||||
|
date: browserToday,
|
||||||
|
assignments: [],
|
||||||
|
},
|
||||||
|
}).as('todayAssignments')
|
||||||
|
|
||||||
|
cy.visit('/dashboard')
|
||||||
|
cy.wait('@me')
|
||||||
|
cy.get('.today-assignments [role="status"]').should(
|
||||||
|
'contain.text',
|
||||||
|
"Loading today's assignments...",
|
||||||
|
)
|
||||||
|
cy.wait('@todayAssignments')
|
||||||
|
|
||||||
|
cy.get('.today-assignments [role="status"]').should(
|
||||||
|
'contain.text',
|
||||||
|
'Nothing is assigned for today.',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('retries independently after the request fails', () => {
|
||||||
|
let requestCount = 0
|
||||||
|
cy.intercept(
|
||||||
|
'GET',
|
||||||
|
`**/api/assignments?date=${browserToday}`,
|
||||||
|
(request) => {
|
||||||
|
requestCount += 1
|
||||||
|
request.alias = `todayAssignments${requestCount}`
|
||||||
|
|
||||||
|
if (requestCount === 1) {
|
||||||
|
request.reply({ statusCode: 500 })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
request.reply({
|
||||||
|
statusCode: 200,
|
||||||
|
body: {
|
||||||
|
date: browserToday,
|
||||||
|
assignments: [],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
cy.visit('/dashboard')
|
||||||
|
cy.wait('@me')
|
||||||
|
cy.wait('@todayAssignments1')
|
||||||
|
|
||||||
|
cy.get('.today-assignments [role="alert"]')
|
||||||
|
.should('contain.text', "We couldn't load today's assignments.")
|
||||||
|
.within(() => {
|
||||||
|
cy.contains('button', 'Try again').click()
|
||||||
|
})
|
||||||
|
cy.get('#schedules-heading').should('have.text', 'Your schedules')
|
||||||
|
cy.wait('@todayAssignments2')
|
||||||
|
|
||||||
|
cy.get('.today-assignments [role="status"]').should(
|
||||||
|
'contain.text',
|
||||||
|
'Nothing is assigned for today.',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -1,10 +1,23 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { SetElementNode } from '@/stores/setLayout'
|
import type { SetElementNode } from '@/stores/setLayout'
|
||||||
|
|
||||||
defineProps<{
|
const props = defineProps<{
|
||||||
nodes: SetElementNode[]
|
nodes: SetElementNode[]
|
||||||
|
expandedElementIds: ReadonlySet<number>
|
||||||
label?: string
|
label?: string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
'toggle-element': [elementId: number]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function childListId(elementId: number): string {
|
||||||
|
return `element-children-${elementId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function isExpanded(elementId: number): boolean {
|
||||||
|
return props.expandedElementIds.has(elementId)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -14,12 +27,39 @@ defineProps<{
|
||||||
:aria-label="label"
|
:aria-label="label"
|
||||||
>
|
>
|
||||||
<li v-for="node in nodes" :key="node.id" class="element-tree__item" :data-element-id="node.id">
|
<li v-for="node in nodes" :key="node.id" class="element-tree__item" :data-element-id="node.id">
|
||||||
<div class="element-node__card">
|
<button
|
||||||
|
v-if="node.children.length > 0"
|
||||||
|
type="button"
|
||||||
|
class="element-node__card element-node__card--toggle"
|
||||||
|
:aria-expanded="isExpanded(node.id)"
|
||||||
|
:aria-controls="childListId(node.id)"
|
||||||
|
@click="$emit('toggle-element', node.id)"
|
||||||
|
>
|
||||||
<span class="element-node__name">{{ node.name }}</span>
|
<span class="element-node__name">{{ node.name }}</span>
|
||||||
<span class="element-node__kind">{{ node.kind }}</span>
|
<span class="element-node__metadata">
|
||||||
|
<span class="element-node__kind">{{ node.kind }}</span>
|
||||||
|
<span
|
||||||
|
class="element-node__chevron"
|
||||||
|
:class="{ 'element-node__chevron--expanded': isExpanded(node.id) }"
|
||||||
|
aria-hidden="true"
|
||||||
|
></span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div v-else class="element-node__card">
|
||||||
|
<span class="element-node__name">{{ node.name }}</span>
|
||||||
|
<span class="element-node__metadata">
|
||||||
|
<span class="element-node__kind">{{ node.kind }}</span>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ElementTree v-if="node.children.length > 0" :nodes="node.children" />
|
<ElementTree
|
||||||
|
v-if="node.children.length > 0 && isExpanded(node.id)"
|
||||||
|
:id="childListId(node.id)"
|
||||||
|
:nodes="node.children"
|
||||||
|
:expanded-element-ids="expandedElementIds"
|
||||||
|
@toggle-element="$emit('toggle-element', $event)"
|
||||||
|
/>
|
||||||
</li>
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -69,6 +109,23 @@ defineProps<{
|
||||||
box-shadow: 0 0.55rem 1.5rem rgb(40 62 52 / 6%);
|
box-shadow: 0 0.55rem 1.5rem rgb(40 62 52 / 6%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.element-node__card--toggle {
|
||||||
|
width: 100%;
|
||||||
|
color: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.element-node__card--toggle:hover {
|
||||||
|
border-color: rgb(40 92 78 / 34%);
|
||||||
|
background: #fffdf7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.element-node__card--toggle:focus-visible {
|
||||||
|
outline: 3px solid rgb(86 127 112 / 34%);
|
||||||
|
outline-offset: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
.element-node__name {
|
.element-node__name {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
color: #183029;
|
color: #183029;
|
||||||
|
|
@ -78,6 +135,13 @@ defineProps<{
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.element-node__metadata {
|
||||||
|
display: flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
.element-node__kind {
|
.element-node__kind {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
padding: 0.35rem 0.55rem;
|
padding: 0.35rem 0.55rem;
|
||||||
|
|
@ -90,6 +154,18 @@ defineProps<{
|
||||||
text-transform: none;
|
text-transform: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.element-node__chevron {
|
||||||
|
width: 0.55rem;
|
||||||
|
height: 0.55rem;
|
||||||
|
border-right: 2px solid #5e7067;
|
||||||
|
border-bottom: 2px solid #5e7067;
|
||||||
|
transform: rotate(-45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.element-node__chevron--expanded {
|
||||||
|
transform: rotate(45deg);
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 37.5rem) {
|
@media (max-width: 37.5rem) {
|
||||||
.element-tree {
|
.element-tree {
|
||||||
margin-left: 0.45rem;
|
margin-left: 0.45rem;
|
||||||
|
|
@ -106,6 +182,10 @@ defineProps<{
|
||||||
padding: 0.75rem 0.7rem;
|
padding: 0.75rem 0.7rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.element-node__metadata {
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
.element-node__kind {
|
.element-node__kind {
|
||||||
padding-inline: 0.42rem;
|
padding-inline: 0.42rem;
|
||||||
font-size: 0.56rem;
|
font-size: 0.56rem;
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,15 @@ const scheduleAssignmentSchema = z.object({
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const assignmentForDateSchema = scheduleAssignmentSchema.extend({
|
||||||
|
schedule: z.object({
|
||||||
|
id: z.number().int().positive(),
|
||||||
|
set: z.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
export const scheduleDetailSchema = scheduleSummarySchema.extend({
|
export const scheduleDetailSchema = scheduleSummarySchema.extend({
|
||||||
days: z.array(
|
days: z.array(
|
||||||
z.object({
|
z.object({
|
||||||
|
|
@ -43,12 +52,18 @@ const scheduleResponseSchema = z.object({
|
||||||
schedule: scheduleDetailSchema,
|
schedule: scheduleDetailSchema,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const assignmentsForDateResponseSchema = z.object({
|
||||||
|
date: isoDateSchema,
|
||||||
|
assignments: z.array(assignmentForDateSchema),
|
||||||
|
})
|
||||||
|
|
||||||
const errorResponseSchema = z.object({
|
const errorResponseSchema = z.object({
|
||||||
error: z.string().min(1),
|
error: z.string().min(1),
|
||||||
})
|
})
|
||||||
|
|
||||||
export type ScheduleSummary = z.infer<typeof scheduleSummarySchema>
|
export type ScheduleSummary = z.infer<typeof scheduleSummarySchema>
|
||||||
export type ScheduleDetail = z.infer<typeof scheduleDetailSchema>
|
export type ScheduleDetail = z.infer<typeof scheduleDetailSchema>
|
||||||
|
export type AssignmentForDate = z.infer<typeof assignmentForDateSchema>
|
||||||
export type CreateScheduleInput = {
|
export type CreateScheduleInput = {
|
||||||
setId: number
|
setId: number
|
||||||
levelId: number
|
levelId: number
|
||||||
|
|
@ -59,6 +74,7 @@ export type CreateScheduleInput = {
|
||||||
const LIST_ERROR = "We couldn't load your schedules."
|
const LIST_ERROR = "We couldn't load your schedules."
|
||||||
const DETAIL_ERROR = "We couldn't load this schedule."
|
const DETAIL_ERROR = "We couldn't load this schedule."
|
||||||
const CREATE_ERROR = "We couldn't create this schedule."
|
const CREATE_ERROR = "We couldn't create this schedule."
|
||||||
|
const ASSIGNMENTS_ERROR = "We couldn't load today's assignments."
|
||||||
|
|
||||||
export const useSchedulesStore = defineStore('schedules', () => {
|
export const useSchedulesStore = defineStore('schedules', () => {
|
||||||
const schedules = ref<ScheduleSummary[]>([])
|
const schedules = ref<ScheduleSummary[]>([])
|
||||||
|
|
@ -70,7 +86,11 @@ export const useSchedulesStore = defineStore('schedules', () => {
|
||||||
const detailNotFound = ref(false)
|
const detailNotFound = ref(false)
|
||||||
const creating = ref(false)
|
const creating = ref(false)
|
||||||
const createError = ref<string | null>(null)
|
const createError = ref<string | null>(null)
|
||||||
|
const assignmentsForDate = ref<AssignmentForDate[]>([])
|
||||||
|
const assignmentsLoading = ref(false)
|
||||||
|
const assignmentsError = ref<string | null>(null)
|
||||||
let activeDetailRequestId = 0
|
let activeDetailRequestId = 0
|
||||||
|
let assignmentsRequestId = 0
|
||||||
|
|
||||||
async function fetchSchedules(): Promise<boolean> {
|
async function fetchSchedules(): Promise<boolean> {
|
||||||
listLoading.value = true
|
listLoading.value = true
|
||||||
|
|
@ -202,6 +222,58 @@ export const useSchedulesStore = defineStore('schedules', () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchAssignmentsForDate(date: string): Promise<boolean> {
|
||||||
|
const requestId = ++assignmentsRequestId
|
||||||
|
assignmentsForDate.value = []
|
||||||
|
assignmentsLoading.value = true
|
||||||
|
assignmentsError.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const query = new URLSearchParams({ date })
|
||||||
|
const response = await fetch(`${API_BASE}/api/assignments?${query.toString()}`, {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (requestId !== assignmentsRequestId) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status !== 200) {
|
||||||
|
assignmentsError.value = ASSIGNMENTS_ERROR
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseBody: unknown = await response.json()
|
||||||
|
if (requestId !== assignmentsRequestId) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedResponse = assignmentsForDateResponseSchema.parse(responseBody)
|
||||||
|
if (parsedResponse.date !== date) {
|
||||||
|
throw new Error('assignment response did not match requested date')
|
||||||
|
}
|
||||||
|
assignmentsForDate.value = parsedResponse.assignments
|
||||||
|
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
if (requestId === assignmentsRequestId) {
|
||||||
|
assignmentsForDate.value = []
|
||||||
|
assignmentsError.value = ASSIGNMENTS_ERROR
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
if (requestId === assignmentsRequestId) {
|
||||||
|
assignmentsLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
schedules,
|
schedules,
|
||||||
listLoading,
|
listLoading,
|
||||||
|
|
@ -212,8 +284,12 @@ export const useSchedulesStore = defineStore('schedules', () => {
|
||||||
detailNotFound,
|
detailNotFound,
|
||||||
creating,
|
creating,
|
||||||
createError,
|
createError,
|
||||||
|
assignmentsForDate,
|
||||||
|
assignmentsLoading,
|
||||||
|
assignmentsError,
|
||||||
fetchSchedules,
|
fetchSchedules,
|
||||||
fetchSchedule,
|
fetchSchedule,
|
||||||
createSchedule,
|
createSchedule,
|
||||||
|
fetchAssignmentsForDate,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { onMounted } from 'vue'
|
import { onMounted, ref } from 'vue'
|
||||||
|
|
||||||
import AuthenticatedHeader from '@/components/AuthenticatedHeader.vue'
|
import AuthenticatedHeader from '@/components/AuthenticatedHeader.vue'
|
||||||
import { useSchedulesStore } from '@/stores/schedules'
|
import { useSchedulesStore } from '@/stores/schedules'
|
||||||
|
|
@ -10,11 +10,25 @@ const setsStore = useSetsStore()
|
||||||
const schedulesStore = useSchedulesStore()
|
const schedulesStore = useSchedulesStore()
|
||||||
const { sets, loading, error } = storeToRefs(setsStore)
|
const { sets, loading, error } = storeToRefs(setsStore)
|
||||||
const { schedules, listLoading, listError } = storeToRefs(schedulesStore)
|
const { schedules, listLoading, listError } = storeToRefs(schedulesStore)
|
||||||
|
const { assignmentsForDate, assignmentsLoading, assignmentsError } = storeToRefs(schedulesStore)
|
||||||
|
const todayDate = ref(browserDate(new Date()))
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await Promise.all([setsStore.fetchSets(), schedulesStore.fetchSchedules()])
|
await Promise.all([
|
||||||
|
setsStore.fetchSets(),
|
||||||
|
schedulesStore.fetchSchedules(),
|
||||||
|
schedulesStore.fetchAssignmentsForDate(todayDate.value),
|
||||||
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function browserDate(date: Date): string {
|
||||||
|
const year = String(date.getFullYear()).padStart(4, '0')
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(date.getDate()).padStart(2, '0')
|
||||||
|
|
||||||
|
return `${year}-${month}-${day}`
|
||||||
|
}
|
||||||
|
|
||||||
function formatDate(value: string): string {
|
function formatDate(value: string): string {
|
||||||
return new Intl.DateTimeFormat('en', {
|
return new Intl.DateTimeFormat('en', {
|
||||||
dateStyle: 'medium',
|
dateStyle: 'medium',
|
||||||
|
|
@ -27,6 +41,67 @@ function formatDate(value: string): string {
|
||||||
<main class="dashboard-page">
|
<main class="dashboard-page">
|
||||||
<AuthenticatedHeader />
|
<AuthenticatedHeader />
|
||||||
|
|
||||||
|
<section class="today-assignments" aria-labelledby="today-heading">
|
||||||
|
<header class="today-assignments__introduction">
|
||||||
|
<div>
|
||||||
|
<p class="sets-catalog__eyebrow">Today's work</p>
|
||||||
|
<h1 id="today-heading">Today</h1>
|
||||||
|
</div>
|
||||||
|
<time :datetime="todayDate" :data-today-date="todayDate">
|
||||||
|
{{ formatDate(todayDate) }}
|
||||||
|
</time>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<p v-if="assignmentsLoading" class="today-state" role="status">
|
||||||
|
Loading today's assignments...
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else-if="assignmentsError !== null"
|
||||||
|
class="today-state today-state--error"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
<p>{{ assignmentsError }}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="retry-button"
|
||||||
|
@click="schedulesStore.fetchAssignmentsForDate(todayDate)"
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-else-if="assignmentsForDate.length === 0" class="today-state" role="status">
|
||||||
|
Nothing is assigned for today.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul v-else class="today-assignment-list" aria-label="Today's assignments">
|
||||||
|
<li v-for="assignment in assignmentsForDate" :key="assignment.id">
|
||||||
|
<RouterLink
|
||||||
|
class="today-assignment-link"
|
||||||
|
:to="{
|
||||||
|
name: 'schedule-detail',
|
||||||
|
params: { scheduleId: assignment.schedule.id },
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<article class="today-assignment">
|
||||||
|
<div class="today-assignment__heading">
|
||||||
|
<p>{{ assignment.schedule.set.name }}</p>
|
||||||
|
<span class="today-assignment__kind">{{ assignment.element.kind }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="today-assignment__path">
|
||||||
|
{{ assignment.element.path.join(' / ') }}
|
||||||
|
</p>
|
||||||
|
<span class="today-assignment__action">
|
||||||
|
Open schedule
|
||||||
|
<span aria-hidden="true">→</span>
|
||||||
|
</span>
|
||||||
|
</article>
|
||||||
|
</RouterLink>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="schedules-catalog" aria-labelledby="schedules-heading">
|
<section class="schedules-catalog" aria-labelledby="schedules-heading">
|
||||||
<div class="schedules-catalog__heading">
|
<div class="schedules-catalog__heading">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -83,7 +158,7 @@ function formatDate(value: string): string {
|
||||||
<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>
|
||||||
<h1 id="sets-heading">Available sets</h1>
|
<h2 id="sets-heading">Available sets</h2>
|
||||||
<p class="sets-catalog__description">
|
<p class="sets-catalog__description">
|
||||||
Browse every set available in Attainly and find the collection that fits your next goal.
|
Browse every set available in Attainly and find the collection that fits your next goal.
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -135,11 +210,29 @@ function formatDate(value: string): string {
|
||||||
margin: clamp(4.5rem, 10vh, 7rem) auto 0;
|
margin: clamp(4.5rem, 10vh, 7rem) auto 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.schedules-catalog {
|
.today-assignments {
|
||||||
width: min(100%, 72rem);
|
width: min(100%, 72rem);
|
||||||
margin: clamp(4.5rem, 10vh, 7rem) auto 0;
|
margin: clamp(4.5rem, 10vh, 7rem) auto 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.today-assignments__introduction {
|
||||||
|
display: flex;
|
||||||
|
align-items: end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.today-assignments__introduction time {
|
||||||
|
color: #68776f;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedules-catalog {
|
||||||
|
width: min(100%, 72rem);
|
||||||
|
margin: clamp(3.5rem, 8vh, 5.5rem) auto 0;
|
||||||
|
}
|
||||||
|
|
||||||
.schedules-catalog__heading {
|
.schedules-catalog__heading {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: end;
|
align-items: end;
|
||||||
|
|
@ -176,7 +269,8 @@ function formatDate(value: string): string {
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
.today-assignments h1,
|
||||||
|
.sets-catalog__introduction h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: Georgia, 'Times New Roman', serif;
|
font-family: Georgia, 'Times New Roman', serif;
|
||||||
font-size: clamp(3rem, 7vw, 5.25rem);
|
font-size: clamp(3rem, 7vw, 5.25rem);
|
||||||
|
|
@ -185,6 +279,104 @@ h1 {
|
||||||
letter-spacing: -0.055em;
|
letter-spacing: -0.055em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.today-state {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 7rem;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
margin: 2rem 0 0;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border: 1px solid rgb(24 48 41 / 12%);
|
||||||
|
border-radius: 1rem;
|
||||||
|
color: #68776f;
|
||||||
|
background: rgb(255 253 247 / 72%);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.today-state--error {
|
||||||
|
align-content: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.today-state--error p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.today-assignment-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(min(100%, 20rem), 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
margin: 2rem 0 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.today-assignment-link {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 1rem;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.today-assignment {
|
||||||
|
height: 100%;
|
||||||
|
padding: 1.4rem;
|
||||||
|
border: 1px solid rgb(24 48 41 / 12%);
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: linear-gradient(135deg, rgb(255 253 247 / 98%), rgb(244 238 225 / 86%));
|
||||||
|
box-shadow: 0 0.75rem 2rem rgb(40 62 52 / 7%);
|
||||||
|
transition:
|
||||||
|
border-color 160ms ease,
|
||||||
|
transform 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.today-assignment-link:hover .today-assignment {
|
||||||
|
border-color: rgb(40 92 78 / 35%);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.today-assignment-link:focus-visible {
|
||||||
|
outline: 3px solid rgb(86 127 112 / 38%);
|
||||||
|
outline-offset: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.today-assignment__heading {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
color: #926044;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.today-assignment__heading p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.today-assignment__kind {
|
||||||
|
text-transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.today-assignment__path {
|
||||||
|
margin: 1.75rem 0 0;
|
||||||
|
color: #183029;
|
||||||
|
font-family: Georgia, 'Times New Roman', serif;
|
||||||
|
font-size: 1.45rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.today-assignment__action {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
color: #567064;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
.sets-catalog__description {
|
.sets-catalog__description {
|
||||||
max-width: 38rem;
|
max-width: 38rem;
|
||||||
margin: 1.5rem 0 0;
|
margin: 1.5rem 0 0;
|
||||||
|
|
@ -393,6 +585,15 @@ h1 {
|
||||||
margin-top: 3.75rem;
|
margin-top: 3.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.today-assignments {
|
||||||
|
margin-top: 3.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.today-assignments__introduction {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.schedules-catalog {
|
.schedules-catalog {
|
||||||
margin-top: 3.75rem;
|
margin-top: 3.75rem;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ const route = useRoute()
|
||||||
const setLayoutStore = useSetLayoutStore()
|
const setLayoutStore = useSetLayoutStore()
|
||||||
const { layout, loading, error, notFound } = storeToRefs(setLayoutStore)
|
const { layout, loading, error, notFound } = storeToRefs(setLayoutStore)
|
||||||
const currentSetId = ref<number | null>(null)
|
const currentSetId = ref<number | null>(null)
|
||||||
|
const expandedElementIds = ref(new Set<number>())
|
||||||
const EMPTY_MESSAGE = 'This set does not have any elements yet.'
|
const EMPTY_MESSAGE = 'This set does not have any elements yet.'
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
|
|
@ -19,6 +20,7 @@ watch(
|
||||||
const rawSetId = Array.isArray(setIdParameter) ? setIdParameter[0] : setIdParameter
|
const rawSetId = Array.isArray(setIdParameter) ? setIdParameter[0] : setIdParameter
|
||||||
const setId = Number(rawSetId)
|
const setId = Number(rawSetId)
|
||||||
currentSetId.value = setId
|
currentSetId.value = setId
|
||||||
|
expandedElementIds.value = new Set<number>()
|
||||||
await setLayoutStore.fetchSetLayout(setId)
|
await setLayoutStore.fetchSetLayout(setId)
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
|
|
@ -29,8 +31,21 @@ async function retry(): Promise<void> {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
expandedElementIds.value = new Set<number>()
|
||||||
await setLayoutStore.fetchSetLayout(currentSetId.value)
|
await setLayoutStore.fetchSetLayout(currentSetId.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleElement(elementId: number): void {
|
||||||
|
const nextExpandedElementIds = new Set(expandedElementIds.value)
|
||||||
|
|
||||||
|
if (nextExpandedElementIds.has(elementId)) {
|
||||||
|
nextExpandedElementIds.delete(elementId)
|
||||||
|
} else {
|
||||||
|
nextExpandedElementIds.add(elementId)
|
||||||
|
}
|
||||||
|
|
||||||
|
expandedElementIds.value = nextExpandedElementIds
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -81,7 +96,12 @@ async function retry(): Promise<void> {
|
||||||
></p>
|
></p>
|
||||||
|
|
||||||
<div v-else class="layout-outline">
|
<div v-else class="layout-outline">
|
||||||
<ElementTree :nodes="layout.elements" :label="`${layout.set.name} element layout`" />
|
<ElementTree
|
||||||
|
:nodes="layout.elements"
|
||||||
|
:expanded-element-ids="expandedElementIds"
|
||||||
|
:label="`${layout.set.name} element layout`"
|
||||||
|
@toggle-element="toggleElement"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue