Compare commits

...

6 commits

20 changed files with 1142 additions and 41 deletions

View file

@ -14,6 +14,8 @@ 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\Schedule\UseCases\SetAssignmentCompletion\SetAssignmentCompletion;
use App\Schedule\UseCases\SetAssignmentCompletion\SetAssignmentCompletionRequest;
use App\Shared\Http\RequestInput;
use App\User\User;
use Illuminate\Http\JsonResponse;
@ -26,6 +28,7 @@ class ScheduleController extends Controller
private ListSchedules $listSchedules,
private GetSchedule $getSchedule,
private ListAssignmentsForDate $listAssignmentsForDate,
private SetAssignmentCompletion $setAssignmentCompletion,
) {}
public function store(Request $request): JsonResponse
@ -124,6 +127,41 @@ class ScheduleController extends Controller
]);
}
public function updateAssignment(
Request $request,
int $assignmentId,
): JsonResponse {
$input = new RequestInput($request);
try {
$assignment = $this->setAssignmentCompletion->execute(
new SetAssignmentCompletionRequest(
assignmentId: $assignmentId,
user: $this->user($request),
completed: $input->boolean('completed'),
),
);
} catch (BadRequestException $exception) {
return new JsonResponse(
['error' => $exception->getMessage()],
400,
);
} catch (NotFoundException $exception) {
return new JsonResponse(
['error' => $exception->getMessage()],
404,
);
}
return new JsonResponse([
'assignment' => [
'id' => $assignment->getId(),
'completedAt' => $assignment->getCompletedAt()
?->format(DATE_ATOM),
],
]);
}
/**
* @return array<string, mixed>
*/
@ -180,7 +218,7 @@ class ScheduleController extends Controller
}
/**
* @return array{id: int, element: array{
* @return array{id: int, completedAt: string|null, element: array{
* name: string,
* kind: string,
* path: list<string>
@ -191,6 +229,8 @@ class ScheduleController extends Controller
): array {
return [
'id' => $assignment->getId(),
'completedAt' => $assignment->getCompletedAt()
?->format(DATE_ATOM),
'element' => [
'name' => $assignment->getName(),
'kind' => $assignment->getKind(),

View file

@ -5,6 +5,7 @@ namespace App\Schedule;
use App\User\User;
use DateTimeImmutable;
use DateTimeZone;
use DomainException;
use Illuminate\Support\Facades\DB;
class EloquentScheduleRepository implements ScheduleRepository
@ -29,6 +30,7 @@ class EloquentScheduleRepository implements ScheduleRepository
'scheduled_date' => $assignmentDto->scheduledDate
->format('Y-m-d'),
'position' => $assignmentDto->position,
'completed_at' => null,
]);
}
@ -77,6 +79,7 @@ class EloquentScheduleRepository implements ScheduleRepository
->where('schedule_assignments.scheduled_date', $date->format(
'Y-m-d',
))
->whereNull('schedule_assignments.completed_at')
->with('schedule')
->orderByDesc('schedules.id')
->orderBy('schedule_assignments.position')
@ -95,6 +98,43 @@ class EloquentScheduleRepository implements ScheduleRepository
return $assignments;
}
public function findAssignmentForUser(
int $id,
User $user,
): ?ScheduleAssignment {
$model = ScheduleAssignmentModel::query()
->select('schedule_assignments.*')
->join(
'schedules',
'schedules.id',
'=',
'schedule_assignments.schedule_id',
)
->where('schedule_assignments.id', $id)
->where('schedules.user_id', $user->getId())
->first();
return $model === null
? null
: $this->assignmentToDomain($model);
}
public function updateAssignment(
ScheduleAssignment $assignment,
): ScheduleAssignment {
$model = ScheduleAssignmentModel::find($assignment->getId());
if ($model === null) {
throw new DomainException(
"Assignment with id {$assignment->getId()} not found",
);
}
$model->completed_at = $assignment->getCompletedAt();
$model->save();
return $this->assignmentToDomain($model);
}
private function toDomain(ScheduleModel $model, User $user): Schedule
{
$assignmentModels = ScheduleAssignmentModel::query()
@ -129,6 +169,9 @@ class EloquentScheduleRepository implements ScheduleRepository
path: $model->element_path,
scheduledDate: $this->date($model->scheduled_date),
position: $model->position,
completedAt: $model->completed_at === null
? null
: $this->dateTime($model->completed_at),
);
}
@ -136,4 +179,10 @@ class EloquentScheduleRepository implements ScheduleRepository
{
return new DateTimeImmutable($value, new DateTimeZone('UTC'));
}
private function dateTime(DateTimeImmutable $value): DateTimeImmutable
{
return DateTimeImmutable::createFromInterface($value)
->setTimezone(new DateTimeZone('UTC'));
}
}

View file

@ -4,7 +4,7 @@ namespace App\Schedule;
use DateTimeImmutable;
final readonly class ScheduleAssignment
final class ScheduleAssignment
{
/**
* @param list<string> $path
@ -16,6 +16,7 @@ final readonly class ScheduleAssignment
private array $path,
private DateTimeImmutable $scheduledDate,
private int $position,
private ?DateTimeImmutable $completedAt,
) {}
public function getId(): int
@ -50,4 +51,21 @@ final readonly class ScheduleAssignment
{
return $this->position;
}
public function getCompletedAt(): ?DateTimeImmutable
{
return $this->completedAt;
}
public function complete(DateTimeImmutable $completedAt): void
{
if ($this->completedAt === null) {
$this->completedAt = $completedAt;
}
}
public function reopen(): void
{
$this->completedAt = null;
}
}

View file

@ -2,6 +2,7 @@
namespace App\Schedule;
use DateTimeImmutable;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
@ -15,6 +16,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
* @property list<string> $element_path
* @property string $scheduled_date
* @property int $position
* @property DateTimeImmutable|null $completed_at
* @property-read ScheduleModel $schedule
*
* @method static Builder<static>|ScheduleAssignmentModel newModelQuery()
@ -30,6 +32,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
'element_path',
'scheduled_date',
'position',
'completed_at',
])]
class ScheduleAssignmentModel extends Model
{
@ -46,6 +49,7 @@ class ScheduleAssignmentModel extends Model
'schedule_id' => 'integer',
'element_path' => 'array',
'position' => 'integer',
'completed_at' => 'immutable_datetime',
];
}

View file

@ -23,4 +23,13 @@ interface ScheduleRepository
User $user,
DateTimeImmutable $date,
): array;
public function findAssignmentForUser(
int $id,
User $user,
): ?ScheduleAssignment;
public function updateAssignment(
ScheduleAssignment $assignment,
): ScheduleAssignment;
}

View file

@ -0,0 +1,47 @@
<?php
namespace App\Schedule\UseCases\SetAssignmentCompletion;
use App\Auth\Clock;
use App\Exceptions\BadRequestException;
use App\Exceptions\NotFoundException;
use App\Schedule\ScheduleAssignment;
use App\Schedule\ScheduleRepository;
class SetAssignmentCompletion
{
public function __construct(
private ScheduleRepository $scheduleRepository,
private Clock $clock,
) {}
/**
* @throws BadRequestException
* @throws NotFoundException
*/
public function execute(
SetAssignmentCompletionRequest $request,
): ScheduleAssignment {
if ($request->completed === null) {
throw new BadRequestException(
'completed must be a boolean',
);
}
$assignment = $this->scheduleRepository->findAssignmentForUser(
$request->assignmentId,
$request->user,
);
if ($assignment === null) {
throw new NotFoundException('assignment not found');
}
if ($request->completed) {
$assignment->complete($this->clock->now());
} else {
$assignment->reopen();
}
return $this->scheduleRepository->updateAssignment($assignment);
}
}

