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\NotFoundException;
|
||||
use App\Schedule\AssignmentForDate;
|
||||
use App\Schedule\Schedule;
|
||||
use App\Schedule\ScheduleAssignment;
|
||||
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\GetScheduleRequest;
|
||||
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\User\User;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
|
@ -22,6 +25,7 @@ class ScheduleController extends Controller
|
|||
private CreateSchedule $createSchedule,
|
||||
private ListSchedules $listSchedules,
|
||||
private GetSchedule $getSchedule,
|
||||
private ListAssignmentsForDate $listAssignmentsForDate,
|
||||
) {}
|
||||
|
||||
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>
|
||||
*/
|
||||
|
|
@ -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
|
||||
{
|
||||
/** @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;
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
$assignmentModels = ScheduleAssignmentModel::query()
|
||||
|
|
@ -71,14 +105,7 @@ class EloquentScheduleRepository implements ScheduleRepository
|
|||
$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,
|
||||
);
|
||||
$assignments[] = $this->assignmentToDomain($assignmentModel);
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
return new DateTimeImmutable($value, new DateTimeZone('UTC'));
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ namespace App\Schedule;
|
|||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
|
|
@ -14,6 +15,7 @@ use Illuminate\Database\Eloquent\Model;
|
|||
* @property list<string> $element_path
|
||||
* @property string $scheduled_date
|
||||
* @property int $position
|
||||
* @property-read ScheduleModel $schedule
|
||||
*
|
||||
* @method static Builder<static>|ScheduleAssignmentModel newModelQuery()
|
||||
* @method static Builder<static>|ScheduleAssignmentModel newQuery()
|
||||
|
|
@ -46,4 +48,12 @@ class ScheduleAssignmentModel extends Model
|
|||
'position' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<ScheduleModel, $this>
|
||||
*/
|
||||
public function schedule(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ScheduleModel::class, 'schedule_id');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
namespace App\Schedule;
|
||||
|
||||
use App\User\User;
|
||||
use DateTimeImmutable;
|
||||
|
||||
interface ScheduleRepository
|
||||
{
|
||||
|
|
@ -14,4 +15,12 @@ interface ScheduleRepository
|
|||
* @return list<Schedule>
|
||||
*/
|
||||
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/{setId}', [SetController::class, 'show'])
|
||||
->whereNumber('setId');
|
||||
Route::get('/assignments', [ScheduleController::class, 'assignments']);
|
||||
Route::post('/schedules', [ScheduleController::class, 'store']);
|
||||
Route::get('/schedules', [ScheduleController::class, 'index']);
|
||||
Route::get('/schedules/{scheduleId}', [ScheduleController::class, 'show'])
|
||||
|
|
|
|||
|
|
@ -3,10 +3,12 @@
|
|||
namespace Tests\Fakes;
|
||||
|
||||
use App\Schedule\CreateScheduleDto;
|
||||
use App\Schedule\AssignmentForDate;
|
||||
use App\Schedule\Schedule;
|
||||
use App\Schedule\ScheduleAssignment;
|
||||
use App\Schedule\ScheduleRepository;
|
||||
use App\User\User;
|
||||
use DateTimeImmutable;
|
||||
|
||||
class FakeScheduleRepository implements ScheduleRepository
|
||||
{
|
||||
|
|
@ -72,6 +74,31 @@ class FakeScheduleRepository implements ScheduleRepository
|
|||
}, 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
|
||||
{
|
||||
$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
|
||||
{
|
||||
$user = $this->createUser('reader@example.com');
|
||||
|
|
@ -323,6 +453,8 @@ class ScheduleEndpointTest extends TestCase
|
|||
|
||||
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/1')->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,
|
||||
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', () => {
|
||||
|
|
@ -39,7 +45,7 @@ describe('email confirmation', () => {
|
|||
cy.wait('@confirmEmail')
|
||||
|
||||
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', () => {
|
||||
|
|
|
|||
|
|
@ -47,6 +47,12 @@ describe('session authentication', () => {
|
|||
statusCode: 200,
|
||||
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', () => {
|
||||
|
|
@ -59,7 +65,7 @@ describe('session authentication', () => {
|
|||
cy.wait('@me')
|
||||
|
||||
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', () => {
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ describe('set element layout', () => {
|
|||
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', {
|
||||
statusCode: 200,
|
||||
body: { sets: [{ id: 41, name: 'Bible' }] },
|
||||
|
|
@ -79,13 +79,35 @@ describe('set element layout', () => {
|
|||
cy.get('[data-element-id="1"] > .element-node__card')
|
||||
.should('contain.text', 'Genesis')
|
||||
.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) => {
|
||||
expect([...$nodes].map((node) => node.dataset.elementId)).to.deep.equal([
|
||||
'3',
|
||||
'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)
|
||||
.and('have.attr', 'data-element-id', '4')
|
||||
cy.contains('.element-node__card', 'Chapter 1')
|
||||
|
|
@ -93,6 +115,15 @@ describe('set element layout', () => {
|
|||
.find('.element-node__kind')
|
||||
.should('have.text', 'Chapter_sections-v2')
|
||||
.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', () => {
|
||||
|
|
@ -183,6 +214,8 @@ describe('set element layout', () => {
|
|||
cy.wait('@me')
|
||||
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.document().then((document) => {
|
||||
expect(document.documentElement.scrollWidth).to.be.at.most(
|
||||
|
|
|
|||
|
|
@ -250,6 +250,12 @@ describe('set scheduling', () => {
|
|||
},
|
||||
})
|
||||
}).as('schedules')
|
||||
cy.intercept('GET', '**/api/assignments?date=*', (request) => {
|
||||
request.reply({
|
||||
statusCode: 200,
|
||||
body: { date: request.query.date, assignments: [] },
|
||||
})
|
||||
})
|
||||
|
||||
cy.visit('/dashboard')
|
||||
cy.wait('@me')
|
||||
|
|
|
|||
|
|
@ -17,6 +17,12 @@ describe('sets dashboard', () => {
|
|||
statusCode: 200,
|
||||
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', () => {
|
||||
|
|
@ -38,7 +44,7 @@ describe('sets dashboard', () => {
|
|||
cy.wait('@me')
|
||||
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) => {
|
||||
expect([...$headings].map((heading) => heading.textContent)).to.deep.equal([
|
||||
'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">
|
||||
import type { SetElementNode } from '@/stores/setLayout'
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
nodes: SetElementNode[]
|
||||
expandedElementIds: ReadonlySet<number>
|
||||
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>
|
||||
|
||||
<template>
|
||||
|
|
@ -14,12 +27,39 @@ defineProps<{
|
|||
:aria-label="label"
|
||||
>
|
||||
<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__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>
|
||||
|
||||
<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>
|
||||
</ol>
|
||||
</template>
|
||||
|
|
@ -69,6 +109,23 @@ defineProps<{
|
|||
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 {
|
||||
min-width: 0;
|
||||
color: #183029;
|
||||
|
|
@ -78,6 +135,13 @@ defineProps<{
|
|||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.element-node__metadata {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.element-node__kind {
|
||||
flex: 0 0 auto;
|
||||
padding: 0.35rem 0.55rem;
|
||||
|
|
@ -90,6 +154,18 @@ defineProps<{
|
|||
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) {
|
||||
.element-tree {
|
||||
margin-left: 0.45rem;
|
||||
|
|
@ -106,6 +182,10 @@ defineProps<{
|
|||
padding: 0.75rem 0.7rem;
|
||||
}
|
||||
|
||||
.element-node__metadata {
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.element-node__kind {
|
||||
padding-inline: 0.42rem;
|
||||
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({
|
||||
days: z.array(
|
||||
z.object({
|
||||
|
|
@ -43,12 +52,18 @@ const scheduleResponseSchema = z.object({
|
|||
schedule: scheduleDetailSchema,
|
||||
})
|
||||
|
||||
const assignmentsForDateResponseSchema = z.object({
|
||||
date: isoDateSchema,
|
||||
assignments: z.array(assignmentForDateSchema),
|
||||
})
|
||||
|
||||
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 AssignmentForDate = z.infer<typeof assignmentForDateSchema>
|
||||
export type CreateScheduleInput = {
|
||||
setId: number
|
||||
levelId: number
|
||||
|
|
@ -59,6 +74,7 @@ export type CreateScheduleInput = {
|
|||
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."
|
||||
const ASSIGNMENTS_ERROR = "We couldn't load today's assignments."
|
||||
|
||||
export const useSchedulesStore = defineStore('schedules', () => {
|
||||
const schedules = ref<ScheduleSummary[]>([])
|
||||
|
|
@ -70,7 +86,11 @@ export const useSchedulesStore = defineStore('schedules', () => {
|
|||
const detailNotFound = ref(false)
|
||||
const creating = ref(false)
|
||||
const createError = ref<string | null>(null)
|
||||
const assignmentsForDate = ref<AssignmentForDate[]>([])
|
||||
const assignmentsLoading = ref(false)
|
||||
const assignmentsError = ref<string | null>(null)
|
||||
let activeDetailRequestId = 0
|
||||
let assignmentsRequestId = 0
|
||||
|
||||
async function fetchSchedules(): Promise<boolean> {
|
||||
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 {
|
||||
schedules,
|
||||
listLoading,
|
||||
|
|
@ -212,8 +284,12 @@ export const useSchedulesStore = defineStore('schedules', () => {
|
|||
detailNotFound,
|
||||
creating,
|
||||
createError,
|
||||
assignmentsForDate,
|
||||
assignmentsLoading,
|
||||
assignmentsError,
|
||||
fetchSchedules,
|
||||
fetchSchedule,
|
||||
createSchedule,
|
||||
fetchAssignmentsForDate,
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { onMounted } from 'vue'
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
import AuthenticatedHeader from '@/components/AuthenticatedHeader.vue'
|
||||
import { useSchedulesStore } from '@/stores/schedules'
|
||||
|
|
@ -10,11 +10,25 @@ const setsStore = useSetsStore()
|
|||
const schedulesStore = useSchedulesStore()
|
||||
const { sets, loading, error } = storeToRefs(setsStore)
|
||||
const { schedules, listLoading, listError } = storeToRefs(schedulesStore)
|
||||
const { assignmentsForDate, assignmentsLoading, assignmentsError } = storeToRefs(schedulesStore)
|
||||
const todayDate = ref(browserDate(new Date()))
|
||||
|
||||
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 {
|
||||
return new Intl.DateTimeFormat('en', {
|
||||
dateStyle: 'medium',
|
||||
|
|
@ -27,6 +41,67 @@ function formatDate(value: string): string {
|
|||
<main class="dashboard-page">
|
||||
<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">
|
||||
<div class="schedules-catalog__heading">
|
||||
<div>
|
||||
|
|
@ -83,7 +158,7 @@ function formatDate(value: string): string {
|
|||
<section class="sets-catalog" aria-labelledby="sets-heading">
|
||||
<div class="sets-catalog__introduction">
|
||||
<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">
|
||||
Browse every set available in Attainly and find the collection that fits your next goal.
|
||||
</p>
|
||||
|
|
@ -135,11 +210,29 @@ function formatDate(value: string): string {
|
|||
margin: clamp(4.5rem, 10vh, 7rem) auto 0;
|
||||
}
|
||||
|
||||
.schedules-catalog {
|
||||
.today-assignments {
|
||||
width: min(100%, 72rem);
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
|
|
@ -176,7 +269,8 @@ function formatDate(value: string): string {
|
|||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h1 {
|
||||
.today-assignments h1,
|
||||
.sets-catalog__introduction h2 {
|
||||
margin: 0;
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
font-size: clamp(3rem, 7vw, 5.25rem);
|
||||
|
|
@ -185,6 +279,104 @@ h1 {
|
|||
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 {
|
||||
max-width: 38rem;
|
||||
margin: 1.5rem 0 0;
|
||||
|
|
@ -393,6 +585,15 @@ h1 {
|
|||
margin-top: 3.75rem;
|
||||
}
|
||||
|
||||
.today-assignments {
|
||||
margin-top: 3.75rem;
|
||||
}
|
||||
|
||||
.today-assignments__introduction {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.schedules-catalog {
|
||||
margin-top: 3.75rem;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ const route = useRoute()
|
|||
const setLayoutStore = useSetLayoutStore()
|
||||
const { layout, loading, error, notFound } = storeToRefs(setLayoutStore)
|
||||
const currentSetId = ref<number | null>(null)
|
||||
const expandedElementIds = ref(new Set<number>())
|
||||
const EMPTY_MESSAGE = 'This set does not have any elements yet.'
|
||||
|
||||
watch(
|
||||
|
|
@ -19,6 +20,7 @@ watch(
|
|||
const rawSetId = Array.isArray(setIdParameter) ? setIdParameter[0] : setIdParameter
|
||||
const setId = Number(rawSetId)
|
||||
currentSetId.value = setId
|
||||
expandedElementIds.value = new Set<number>()
|
||||
await setLayoutStore.fetchSetLayout(setId)
|
||||
},
|
||||
{ immediate: true },
|
||||
|
|
@ -29,8 +31,21 @@ async function retry(): Promise<void> {
|
|||
return
|
||||
}
|
||||
|
||||
expandedElementIds.value = new Set<number>()
|
||||
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>
|
||||
|
||||
<template>
|
||||
|
|
@ -81,7 +96,12 @@ async function retry(): Promise<void> {
|
|||
></p>
|
||||
|
||||
<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>
|
||||
</template>
|
||||
</section>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue