diff --git a/backend/app/Http/Controllers/ScheduleController.php b/backend/app/Http/Controllers/ScheduleController.php
index f80461a..9ec6588 100644
--- a/backend/app/Http/Controllers/ScheduleController.php
+++ b/backend/app/Http/Controllers/ScheduleController.php
@@ -14,8 +14,6 @@ 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;
@@ -28,7 +26,6 @@ class ScheduleController extends Controller
private ListSchedules $listSchedules,
private GetSchedule $getSchedule,
private ListAssignmentsForDate $listAssignmentsForDate,
- private SetAssignmentCompletion $setAssignmentCompletion,
) {}
public function store(Request $request): JsonResponse
@@ -127,41 +124,6 @@ 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
*/
@@ -218,7 +180,7 @@ class ScheduleController extends Controller
}
/**
- * @return array{id: int, completedAt: string|null, element: array{
+ * @return array{id: int, element: array{
* name: string,
* kind: string,
* path: list
@@ -229,8 +191,6 @@ class ScheduleController extends Controller
): array {
return [
'id' => $assignment->getId(),
- 'completedAt' => $assignment->getCompletedAt()
- ?->format(DATE_ATOM),
'element' => [
'name' => $assignment->getName(),
'kind' => $assignment->getKind(),
diff --git a/backend/app/Schedule/EloquentScheduleRepository.php b/backend/app/Schedule/EloquentScheduleRepository.php
index 214e8a5..7aec036 100644
--- a/backend/app/Schedule/EloquentScheduleRepository.php
+++ b/backend/app/Schedule/EloquentScheduleRepository.php
@@ -5,7 +5,6 @@ namespace App\Schedule;
use App\User\User;
use DateTimeImmutable;
use DateTimeZone;
-use DomainException;
use Illuminate\Support\Facades\DB;
class EloquentScheduleRepository implements ScheduleRepository
@@ -30,7 +29,6 @@ class EloquentScheduleRepository implements ScheduleRepository
'scheduled_date' => $assignmentDto->scheduledDate
->format('Y-m-d'),
'position' => $assignmentDto->position,
- 'completed_at' => null,
]);
}
@@ -79,7 +77,6 @@ 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')
@@ -98,43 +95,6 @@ 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()
@@ -169,9 +129,6 @@ 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),
);
}
@@ -179,10 +136,4 @@ 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'));
- }
}
diff --git a/backend/app/Schedule/ScheduleAssignment.php b/backend/app/Schedule/ScheduleAssignment.php
index 8f90435..482b45d 100644
--- a/backend/app/Schedule/ScheduleAssignment.php
+++ b/backend/app/Schedule/ScheduleAssignment.php
@@ -4,7 +4,7 @@ namespace App\Schedule;
use DateTimeImmutable;
-final class ScheduleAssignment
+final readonly class ScheduleAssignment
{
/**
* @param list $path
@@ -16,7 +16,6 @@ final class ScheduleAssignment
private array $path,
private DateTimeImmutable $scheduledDate,
private int $position,
- private ?DateTimeImmutable $completedAt,
) {}
public function getId(): int
@@ -51,21 +50,4 @@ final 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;
- }
}
diff --git a/backend/app/Schedule/ScheduleAssignmentModel.php b/backend/app/Schedule/ScheduleAssignmentModel.php
index 33b2953..3cd5577 100644
--- a/backend/app/Schedule/ScheduleAssignmentModel.php
+++ b/backend/app/Schedule/ScheduleAssignmentModel.php
@@ -2,7 +2,6 @@
namespace App\Schedule;
-use DateTimeImmutable;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
@@ -16,7 +15,6 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
* @property list $element_path
* @property string $scheduled_date
* @property int $position
- * @property DateTimeImmutable|null $completed_at
* @property-read ScheduleModel $schedule
*
* @method static Builder|ScheduleAssignmentModel newModelQuery()
@@ -32,7 +30,6 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
'element_path',
'scheduled_date',
'position',
- 'completed_at',
])]
class ScheduleAssignmentModel extends Model
{
@@ -49,7 +46,6 @@ class ScheduleAssignmentModel extends Model
'schedule_id' => 'integer',
'element_path' => 'array',
'position' => 'integer',
- 'completed_at' => 'immutable_datetime',
];
}
diff --git a/backend/app/Schedule/ScheduleRepository.php b/backend/app/Schedule/ScheduleRepository.php
index 3222311..fcdcb8c 100644
--- a/backend/app/Schedule/ScheduleRepository.php
+++ b/backend/app/Schedule/ScheduleRepository.php
@@ -23,13 +23,4 @@ interface ScheduleRepository
User $user,
DateTimeImmutable $date,
): array;
-
- public function findAssignmentForUser(
- int $id,
- User $user,
- ): ?ScheduleAssignment;
-
- public function updateAssignment(
- ScheduleAssignment $assignment,
- ): ScheduleAssignment;
}
diff --git a/backend/app/Schedule/UseCases/SetAssignmentCompletion/SetAssignmentCompletion.php b/backend/app/Schedule/UseCases/SetAssignmentCompletion/SetAssignmentCompletion.php
deleted file mode 100644
index df66b8d..0000000
--- a/backend/app/Schedule/UseCases/SetAssignmentCompletion/SetAssignmentCompletion.php
+++ /dev/null
@@ -1,47 +0,0 @@
-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);
- }
-}
diff --git a/backend/app/Schedule/UseCases/SetAssignmentCompletion/SetAssignmentCompletionRequest.php b/backend/app/Schedule/UseCases/SetAssignmentCompletion/SetAssignmentCompletionRequest.php
deleted file mode 100644
index e591375..0000000
--- a/backend/app/Schedule/UseCases/SetAssignmentCompletion/SetAssignmentCompletionRequest.php
+++ /dev/null
@@ -1,14 +0,0 @@
-request->input($key);
-
- return is_bool($value) ? $value : null;
- }
}
diff --git a/backend/database/migrations/2026_08_10_000001_create_schedule_assignments_table.php b/backend/database/migrations/2026_08_10_000001_create_schedule_assignments_table.php
index c98992a..b52c60a 100644
--- a/backend/database/migrations/2026_08_10_000001_create_schedule_assignments_table.php
+++ b/backend/database/migrations/2026_08_10_000001_create_schedule_assignments_table.php
@@ -20,12 +20,10 @@ 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',
]);
},
diff --git a/backend/routes/api.php b/backend/routes/api.php
index 76caf16..2355570 100644
--- a/backend/routes/api.php
+++ b/backend/routes/api.php
@@ -16,10 +16,6 @@ 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'])
diff --git a/backend/tests/Fakes/FakeScheduleRepository.php b/backend/tests/Fakes/FakeScheduleRepository.php
index e2f35bc..e30caf2 100644
--- a/backend/tests/Fakes/FakeScheduleRepository.php
+++ b/backend/tests/Fakes/FakeScheduleRepository.php
@@ -9,7 +9,6 @@ use App\Schedule\ScheduleAssignment;
use App\Schedule\ScheduleRepository;
use App\User\User;
use DateTimeImmutable;
-use DomainException;
class FakeScheduleRepository implements ScheduleRepository
{
@@ -18,8 +17,6 @@ class FakeScheduleRepository implements ScheduleRepository
*/
private array $schedules = [];
- private int $nextAssignmentId = 1;
-
public function create(CreateScheduleDto $dto): Schedule
{
$id = count($this->schedules) + 1;
@@ -27,13 +24,12 @@ class FakeScheduleRepository implements ScheduleRepository
foreach ($dto->assignments as $assignmentDto) {
$assignments[] = new ScheduleAssignment(
- id: $this->nextAssignmentId++,
+ id: count($assignments) + 1,
name: $assignmentDto->name,
kind: $assignmentDto->kind,
path: $assignmentDto->path,
scheduledDate: $assignmentDto->scheduledDate,
position: $assignmentDto->position,
- completedAt: null,
);
}
@@ -88,7 +84,6 @@ 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;
}
@@ -104,66 +99,21 @@ 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 $this->copyAssignment($assignment);
- }, $schedule->getAssignments());
+ $assignments = array_map(
+ function (ScheduleAssignment $assignment): ScheduleAssignment {
+ return new ScheduleAssignment(
+ id: $assignment->getId(),
+ name: $assignment->getName(),
+ kind: $assignment->getKind(),
+ path: $assignment->getPath(),
+ scheduledDate: $assignment->getScheduledDate(),
+ position: $assignment->getPosition(),
+ );
+ },
+ $schedule->getAssignments(),
+ );
return new Schedule(
id: $schedule->getId(),
@@ -175,18 +125,4 @@ 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(),
- );
- }
}
diff --git a/backend/tests/Feature/Schedule/ScheduleEndpointTest.php b/backend/tests/Feature/Schedule/ScheduleEndpointTest.php
index b68e2ad..a0e9cf8 100644
--- a/backend/tests/Feature/Schedule/ScheduleEndpointTest.php
+++ b/backend/tests/Feature/Schedule/ScheduleEndpointTest.php
@@ -3,7 +3,6 @@
namespace Tests\Feature\Schedule;
use App\Auth\CreateSessionDto;
-use App\Auth\Clock;
use App\Auth\SessionRepository;
use App\Element\CreateElementDto;
use App\Element\ElementModel;
@@ -26,7 +25,6 @@ use DateTimeZone;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Testing\TestResponse;
use Tests\TestCase;
-use Tests\Fakes\FakeClock;
class ScheduleEndpointTest extends TestCase
{
@@ -96,7 +94,6 @@ class ScheduleEndpointTest extends TestCase
'assignments' => [
[
'id' => 1,
- 'completedAt' => null,
'element' => [
'name' => 'Chapter 1',
'kind' => 'chapter',
@@ -118,7 +115,6 @@ class ScheduleEndpointTest extends TestCase
'assignments' => [
[
'id' => 2,
- 'completedAt' => null,
'element' => [
'name' => 'Chapter 1',
'kind' => 'chapter',
@@ -355,130 +351,6 @@ 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');
@@ -583,9 +455,6 @@ 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);
@@ -651,18 +520,4 @@ class ScheduleEndpointTest extends TestCase
'valid-token',
)->getJson($uri);
}
-
- /**
- * @param array $payload
- */
- private function credentialedPatch(
- string $uri,
- array $payload,
- ): TestResponse {
- return $this->withCredentials()
- ->withUnencryptedCookie(
- AuthMiddleware::COOKIE_NAME,
- 'valid-token',
- )->patchJson($uri, $payload);
- }
}
diff --git a/backend/tests/Unit/Schedule/ScheduleAssignmentTest.php b/backend/tests/Unit/Schedule/ScheduleAssignmentTest.php
deleted file mode 100644
index 12f86f5..0000000
--- a/backend/tests/Unit/Schedule/ScheduleAssignmentTest.php
+++ /dev/null
@@ -1,49 +0,0 @@
-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'));
- }
-}
diff --git a/backend/tests/Unit/Schedule/UseCases/ListAssignmentsForDateTest.php b/backend/tests/Unit/Schedule/UseCases/ListAssignmentsForDateTest.php
index c6bc95f..1d1e3fd 100644
--- a/backend/tests/Unit/Schedule/UseCases/ListAssignmentsForDateTest.php
+++ b/backend/tests/Unit/Schedule/UseCases/ListAssignmentsForDateTest.php
@@ -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;
- $olderSchedule = $repository->create($this->schedule(
+ $repository->create($this->schedule(
user: $user,
setName: 'Older plan',
date: '2026-08-15',
@@ -46,12 +46,6 @@ 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(
@@ -61,13 +55,13 @@ class ListAssignmentsForDateTest extends TestCase
);
$this->assertSame(
- ['Newer plan', 'Older plan'],
+ ['Newer plan', 'Older plan', 'Older plan'],
array_map(function ($assignment): string {
return $assignment->getSetName();
}, $assignments),
);
$this->assertSame(
- ['Third', 'Second'],
+ ['Third', 'First', 'Second'],
array_map(function ($assignment): string {
return $assignment->getAssignment()->getName();
}, $assignments),
diff --git a/backend/tests/Unit/Schedule/UseCases/SetAssignmentCompletionTest.php b/backend/tests/Unit/Schedule/UseCases/SetAssignmentCompletionTest.php
deleted file mode 100644
index 8950edc..0000000
--- a/backend/tests/Unit/Schedule/UseCases/SetAssignmentCompletionTest.php
+++ /dev/null
@@ -1,175 +0,0 @@
-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'));
- }
-}
diff --git a/frontend/website/cypress/e2e/set-scheduling.cy.ts b/frontend/website/cypress/e2e/set-scheduling.cy.ts
index 9f83629..a12f672 100644
--- a/frontend/website/cypress/e2e/set-scheduling.cy.ts
+++ b/frontend/website/cypress/e2e/set-scheduling.cy.ts
@@ -74,7 +74,6 @@ const scheduleDetail = {
assignments: [
{
id: 1,
- completedAt: null,
element: {
name: 'Chapter 1',
kind: 'Chapter_sections-v2',
@@ -89,7 +88,6 @@ const scheduleDetail = {
assignments: [
{
id: 2,
- completedAt: null,
element: {
name: 'Chapter 1',
kind: 'Chapter_sections-v2',
@@ -102,25 +100,6 @@ 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,
@@ -248,65 +227,6 @@ 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,
@@ -352,10 +272,3 @@ 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))
-}
diff --git a/frontend/website/cypress/e2e/today-assignments.cy.ts b/frontend/website/cypress/e2e/today-assignments.cy.ts
index 77d2ba5..9d31dde 100644
--- a/frontend/website/cypress/e2e/today-assignments.cy.ts
+++ b/frontend/website/cypress/e2e/today-assignments.cy.ts
@@ -113,74 +113,6 @@ 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(
diff --git a/frontend/website/src/stores/schedules.ts b/frontend/website/src/stores/schedules.ts
index 2471d66..090db7c 100644
--- a/frontend/website/src/stores/schedules.ts
+++ b/frontend/website/src/stores/schedules.ts
@@ -17,7 +17,7 @@ export const scheduleSummarySchema = z.object({
assignmentCount: z.number().int().nonnegative(),
})
-const assignmentIdentitySchema = z.object({
+const scheduleAssignmentSchema = z.object({
id: z.number().int().positive(),
element: z.object({
name: z.string().min(1),
@@ -26,11 +26,7 @@ const assignmentIdentitySchema = z.object({
}),
})
-const scheduleAssignmentSchema = assignmentIdentitySchema.extend({
- completedAt: z.string().datetime({ offset: true }).nullable(),
-})
-
-export const assignmentForDateSchema = assignmentIdentitySchema.extend({
+export const assignmentForDateSchema = scheduleAssignmentSchema.extend({
schedule: z.object({
id: z.number().int().positive(),
set: z.object({
@@ -61,13 +57,6 @@ 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),
})
@@ -86,7 +75,6 @@ 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([])
@@ -101,8 +89,6 @@ export const useSchedulesStore = defineStore('schedules', () => {
const assignmentsForDate = ref([])
const assignmentsLoading = ref(false)
const assignmentsError = ref(null)
- const assignmentCompletionPendingIds = ref([])
- const assignmentCompletionErrors = ref>({})
let activeDetailRequestId = 0
let assignmentsRequestId = 0
@@ -288,84 +274,6 @@ export const useSchedulesStore = defineStore('schedules', () => {
}
}
- async function setAssignmentCompleted(
- assignmentId: number,
- completed: boolean,
- ): Promise {
- 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,
@@ -379,14 +287,9 @@ export const useSchedulesStore = defineStore('schedules', () => {
assignmentsForDate,
assignmentsLoading,
assignmentsError,
- assignmentCompletionPendingIds,
- assignmentCompletionErrors,
fetchSchedules,
fetchSchedule,
createSchedule,
fetchAssignmentsForDate,
- setAssignmentCompleted,
- isAssignmentCompletionPending,
- assignmentCompletionError,
}
})
diff --git a/frontend/website/src/views/DashboardView.vue b/frontend/website/src/views/DashboardView.vue
index 822da86..c99ccf4 100644
--- a/frontend/website/src/views/DashboardView.vue
+++ b/frontend/website/src/views/DashboardView.vue
@@ -35,10 +35,6 @@ function formatDate(value: string): string {
timeZone: 'UTC',
}).format(new Date(`${value}T00:00:00Z`))
}
-
-async function completeAssignment(assignmentId: number): Promise {
- await schedulesStore.setAssignmentCompleted(assignmentId, true)
-}
@@ -80,19 +76,15 @@ async function completeAssignment(assignmentId: number): Promise {
- -
-
-
+
-
+
+
{{ assignment.schedule.set.name }}
{{ assignment.element.kind }}
@@ -104,30 +96,8 @@ async function completeAssignment(assignmentId: number): Promise
{
Open schedule
→
-
-
-
- {{ schedulesStore.assignmentCompletionError(assignment.id) }}
-
-
-
-
+
+
@@ -343,13 +313,12 @@ async function completeAssignment(assignmentId: number): Promise {
.today-assignment-link {
display: block;
- color: inherit;
+ height: 100%;
+ border-radius: 1rem;
text-decoration: none;
}
.today-assignment {
- display: flex;
- flex-direction: column;
height: 100%;
padding: 1.4rem;
border: 1px solid rgb(24 48 41 / 12%);
@@ -361,7 +330,7 @@ async function completeAssignment(assignmentId: number): Promise {
transform 160ms ease;
}
-.today-assignment:hover {
+.today-assignment-link:hover .today-assignment {
border-color: rgb(40 92 78 / 35%);
transform: translateY(-2px);
}
@@ -408,48 +377,6 @@ async function completeAssignment(assignmentId: number): Promise {
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;
diff --git a/frontend/website/src/views/ScheduleDetailView.vue b/frontend/website/src/views/ScheduleDetailView.vue
index 9a455a9..352f44f 100644
--- a/frontend/website/src/views/ScheduleDetailView.vue
+++ b/frontend/website/src/views/ScheduleDetailView.vue
@@ -39,17 +39,6 @@ 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 {
- await schedulesStore.setAssignmentCompleted(assignmentId, completed)
-}
@@ -109,53 +98,9 @@ async function setAssignmentCompleted(assignmentId: number, completed: boolean):
Rest day
@@ -278,17 +223,13 @@ h1 {
}
.assignment-list li {
- display: grid;
- grid-template-columns: minmax(0, 1fr) auto;
- align-items: start;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
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%);
}
@@ -300,14 +241,6 @@ 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;
@@ -320,68 +253,6 @@ 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;
@@ -431,21 +302,7 @@ 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;
- }
}