View file

@ -0,0 +1,14 @@
<?php
namespace App\Schedule\UseCases\SetAssignmentCompletion;
use App\User\User;
final readonly class SetAssignmentCompletionRequest
{
public function __construct(
public int $assignmentId,
public User $user,
public ?bool $completed,
) {}
}

View file

@ -27,4 +27,11 @@ class RequestInput
return is_int($value) ? $value : null;
}
public function boolean(string $key): ?bool
{
$value = $this->request->input($key);
return is_bool($value) ? $value : null;
}
}

View file

@ -20,10 +20,12 @@ return new class extends Migration
$table->json('element_path');
$table->date('scheduled_date');
$table->unsignedInteger('position');
$table->timestamp('completed_at')->nullable();
$table->unique(['schedule_id', 'position']);
$table->index([
'schedule_id',
'scheduled_date',
'completed_at',
'position',
]);
},

View file

@ -16,6 +16,10 @@ Route::middleware(AuthMiddleware::class)->group(function (): void {
Route::get('/sets/{setId}', [SetController::class, 'show'])
->whereNumber('setId');
Route::get('/assignments', [ScheduleController::class, 'assignments']);
Route::patch(
'/assignments/{assignmentId}',
[ScheduleController::class, 'updateAssignment'],
)->whereNumber('assignmentId');
Route::post('/schedules', [ScheduleController::class, 'store']);
Route::get('/schedules', [ScheduleController::class, 'index']);
Route::get('/schedules/{scheduleId}', [ScheduleController::class, 'show'])

View file

@ -9,6 +9,7 @@ use App\Schedule\ScheduleAssignment;
use App\Schedule\ScheduleRepository;
use App\User\User;
use DateTimeImmutable;
use DomainException;
class FakeScheduleRepository implements ScheduleRepository
{
@ -17,6 +18,8 @@ class FakeScheduleRepository implements ScheduleRepository
*/
private array $schedules = [];
private int $nextAssignmentId = 1;
public function create(CreateScheduleDto $dto): Schedule
{
$id = count($this->schedules) + 1;
@ -24,12 +27,13 @@ class FakeScheduleRepository implements ScheduleRepository
foreach ($dto->assignments as $assignmentDto) {
$assignments[] = new ScheduleAssignment(
id: count($assignments) + 1,
id: $this->nextAssignmentId++,
name: $assignmentDto->name,
kind: $assignmentDto->kind,
path: $assignmentDto->path,
scheduledDate: $assignmentDto->scheduledDate,
position: $assignmentDto->position,
completedAt: null,
);
}
@ -84,6 +88,7 @@ class FakeScheduleRepository implements ScheduleRepository
foreach ($schedule->getAssignments() as $assignment) {
if ($assignment->getScheduledDate()->format('Y-m-d')
!== $date->format('Y-m-d')
|| $assignment->getCompletedAt() !== null
) {
continue;
}
@ -99,21 +104,66 @@ class FakeScheduleRepository implements ScheduleRepository
return $assignments;
}
public function findAssignmentForUser(
int $id,
User $user,
): ?ScheduleAssignment {
foreach ($this->findAllForUser($user) as $schedule) {
foreach ($schedule->getAssignments() as $assignment) {
if ($assignment->getId() === $id) {
return $this->copyAssignment($assignment);
}
}
}
return null;
}
public function updateAssignment(
ScheduleAssignment $assignment,
): ScheduleAssignment {
foreach ($this->schedules as $id => $schedule) {
$assignments = [];
$found = false;
foreach ($schedule->getAssignments() as $storedAssignment) {
if ($storedAssignment->getId() === $assignment->getId()) {
$assignments[] = $this->copyAssignment($assignment);
$found = true;
} else {
$assignments[] = $this->copyAssignment(
$storedAssignment,
);
}
}
if ($found) {
$this->schedules[$id] = new Schedule(
id: $schedule->getId(),
user: $schedule->getUser(),
setName: $schedule->getSetName(),
elementKind: $schedule->getElementKind(),
startDate: $schedule->getStartDate(),
targetDate: $schedule->getTargetDate(),
assignments: $assignments,
);
return $this->copyAssignment($assignment);
}
}
throw new DomainException(
"Assignment with id {$assignment->getId()} not found",
);
}
private function copy(Schedule $schedule): Schedule
{
$assignments = array_map(
function (ScheduleAssignment $assignment): ScheduleAssignment {
return new ScheduleAssignment(
id: $assignment->getId(),
name: $assignment->getName(),
kind: $assignment->getKind(),
path: $assignment->getPath(),
scheduledDate: $assignment->getScheduledDate(),
position: $assignment->getPosition(),
);
},
$schedule->getAssignments(),
);
$assignments = array_map(function (
ScheduleAssignment $assignment,
): ScheduleAssignment {
return $this->copyAssignment($assignment);
}, $schedule->getAssignments());
return new Schedule(
id: $schedule->getId(),
@ -125,4 +175,18 @@ class FakeScheduleRepository implements ScheduleRepository
assignments: $assignments,
);
}
private function copyAssignment(
ScheduleAssignment $assignment,
): ScheduleAssignment {
return new ScheduleAssignment(
id: $assignment->getId(),
name: $assignment->getName(),
kind: $assignment->getKind(),
path: $assignment->getPath(),
scheduledDate: $assignment->getScheduledDate(),
position: $assignment->getPosition(),
completedAt: $assignment->getCompletedAt(),
);
}
}

View file

@ -3,6 +3,7 @@
namespace Tests\Feature\Schedule;
use App\Auth\CreateSessionDto;
use App\Auth\Clock;
use App\Auth\SessionRepository;
use App\Element\CreateElementDto;
use App\Element\ElementModel;
@ -25,6 +26,7 @@ use DateTimeZone;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Testing\TestResponse;
use Tests\TestCase;
use Tests\Fakes\FakeClock;
class ScheduleEndpointTest extends TestCase
{
@ -94,6 +96,7 @@ class ScheduleEndpointTest extends TestCase
'assignments' => [
[
'id' => 1,
'completedAt' => null,
'element' => [
'name' => 'Chapter 1',
'kind' => 'chapter',
@ -115,6 +118,7 @@ class ScheduleEndpointTest extends TestCase
'assignments' => [
[
'id' => 2,
'completedAt' => null,
'element' => [
'name' => 'Chapter 1',
'kind' => 'chapter',
@ -351,6 +355,130 @@ class ScheduleEndpointTest extends TestCase
->assertExactJson(['error' => 'date is required']);
}
public function test_it_completes_and_reopens_an_assignment(): void
{
$user = $this->createUser('reader@example.com');
$set = $this->createSet($user, 'Course');
$lessonLevel = $this->createLevel($set, 'lesson');
$repository = app(ElementRepository::class);
$repository->create(new CreateElementDto(
name: 'First lesson',
level: $lessonLevel,
parentElement: null,
));
$repository->create(new CreateElementDto(
name: 'Second lesson',
level: $lessonLevel,
parentElement: null,
));
$this->createSession($user, 'valid-token');
$completedAt = new DateTimeImmutable(
'2026-08-15T12:30:45',
new DateTimeZone('UTC'),
);
$this->app->instance(Clock::class, new FakeClock($completedAt));
$this->credentialedPost('/api/schedules', [
'setId' => $set->getId(),
'levelId' => $lessonLevel->getId(),
'startDate' => '2026-08-20',
'targetDate' => '2026-08-20',
])->assertCreated();
$this->credentialedPatch('/api/assignments/1', [
'completed' => true,
])->assertOk()->assertExactJson([
'assignment' => [
'id' => 1,
'completedAt' => '2026-08-15T12:30:45+00:00',
],
]);
$this->assertDatabaseHas('schedule_assignments', [
'id' => 1,
'completed_at' => '2026-08-15 12:30:45',
]);
$this->credentialedGet('/api/schedules/1')
->assertOk()
->assertJsonPath(
'schedule.days.0.assignments.0.completedAt',
'2026-08-15T12:30:45+00:00',
)
->assertJsonPath(
'schedule.days.0.assignments.1.completedAt',
null,
);
$this->credentialedGet('/api/assignments?date=2026-08-20')
->assertOk()
->assertJsonCount(1, 'assignments')
->assertJsonPath('assignments.0.id', 2);
$this->credentialedPatch('/api/assignments/1', [
'completed' => false,
])->assertOk()->assertExactJson([
'assignment' => [
'id' => 1,
'completedAt' => null,
],
]);
$this->assertDatabaseHas('schedule_assignments', [
'id' => 1,
'completed_at' => null,
]);
$this->credentialedGet('/api/assignments?date=2026-08-20')
->assertOk()
->assertJsonCount(2, 'assignments');
}
public function test_it_rejects_invalid_assignment_completion_input(): void
{
$user = $this->createUser('reader@example.com');
$this->createSession($user, 'valid-token');
$this->credentialedPatch('/api/assignments/1', [
'completed' => 'yes',
])->assertBadRequest()->assertExactJson([
'error' => 'completed must be a boolean',
]);
}
public function test_it_hides_another_users_assignment(): void
{
$owner = $this->createUser('owner@example.com');
$viewer = $this->createUser('viewer@example.com');
$set = $this->createSet($owner, 'Course');
$lessonLevel = $this->createLevel($set, 'lesson');
app(ElementRepository::class)->create(new CreateElementDto(
name: 'Welcome',
level: $lessonLevel,
parentElement: null,
));
$this->createSession($owner, 'owner-token');
$this->createSession($viewer, 'viewer-token');
$this->withCredentials()
->withUnencryptedCookie(
AuthMiddleware::COOKIE_NAME,
'owner-token',
)->postJson('/api/schedules', [
'setId' => $set->getId(),
'levelId' => $lessonLevel->getId(),
'startDate' => '2026-08-15',
'targetDate' => '2026-08-15',
])->assertCreated();
$this->withCredentials()
->withUnencryptedCookie(
AuthMiddleware::COOKIE_NAME,
'viewer-token',
)->patchJson('/api/assignments/1', [
'completed' => true,
])->assertNotFound()->assertExactJson([
'error' => 'assignment not found',
]);
$this->assertDatabaseHas('schedule_assignments', [
'id' => 1,
'completed_at' => null,
]);
}
public function test_it_is_stable_after_sources_change_or_are_deleted(): void
{
$user = $this->createUser('reader@example.com');
@ -455,6 +583,9 @@ class ScheduleEndpointTest extends TestCase
{
$this->getJson('/api/assignments?date=2026-08-15')
->assertStatus(401);
$this->patchJson('/api/assignments/1', [
'completed' => true,
])->assertStatus(401);
$this->getJson('/api/schedules')->assertStatus(401);
$this->getJson('/api/schedules/1')->assertStatus(401);
$this->postJson('/api/schedules', [])->assertStatus(401);
@ -520,4 +651,18 @@ class ScheduleEndpointTest extends TestCase
'valid-token',
)->getJson($uri);
}
/**
* @param array<string, mixed> $payload
*/
private function credentialedPatch(
string $uri,
array $payload,
): TestResponse {
return $this->withCredentials()
->withUnencryptedCookie(
AuthMiddleware::COOKIE_NAME,
'valid-token',
)->patchJson($uri, $payload);
}
}

View file

@ -0,0 +1,49 @@
<?php
namespace Tests\Unit\Schedule;
use App\Schedule\ScheduleAssignment;
use DateTimeImmutable;
use DateTimeZone;
use PHPUnit\Framework\TestCase;
class ScheduleAssignmentTest extends TestCase
{
public function test_it_completes_only_once_until_reopened(): void
{
$assignment = $this->assignment();
$firstCompletion = $this->utc('2026-08-15T12:00:00');
$laterCompletion = $this->utc('2026-08-15T13:00:00');
$assignment->complete($firstCompletion);
$assignment->complete($laterCompletion);
$this->assertSame($firstCompletion, $assignment->getCompletedAt());
$assignment->reopen();
$this->assertNull($assignment->getCompletedAt());
$assignment->complete($laterCompletion);
$this->assertSame($laterCompletion, $assignment->getCompletedAt());
}
private function assignment(): ScheduleAssignment
{
return new ScheduleAssignment(
id: 1,
name: 'Lesson',
kind: 'lesson',
path: ['Course', 'Lesson'],
scheduledDate: $this->utc('2026-08-15'),
position: 1,
completedAt: null,
);
}
private function utc(string $value): DateTimeImmutable
{
return new DateTimeImmutable($value, new DateTimeZone('UTC'));
}
}

View file

@ -22,7 +22,7 @@ class ListAssignmentsForDateTest extends TestCase
$user = $this->user(1, 'reader@example.com');
$otherUser = $this->user(2, 'other@example.com');
$repository = new FakeScheduleRepository;
$repository->create($this->schedule(
$olderSchedule = $repository->create($this->schedule(
user: $user,
setName: 'Older plan',
date: '2026-08-15',
@ -46,6 +46,12 @@ class ListAssignmentsForDateTest extends TestCase
date: '2026-08-16',
assignmentNames: ['Later'],
));
$completedAssignment = $olderSchedule->getAssignments()[0];
$completedAssignment->complete(new DateTimeImmutable(
'2026-08-15T12:00:00',
new DateTimeZone('UTC'),
));
$repository->updateAssignment($completedAssignment);
$assignments = (new ListAssignmentsForDate($repository))->execute(
new ListAssignmentsForDateRequest(
@ -55,13 +61,13 @@ class ListAssignmentsForDateTest extends TestCase
);
$this->assertSame(
['Newer plan', 'Older plan', 'Older plan'],
['Newer plan', 'Older plan'],
array_map(function ($assignment): string {
return $assignment->getSetName();
}, $assignments),
);
$this->assertSame(
['Third', 'First', 'Second'],
['Third', 'Second'],
array_map(function ($assignment): string {
return $assignment->getAssignment()->getName();
}, $assignments),

View file

@ -0,0 +1,175 @@
<?php
namespace Tests\Unit\Schedule\UseCases;
use App\Exceptions\BadRequestException;
use App\Exceptions\NotFoundException;
use App\Schedule\CreateScheduleAssignmentDto;
use App\Schedule\CreateScheduleDto;
use App\Schedule\UseCases\SetAssignmentCompletion\SetAssignmentCompletion;
use App\Schedule\UseCases\SetAssignmentCompletion\SetAssignmentCompletionRequest;
use App\Shared\ValueObject\EmailAddress;
use App\User\User;
use DateTimeImmutable;
use DateTimeZone;
use PHPUnit\Framework\TestCase;
use Tests\Fakes\FakeClock;
use Tests\Fakes\FakeScheduleRepository;
class SetAssignmentCompletionTest extends TestCase
{
public function test_it_completes_an_owned_assignment_at_the_current_time(): void
{
$user = $this->user(1, 'reader@example.com');
$repository = new FakeScheduleRepository;
$schedule = $repository->create($this->schedule($user));
$now = $this->utc('2026-08-15T12:00:00');
$assignment = (new SetAssignmentCompletion(
$repository,
new FakeClock($now),
))->execute(new SetAssignmentCompletionRequest(
assignmentId: $schedule->getAssignments()[0]->getId(),
user: $user,
completed: true,
));
$this->assertSame($now, $assignment->getCompletedAt());
$this->assertSame(
$now,
$repository->findAssignmentForUser(
$assignment->getId(),
$user,
)?->getCompletedAt(),
);
}
public function test_it_preserves_the_first_completion_time(): void
{
$user = $this->user(1, 'reader@example.com');
$repository = new FakeScheduleRepository;
$schedule = $repository->create($this->schedule($user));
$assignmentId = $schedule->getAssignments()[0]->getId();
$firstCompletion = $this->utc('2026-08-15T12:00:00');
$laterCompletion = $this->utc('2026-08-15T13:00:00');
(new SetAssignmentCompletion(
$repository,
new FakeClock($firstCompletion),
))->execute(new SetAssignmentCompletionRequest(
assignmentId: $assignmentId,
user: $user,
completed: true,
));
$assignment = (new SetAssignmentCompletion(
$repository,
new FakeClock($laterCompletion),
))->execute(new SetAssignmentCompletionRequest(
assignmentId: $assignmentId,
user: $user,
completed: true,
));
$this->assertSame(
$firstCompletion,
$assignment->getCompletedAt(),
);
}
public function test_it_reopens_a_completed_assignment(): void
{
$user = $this->user(1, 'reader@example.com');
$repository = new FakeScheduleRepository;
$schedule = $repository->create($this->schedule($user));
$assignmentId = $schedule->getAssignments()[0]->getId();
$useCase = new SetAssignmentCompletion(
$repository,
new FakeClock($this->utc('2026-08-15T12:00:00')),
);
$useCase->execute(new SetAssignmentCompletionRequest(
assignmentId: $assignmentId,
user: $user,
completed: true,
));
$assignment = $useCase->execute(
new SetAssignmentCompletionRequest(
assignmentId: $assignmentId,
user: $user,
completed: false,
),
);
$this->assertNull($assignment->getCompletedAt());
}
public function test_it_hides_another_users_assignment(): void
{
$repository = new FakeScheduleRepository;
$owner = $this->user(1, 'owner@example.com');
$schedule = $repository->create($this->schedule($owner));
$this->expectException(NotFoundException::class);
$this->expectExceptionMessage('assignment not found');
(new SetAssignmentCompletion(
$repository,
new FakeClock($this->utc('2026-08-15T12:00:00')),
))->execute(new SetAssignmentCompletionRequest(
assignmentId: $schedule->getAssignments()[0]->getId(),
user: $this->user(2, 'viewer@example.com'),
completed: true,
));
}
public function test_it_requires_a_boolean_completion_value(): void
{
$this->expectException(BadRequestException::class);
$this->expectExceptionMessage('completed must be a boolean');
(new SetAssignmentCompletion(
new FakeScheduleRepository,
new FakeClock($this->utc('2026-08-15T12:00:00')),
))->execute(new SetAssignmentCompletionRequest(
assignmentId: 1,
user: $this->user(1, 'reader@example.com'),
completed: null,
));
}
private function schedule(User $user): CreateScheduleDto
{
$scheduledDate = $this->utc('2026-08-20');
return new CreateScheduleDto(
user: $user,
setName: 'Course',
elementKind: 'lesson',
startDate: $scheduledDate,
targetDate: $scheduledDate,
assignments: [
new CreateScheduleAssignmentDto(
name: 'Lesson',
kind: 'lesson',
path: ['Course', 'Lesson'],
scheduledDate: $scheduledDate,
position: 1,
),
],
);
}
private function user(int $id, string $email): User
{
return new User(
id: $id,
email: new EmailAddress($email),
passwordHash: 'hashed-password',
);
}
private function utc(string $value): DateTimeImmutable
{
return new DateTimeImmutable($value, new DateTimeZone('UTC'));
}
}

View file

@ -74,6 +74,7 @@ const scheduleDetail = {
assignments: [
{
id: 1,
completedAt: null,
element: {
name: 'Chapter 1',
kind: 'Chapter_sections-v2',
@ -88,6 +89,7 @@ const scheduleDetail = {
assignments: [
{
id: 2,
completedAt: null,
element: {
name: 'Chapter 1',
kind: 'Chapter_sections-v2',
@ -100,6 +102,25 @@ const scheduleDetail = {
},
}
const completedScheduleDetail = {
schedule: {
...scheduleDetail.schedule,
days: [
scheduleDetail.schedule.days[0],
scheduleDetail.schedule.days[1],
{
...scheduleDetail.schedule.days[2],
assignments: [
{
...scheduleDetail.schedule.days[2]?.assignments[0],
completedAt: '2026-08-15T09:30:00+00:00',
},
],
},
],
},
}
function interceptAuthenticatedUser(): void {
cy.intercept('GET', '**/api/me', {
statusCode: 200,
@ -227,6 +248,65 @@ describe('set scheduling', () => {
cy.get('h1').should('have.text', 'Schedule not found')
})
it('completes and reopens assignments from the schedule', () => {
const completedAt = '2026-08-15T12:30:00+00:00'
cy.intercept('GET', '**/api/schedules/73', {
statusCode: 200,
body: completedScheduleDetail,
}).as('schedule')
cy.intercept('PATCH', '**/api/assignments/1', (request) => {
expect(request.body).to.deep.equal({ completed: true })
request.reply({
delay: 400,
statusCode: 200,
body: {
assignment: { id: 1, completedAt },
},
})
}).as('completeAssignment')
cy.intercept('PATCH', '**/api/assignments/2', (request) => {
expect(request.body).to.deep.equal({ completed: false })
request.reply({
statusCode: 200,
body: {
assignment: { id: 2, completedAt: null },
},
})
}).as('reopenAssignment')
cy.visit('/schedules/73')
cy.wait('@me')
cy.wait('@schedule')
cy.get('[data-schedule-assignment="2"]').within(() => {
cy.contains('Completed').should('be.visible')
cy.get('time')
.should('have.attr', 'datetime', '2026-08-15T09:30:00+00:00')
.and('have.text', formatCompletionTime('2026-08-15T09:30:00+00:00'))
cy.contains('button', 'Reopen').click()
})
cy.wait('@reopenAssignment')
cy.get('[data-schedule-assignment="2"]')
.should('contain.text', 'Not completed')
.and('not.contain.text', 'Completed')
.within(() => {
cy.contains('button', 'Mark complete').should('be.enabled')
})
cy.get('[data-schedule-assignment="1"]').within(() => {
cy.contains('button', 'Mark complete').click()
cy.contains('button', 'Saving...').should('be.disabled')
})
cy.wait('@completeAssignment')
cy.get('[data-schedule-assignment="1"]').within(() => {
cy.contains('Completed').should('be.visible')
cy.get('time')
.should('have.attr', 'datetime', completedAt)
.and('have.text', formatCompletionTime(completedAt))
cy.contains('button', 'Reopen').should('be.enabled')
})
})
it('lists the users schedules on the dashboard', () => {
cy.intercept('GET', '**/api/sets', {
statusCode: 200,
@ -272,3 +352,10 @@ describe('set scheduling', () => {
cy.contains('a', 'Bible').should('have.attr', 'href', '/schedules/73')
})
})
function formatCompletionTime(value: string): string {
return new Intl.DateTimeFormat('en', {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(value))
}

View file

@ -113,6 +113,74 @@ describe("today's assignments", () => {
)
})
it('completes an assignment and keeps failures retryable', () => {
cy.intercept('GET', `**/api/assignments?date=${browserToday}`, {
statusCode: 200,
body: {
date: browserToday,
assignments: [
{
id: 12,
schedule: {
id: 73,
set: { name: 'Bible' },
},
element: {
name: 'Chapter 1',
kind: 'chapter',
path: ['Genesis', 'Chapter 1'],
},
},
],
},
}).as('todayAssignments')
let requestCount = 0
cy.intercept('PATCH', '**/api/assignments/12', (request) => {
requestCount += 1
request.alias = `completion${requestCount}`
expect(request.body).to.deep.equal({ completed: true })
if (requestCount === 1) {
request.reply({ delay: 400, statusCode: 500 })
return
}
request.reply({
delay: 400,
statusCode: 200,
body: {
assignment: {
id: 12,
completedAt: '2026-08-15T12:30:00+00:00',
},
},
})
})
cy.visit('/dashboard')
cy.wait('@me')
cy.wait('@todayAssignments')
cy.get('[data-today-assignment="12"]').within(() => {
cy.contains('button', 'Mark complete').click()
cy.contains('button', 'Completing...').should('be.disabled')
})
cy.wait('@completion1')
cy.get('[data-today-assignment="12"]')
.should('contain.text', "We couldn't update this assignment.")
.within(() => {
cy.contains('button', 'Mark complete').click()
cy.contains('button', 'Completing...').should('be.disabled')
})
cy.wait('@completion2')
cy.get('[data-today-assignment="12"]').should('not.exist')
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(

View file

@ -17,7 +17,7 @@ export const scheduleSummarySchema = z.object({
assignmentCount: z.number().int().nonnegative(),
})
const scheduleAssignmentSchema = z.object({
const assignmentIdentitySchema = z.object({
id: z.number().int().positive(),
element: z.object({
name: z.string().min(1),
@ -26,7 +26,11 @@ const scheduleAssignmentSchema = z.object({
}),
})
export const assignmentForDateSchema = scheduleAssignmentSchema.extend({
const scheduleAssignmentSchema = assignmentIdentitySchema.extend({
completedAt: z.string().datetime({ offset: true }).nullable(),
})
export const assignmentForDateSchema = assignmentIdentitySchema.extend({
schedule: z.object({
id: z.number().int().positive(),
set: z.object({
@ -57,6 +61,13 @@ const assignmentsForDateResponseSchema = z.object({
assignments: z.array(assignmentForDateSchema),
})
const assignmentCompletionResponseSchema = z.object({
assignment: z.object({
id: z.number().int().positive(),
completedAt: z.string().datetime({ offset: true }).nullable(),
}),
})
const errorResponseSchema = z.object({
error: z.string().min(1),
})
@ -75,6 +86,7 @@ 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."
const COMPLETION_ERROR = "We couldn't update this assignment."
export const useSchedulesStore = defineStore('schedules', () => {
const schedules = ref<ScheduleSummary[]>([])
@ -89,6 +101,8 @@ export const useSchedulesStore = defineStore('schedules', () => {
const assignmentsForDate = ref<AssignmentForDate[]>([])
const assignmentsLoading = ref(false)
const assignmentsError = ref<string | null>(null)
const assignmentCompletionPendingIds = ref<number[]>([])
const assignmentCompletionErrors = ref<Record<number, string>>({})
let activeDetailRequestId = 0
let assignmentsRequestId = 0
@ -274,6 +288,84 @@ export const useSchedulesStore = defineStore('schedules', () => {
}
}
async function setAssignmentCompleted(
assignmentId: number,
completed: boolean,
): Promise<boolean> {
if (assignmentCompletionPendingIds.value.includes(assignmentId)) {
return false
}
assignmentCompletionPendingIds.value = [...assignmentCompletionPendingIds.value, assignmentId]
const remainingErrors = { ...assignmentCompletionErrors.value }
delete remainingErrors[assignmentId]
assignmentCompletionErrors.value = remainingErrors
try {
const response = await fetch(`${API_BASE}/api/assignments/${assignmentId}`, {
method: 'PATCH',
credentials: 'include',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({ completed }),
})
if (response.status !== 200) {
throw new Error('assignment completion request failed')
}
const responseBody: unknown = await response.json()
const assignment = assignmentCompletionResponseSchema.parse(responseBody).assignment
const completionDoesNotMatchRequest = completed
? assignment.completedAt === null
: assignment.completedAt !== null
if (assignment.id !== assignmentId || completionDoesNotMatchRequest) {
throw new Error('assignment completion response did not match request')
}
if (activeSchedule.value !== null) {
for (const day of activeSchedule.value.days) {
const activeAssignment = day.assignments.find(
(candidate) => candidate.id === assignmentId,
)
if (activeAssignment !== undefined) {
activeAssignment.completedAt = assignment.completedAt
break
}
}
}
if (assignment.completedAt !== null) {
assignmentsForDate.value = assignmentsForDate.value.filter(
(candidate) => candidate.id !== assignmentId,
)
}
return true
} catch {
assignmentCompletionErrors.value = {
...assignmentCompletionErrors.value,
[assignmentId]: COMPLETION_ERROR,
}
return false
} finally {
assignmentCompletionPendingIds.value = assignmentCompletionPendingIds.value.filter(
(pendingId) => pendingId !== assignmentId,
)
}
}
function isAssignmentCompletionPending(assignmentId: number): boolean {
return assignmentCompletionPendingIds.value.includes(assignmentId)
}
function assignmentCompletionError(assignmentId: number): string | null {
return assignmentCompletionErrors.value[assignmentId] ?? null
}
return {
schedules,
listLoading,
@ -287,9 +379,14 @@ export const useSchedulesStore = defineStore('schedules', () => {
assignmentsForDate,
assignmentsLoading,
assignmentsError,
assignmentCompletionPendingIds,
assignmentCompletionErrors,
fetchSchedules,
fetchSchedule,
createSchedule,
fetchAssignmentsForDate,
setAssignmentCompleted,
isAssignmentCompletionPending,
assignmentCompletionError,
}
})

View file

@ -35,6 +35,10 @@ function formatDate(value: string): string {
timeZone: 'UTC',
}).format(new Date(`${value}T00:00:00Z`))
}
async function completeAssignment(assignmentId: number): Promise<void> {
await schedulesStore.setAssignmentCompleted(assignmentId, true)
}
</script>
<template>
@ -76,15 +80,19 @@ function formatDate(value: string): string {
</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">
<li
v-for="assignment in assignmentsForDate"
:key="assignment.id"
:data-today-assignment="assignment.id"
>
<article class="today-assignment">
<RouterLink
class="today-assignment-link"
:to="{
name: 'schedule-detail',
params: { scheduleId: assignment.schedule.id },
}"
>
<div class="today-assignment__heading">
<p>{{ assignment.schedule.set.name }}</p>
<span class="today-assignment__kind">{{ assignment.element.kind }}</span>
@ -96,8 +104,30 @@ function formatDate(value: string): string {
Open schedule
<span aria-hidden="true"></span>
</span>
</article>
</RouterLink>
</RouterLink>
<div class="today-assignment__completion">
<p
v-if="schedulesStore.assignmentCompletionError(assignment.id) !== null"
class="assignment-completion-error"
role="alert"
>
{{ schedulesStore.assignmentCompletionError(assignment.id) }}
</p>
<button
type="button"
class="assignment-completion-button"
:aria-label="`Mark ${assignment.element.path.join(' / ')} complete`"
:disabled="schedulesStore.isAssignmentCompletionPending(assignment.id)"
@click="completeAssignment(assignment.id)"
>
{{
schedulesStore.isAssignmentCompletionPending(assignment.id)
? 'Completing...'
: 'Mark complete'
}}
</button>
</div>
</article>
</li>
</ul>
</section>
@ -313,12 +343,13 @@ function formatDate(value: string): string {
.today-assignment-link {
display: block;
height: 100%;
border-radius: 1rem;
color: inherit;
text-decoration: none;
}
.today-assignment {
display: flex;
flex-direction: column;
height: 100%;
padding: 1.4rem;
border: 1px solid rgb(24 48 41 / 12%);
@ -330,7 +361,7 @@ function formatDate(value: string): string {
transform 160ms ease;
}
.today-assignment-link:hover .today-assignment {
.today-assignment:hover {
border-color: rgb(40 92 78 / 35%);
transform: translateY(-2px);
}
@ -377,6 +408,48 @@ function formatDate(value: string): string {
font-weight: 750;
}
.today-assignment__completion {
display: grid;
justify-items: start;
gap: 0.65rem;
margin-top: auto;
padding-top: 1.15rem;
border-top: 1px solid rgb(24 48 41 / 10%);
}
.assignment-completion-button {
min-height: 2.5rem;
padding: 0.6rem 0.9rem;
border: 1px solid rgb(24 58 49 / 28%);
border-radius: 0.7rem;
color: #fffdf7;
background: #285c4e;
font-size: 0.78rem;
font-weight: 800;
cursor: pointer;
}
.assignment-completion-button:hover:not(:disabled) {
background: #183a31;
}
.assignment-completion-button:focus-visible {
outline: 3px solid rgb(86 127 112 / 34%);
outline-offset: 0.25rem;
}
.assignment-completion-button:disabled {
cursor: wait;
opacity: 0.65;
}
.assignment-completion-error {
margin: 0;
color: #9b3f32;
font-size: 0.76rem;
font-weight: 700;
}
.sets-catalog__description {
max-width: 38rem;
margin: 1.5rem 0 0;

View file

@ -39,6 +39,17 @@ function formatDate(value: string): string {
timeZone: 'UTC',
}).format(new Date(`${value}T00:00:00Z`))
}
function formatCompletionTime(value: string): string {
return new Intl.DateTimeFormat('en', {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(value))
}
async function setAssignmentCompleted(assignmentId: number, completed: boolean): Promise<void> {
await schedulesStore.setAssignmentCompleted(assignmentId, completed)
}
</script>
<template>
@ -98,9 +109,53 @@ function formatDate(value: string): string {
<p v-if="day.assignments.length === 0" class="rest-day">Rest day</p>
<ul v-else class="assignment-list">
<li v-for="assignment in day.assignments" :key="assignment.id">
<span class="assignment-path">{{ assignment.element.path.join(' / ') }}</span>
<span class="assignment-kind">{{ assignment.element.kind }}</span>
<li
v-for="assignment in day.assignments"
:key="assignment.id"
:class="{ 'is-completed': assignment.completedAt !== null }"
:data-schedule-assignment="assignment.id"
>
<div class="assignment-description">
<span class="assignment-path">
{{ assignment.element.path.join(' / ') }}
</span>
<span class="assignment-kind">{{ assignment.element.kind }}</span>
</div>
<div class="assignment-completion">
<p v-if="assignment.completedAt !== null" class="assignment-completion__status">
Completed
<time :datetime="assignment.completedAt">
{{ formatCompletionTime(assignment.completedAt) }}
</time>
</p>
<p v-else class="assignment-completion__status">Not completed</p>
<button
type="button"
class="assignment-completion-button"
:aria-label="
assignment.completedAt === null
? `Mark ${assignment.element.path.join(' / ')} complete`
: `Reopen ${assignment.element.path.join(' / ')}`
"
:disabled="schedulesStore.isAssignmentCompletionPending(assignment.id)"
@click="setAssignmentCompleted(assignment.id, assignment.completedAt === null)"
>
{{
schedulesStore.isAssignmentCompletionPending(assignment.id)
? 'Saving...'
: assignment.completedAt === null
? 'Mark complete'
: 'Reopen'
}}
</button>
<p
v-if="schedulesStore.assignmentCompletionError(assignment.id) !== null"
class="assignment-completion__error"
role="alert"
>
{{ schedulesStore.assignmentCompletionError(assignment.id) }}
</p>
</div>
</li>
</ul>
</li>
@ -223,13 +278,17 @@ h1 {
}
.assignment-list li {
display: flex;
align-items: center;
justify-content: space-between;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: start;
gap: 1rem;
padding: 1rem;
}
.assignment-list li.is-completed {
background: rgb(220 235 224 / 34%);
}
.assignment-list li + li {
border-top: 1px solid rgb(24 48 41 / 10%);
}
@ -241,6 +300,14 @@ h1 {
overflow-wrap: anywhere;
}
.assignment-description {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
min-width: 0;
}
.assignment-kind {
flex: 0 0 auto;
padding: 0.35rem 0.55rem;
@ -253,6 +320,68 @@ h1 {
text-transform: none;
}
.assignment-completion {
display: grid;
justify-items: end;
gap: 0.55rem;
min-width: 12rem;
}
.assignment-completion__status,
.assignment-completion__error {
margin: 0;
font-size: 0.74rem;
font-weight: 700;
text-align: right;
}
.assignment-completion__status {
color: #5e7067;
}
.assignment-completion__status time {
display: block;
margin-top: 0.2rem;
color: #344e45;
}
.assignment-completion__error {
max-width: 14rem;
color: #9b3f32;
}
.assignment-completion-button {
min-height: 2.4rem;
padding: 0.55rem 0.8rem;
border: 1px solid rgb(24 58 49 / 28%);
border-radius: 0.65rem;
color: #183a31;
background: #fffdf7;
font-size: 0.75rem;
font-weight: 800;
cursor: pointer;
}
.is-completed .assignment-completion-button {
color: #5e7067;
background: transparent;
}
.assignment-completion-button:hover:not(:disabled) {
border-color: #285c4e;
background: #f9f5e9;
}
.assignment-completion-button:focus-visible {
outline: 3px solid rgb(86 127 112 / 34%);
outline-offset: 0.25rem;
}
.assignment-completion-button:disabled {
cursor: wait;
opacity: 0.65;
}
.page-state {
display: grid;
min-height: 10rem;
@ -302,7 +431,21 @@ h1 {
}
.assignment-list li {
grid-template-columns: 1fr;
}
.assignment-description {
align-items: flex-start;
}
.assignment-completion {
justify-items: start;
min-width: 0;
}
.assignment-completion__status,
.assignment-completion__error {
text-align: left;
}
}
</style>