From 9ced96fc886b6ef504af7b6e76091883800e72b2 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 10:20:27 +0300 Subject: [PATCH 01/32] test create plan button visibility --- cypress/e2e/homeCreatePlan.cy.js | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 cypress/e2e/homeCreatePlan.cy.js diff --git a/cypress/e2e/homeCreatePlan.cy.js b/cypress/e2e/homeCreatePlan.cy.js new file mode 100644 index 0000000..a7ce2bf --- /dev/null +++ b/cypress/e2e/homeCreatePlan.cy.js @@ -0,0 +1,18 @@ +describe('Create plan modal on the home page', () => { + beforeEach(() => { + cy.exec('npm run db:seed') + cy.intercept('GET', '/api/texts').as('getTexts') + cy.visit('/home') + cy.wait('@getTexts') + }) + + afterEach(() => { + cy.exec('npm run db:wipe') + }) + + it('shows a "Create plan" button on each text', () => { + cy.get('#texts-list li').each((textItem) => { + cy.wrap(textItem).find('button.create-plan').should('exist') + }) + }) +}) From 39539313c9a18a727aa9e16b6b5f0acff7c2f1bc Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 10:20:48 +0300 Subject: [PATCH 02/32] add create plan buttons to texts list --- public/js/home.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/public/js/home.js b/public/js/home.js index 3e7cde8..700bef8 100644 --- a/public/js/home.js +++ b/public/js/home.js @@ -5,7 +5,11 @@ document.addEventListener('DOMContentLoaded', () => { const response = await fetch('/api/texts'); const texts = await response.json(); textsList.innerHTML = texts - .map(text => '
  • ' + text.name + '
  • ') + .map(text => + '
  • ' + text.name + + '
  • ' + ) .join(''); } From e005eb9a9bdb9f560357b1d888103c59e274d945 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 10:21:21 +0300 Subject: [PATCH 03/32] test modal opens on create plan click --- cypress/e2e/homeCreatePlan.cy.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/cypress/e2e/homeCreatePlan.cy.js b/cypress/e2e/homeCreatePlan.cy.js index a7ce2bf..1bb9115 100644 --- a/cypress/e2e/homeCreatePlan.cy.js +++ b/cypress/e2e/homeCreatePlan.cy.js @@ -15,4 +15,14 @@ describe('Create plan modal on the home page', () => { cy.wrap(textItem).find('button.create-plan').should('exist') }) }) + + it('hides the create plan modal by default', () => { + cy.get('#create-plan-modal').should('not.be.visible') + }) + + it('shows the modal when clicking "Create plan"', () => { + cy.get('#texts-list li').first() + .find('button.create-plan').click() + cy.get('#create-plan-modal').should('be.visible') + }) }) From 3d88a1d816cb41b750874b090c60a9759c7f4f1a Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 10:21:29 +0300 Subject: [PATCH 04/32] add create plan modal markup --- views/templates/home.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/views/templates/home.php b/views/templates/home.php index a386efe..751498d 100644 --- a/views/templates/home.php +++ b/views/templates/home.php @@ -7,6 +7,9 @@

    Home

    + From 389e125cef7db247ce655393577dc2f74af681d6 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 10:21:53 +0300 Subject: [PATCH 05/32] open modal on create plan click --- public/js/home.js | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/public/js/home.js b/public/js/home.js index 700bef8..198b122 100644 --- a/public/js/home.js +++ b/public/js/home.js @@ -1,5 +1,6 @@ document.addEventListener('DOMContentLoaded', () => { const textsList = document.getElementById('texts-list'); + const createPlanModal = document.getElementById('create-plan-modal'); async function loadTexts() { const response = await fetch('/api/texts'); @@ -13,5 +14,20 @@ document.addEventListener('DOMContentLoaded', () => { .join(''); } + function openCreatePlanModal(textId) { + createPlanModal.dataset.textId = textId; + createPlanModal.hidden = false; + } + + textsList.addEventListener('click', (clickEvent) => { + const createPlanButton = clickEvent.target.closest( + 'button.create-plan' + ); + if (createPlanButton === null) { + return; + } + openCreatePlanModal(createPlanButton.dataset.textId); + }); + loadTexts(); }); From 52ed08b8a9eea34485f3c197e8647f0547570c91 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 10:22:22 +0300 Subject: [PATCH 06/32] test modal contains required fields --- cypress/e2e/homeCreatePlan.cy.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/cypress/e2e/homeCreatePlan.cy.js b/cypress/e2e/homeCreatePlan.cy.js index 1bb9115..e501984 100644 --- a/cypress/e2e/homeCreatePlan.cy.js +++ b/cypress/e2e/homeCreatePlan.cy.js @@ -25,4 +25,16 @@ describe('Create plan modal on the home page', () => { .find('button.create-plan').click() cy.get('#create-plan-modal').should('be.visible') }) + + it('modal contains name, date start, date end, save, cancel', () => { + cy.get('#texts-list li').first() + .find('button.create-plan').click() + cy.get('#create-plan-modal input.plan-name').should('be.visible') + cy.get('#create-plan-modal input.plan-date-start') + .should('be.visible') + cy.get('#create-plan-modal input.plan-date-end') + .should('be.visible') + cy.get('#create-plan-modal button.save-plan').should('be.visible') + cy.get('#create-plan-modal button.cancel-plan').should('be.visible') + }) }) From b50413f7c9abc9ae213241a4b85fa5e39a95cc3e Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 10:22:45 +0300 Subject: [PATCH 07/32] add modal form fields and buttons --- views/templates/home.php | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/views/templates/home.php b/views/templates/home.php index 751498d..ddbc361 100644 --- a/views/templates/home.php +++ b/views/templates/home.php @@ -9,6 +9,17 @@ From 41f385f50bd9bc4c4fa1a3a429c2b52e1003f703 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 10:23:11 +0300 Subject: [PATCH 08/32] test cancel button hides modal --- cypress/e2e/homeCreatePlan.cy.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/cypress/e2e/homeCreatePlan.cy.js b/cypress/e2e/homeCreatePlan.cy.js index e501984..575b4ce 100644 --- a/cypress/e2e/homeCreatePlan.cy.js +++ b/cypress/e2e/homeCreatePlan.cy.js @@ -37,4 +37,12 @@ describe('Create plan modal on the home page', () => { cy.get('#create-plan-modal button.save-plan').should('be.visible') cy.get('#create-plan-modal button.cancel-plan').should('be.visible') }) + + it('hides the modal when clicking "Cancel"', () => { + cy.get('#texts-list li').first() + .find('button.create-plan').click() + cy.get('#create-plan-modal').should('be.visible') + cy.get('#create-plan-modal button.cancel-plan').click() + cy.get('#create-plan-modal').should('not.be.visible') + }) }) From e7d30d364a7b060a180358da5db01a56be21e6c1 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 10:23:37 +0300 Subject: [PATCH 09/32] add cancel handler for modal --- public/js/home.js | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/public/js/home.js b/public/js/home.js index 198b122..bf76104 100644 --- a/public/js/home.js +++ b/public/js/home.js @@ -14,11 +14,29 @@ document.addEventListener('DOMContentLoaded', () => { .join(''); } + const cancelPlanButton = createPlanModal.querySelector( + 'button.cancel-plan' + ); + const planNameInput = createPlanModal.querySelector('input.plan-name'); + const planDateStartInput = createPlanModal.querySelector( + 'input.plan-date-start' + ); + const planDateEndInput = createPlanModal.querySelector( + 'input.plan-date-end' + ); + function openCreatePlanModal(textId) { createPlanModal.dataset.textId = textId; createPlanModal.hidden = false; } + function closeCreatePlanModal() { + createPlanModal.hidden = true; + planNameInput.value = ''; + planDateStartInput.value = ''; + planDateEndInput.value = ''; + } + textsList.addEventListener('click', (clickEvent) => { const createPlanButton = clickEvent.target.closest( 'button.create-plan' @@ -29,5 +47,9 @@ document.addEventListener('DOMContentLoaded', () => { openCreatePlanModal(createPlanButton.dataset.textId); }); + cancelPlanButton.addEventListener('click', () => { + closeCreatePlanModal(); + }); + loadTexts(); }); From e1b02f0ba9b9e08a074429c965a89c901065b4ea Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 10:24:24 +0300 Subject: [PATCH 10/32] test submit creates plan and closes modal --- cypress/e2e/homeCreatePlan.cy.js | 50 ++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/cypress/e2e/homeCreatePlan.cy.js b/cypress/e2e/homeCreatePlan.cy.js index 575b4ce..3c9ee87 100644 --- a/cypress/e2e/homeCreatePlan.cy.js +++ b/cypress/e2e/homeCreatePlan.cy.js @@ -45,4 +45,54 @@ describe('Create plan modal on the home page', () => { cy.get('#create-plan-modal button.cancel-plan').click() cy.get('#create-plan-modal').should('not.be.visible') }) + + it('submits plan details to /api/plans', () => { + cy.intercept('POST', '/api/plans').as('createPlan') + cy.get('#texts-list li').first() + .find('button.create-plan').click() + cy.get('#create-plan-modal input.plan-name') + .type('My reading plan') + cy.get('#create-plan-modal input.plan-date-start') + .type('2025-01-01') + cy.get('#create-plan-modal input.plan-date-end') + .type('2025-01-31') + cy.get('#create-plan-modal button.save-plan').click() + cy.wait('@createPlan').then((createPlanRequest) => { + expect(createPlanRequest.response.statusCode).to.eq(201) + expect(createPlanRequest.request.body).to.deep.equal({ + userId: 0, + textId: 0, + name: 'My reading plan', + dateStart: '2025-01-01', + dateEnd: '2025-01-31', + }) + }) + }) + + it('closes the modal after successful submit', () => { + cy.intercept('POST', '/api/plans').as('createPlan') + cy.get('#texts-list li').first() + .find('button.create-plan').click() + cy.get('#create-plan-modal input.plan-name') + .type('Another plan') + cy.get('#create-plan-modal input.plan-date-start') + .type('2025-02-01') + cy.get('#create-plan-modal input.plan-date-end') + .type('2025-02-28') + cy.get('#create-plan-modal button.save-plan').click() + cy.wait('@createPlan') + cy.get('#create-plan-modal').should('not.be.visible') + }) + + it('does not submit if name is empty', () => { + cy.intercept('POST', '/api/plans').as('createPlan') + cy.get('#texts-list li').first() + .find('button.create-plan').click() + cy.get('#create-plan-modal input.plan-date-start') + .type('2025-01-01') + cy.get('#create-plan-modal input.plan-date-end') + .type('2025-01-31') + cy.get('#create-plan-modal button.save-plan').click() + cy.get('@createPlan.all').should('have.length', 0) + }) }) From 895bfb01da26ef00ed01976930c6da02ef7fe576 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 10:24:39 +0300 Subject: [PATCH 11/32] add json plan repository --- app/Plan/JsonPlanRepository.php | 93 +++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 app/Plan/JsonPlanRepository.php diff --git a/app/Plan/JsonPlanRepository.php b/app/Plan/JsonPlanRepository.php new file mode 100644 index 0000000..8a88838 --- /dev/null +++ b/app/Plan/JsonPlanRepository.php @@ -0,0 +1,93 @@ +filePath = __DIR__ . '/../../data/plans.json'; + } + + public function create(CreatePlanDto $dto): Plan + { + $plans = $this->readPlans(); + $id = $this->getNextId($plans); + + $plans[] = [ + 'id' => $id, + 'name' => $dto->name, + 'userId' => $dto->user->getId(), + ]; + $this->writePlans($plans); + + return new Plan( + id: $id, + name: $dto->name, + user: $dto->user, + ); + } + + public function find(int $id): ?Plan + { + $plans = $this->readPlans(); + + foreach ($plans as $data) { + if ($data['id'] === $id) { + $user = $this->userRepository->find($data['userId']); + if ($user === null) { + return null; + } + return new Plan( + id: $data['id'], + name: $data['name'], + user: $user, + ); + } + } + + return null; + } + + private function readPlans(): array + { + if (!file_exists($this->filePath)) { + return []; + } + + $content = file_get_contents($this->filePath); + + return json_decode($content, true) ?? []; + } + + private function writePlans(array $plans): void + { + file_put_contents( + $this->filePath, + json_encode($plans, JSON_PRETTY_PRINT) + ); + } + + private function getNextId(array $plans): int + { + if (empty($plans)) { + return 0; + } + + $maxId = -1; + foreach ($plans as $plan) { + if ($plan['id'] > $maxId) { + $maxId = $plan['id']; + } + } + + return $maxId + 1; + } +} From 3595bcbf1137ddf9348923b2a2b6b62851f3125d Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 10:24:57 +0300 Subject: [PATCH 12/32] add json user repository --- app/User/JsonUserRepository.php | 84 +++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 app/User/JsonUserRepository.php diff --git a/app/User/JsonUserRepository.php b/app/User/JsonUserRepository.php new file mode 100644 index 0000000..bde8c62 --- /dev/null +++ b/app/User/JsonUserRepository.php @@ -0,0 +1,84 @@ +filePath = __DIR__ . '/../../data/users.json'; + } + + public function create(CreateUserDto $dto): User + { + $users = $this->readUsers(); + $id = $this->getNextId($users); + + $users[] = [ + 'id' => $id, + 'email' => (string) $dto->email, + ]; + $this->writeUsers($users); + + return new User( + id: $id, + email: $dto->email, + ); + } + + public function find(int $id): ?User + { + $users = $this->readUsers(); + + foreach ($users as $data) { + if ($data['id'] === $id) { + return new User( + id: $data['id'], + email: new EmailAddress($data['email']), + ); + } + } + + return null; + } + + private function readUsers(): array + { + if (!file_exists($this->filePath)) { + return []; + } + + $content = file_get_contents($this->filePath); + + return json_decode($content, true) ?? []; + } + + private function writeUsers(array $users): void + { + file_put_contents( + $this->filePath, + json_encode($users, JSON_PRETTY_PRINT) + ); + } + + private function getNextId(array $users): int + { + if (empty($users)) { + return 0; + } + + $maxId = -1; + foreach ($users as $user) { + if ($user['id'] > $maxId) { + $maxId = $user['id']; + } + } + + return $maxId + 1; + } +} From baf12500c71a8b55c2c3028cec1b387be71768f0 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 10:25:08 +0300 Subject: [PATCH 13/32] add json scheduled node repository --- .../JsonScheduledNodeRepository.php | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 app/ScheduledNode/JsonScheduledNodeRepository.php diff --git a/app/ScheduledNode/JsonScheduledNodeRepository.php b/app/ScheduledNode/JsonScheduledNodeRepository.php new file mode 100644 index 0000000..bee31ea --- /dev/null +++ b/app/ScheduledNode/JsonScheduledNodeRepository.php @@ -0,0 +1,67 @@ +filePath = __DIR__ . '/../../data/scheduledNodes.json'; + } + + public function create(CreateScheduledNodeDto $dto): ScheduledNode + { + $scheduledNodes = $this->readScheduledNodes(); + $id = $this->getNextId($scheduledNodes); + + $scheduledNodes[] = [ + 'id' => $id, + 'date' => $dto->date->format('Y-m-d'), + 'planId' => $dto->plan->getId(), + ]; + $this->writeScheduledNodes($scheduledNodes); + + return new ScheduledNode( + id: $id, + date: $dto->date, + plan: $dto->plan, + ); + } + + private function readScheduledNodes(): array + { + if (!file_exists($this->filePath)) { + return []; + } + + $content = file_get_contents($this->filePath); + + return json_decode($content, true) ?? []; + } + + private function writeScheduledNodes(array $scheduledNodes): void + { + file_put_contents( + $this->filePath, + json_encode($scheduledNodes, JSON_PRETTY_PRINT) + ); + } + + private function getNextId(array $scheduledNodes): int + { + if (empty($scheduledNodes)) { + return 0; + } + + $maxId = -1; + foreach ($scheduledNodes as $scheduledNode) { + if ($scheduledNode['id'] > $maxId) { + $maxId = $scheduledNode['id']; + } + } + + return $maxId + 1; + } +} From 7c4d7a93c11156b16774937649a3f53089687045 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 10:25:18 +0300 Subject: [PATCH 14/32] bind plan user scheduled node repositories --- bootstrap/container.php | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/bootstrap/container.php b/bootstrap/container.php index efcfde5..fb80996 100644 --- a/bootstrap/container.php +++ b/bootstrap/container.php @@ -6,10 +6,20 @@ use App\Text\TextRepository; use App\Text\JsonTextRepository; use App\Node\NodeRepository; use App\Node\JsonNodeRepository; +use App\Plan\PlanRepository; +use App\Plan\JsonPlanRepository; +use App\User\UserRepository; +use App\User\JsonUserRepository; +use App\ScheduledNode\ScheduledNodeRepository; +use App\ScheduledNode\JsonScheduledNodeRepository; $container = new Container([ TextRepository::class => DI\autowire(JsonTextRepository::class), NodeRepository::class => DI\autowire(JsonNodeRepository::class), + PlanRepository::class => DI\autowire(JsonPlanRepository::class), + UserRepository::class => DI\autowire(JsonUserRepository::class), + ScheduledNodeRepository::class => + DI\autowire(JsonScheduledNodeRepository::class), ]); return $container; From 2b482cdc748222b2c9bb391f30502c36f427009d Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 10:25:30 +0300 Subject: [PATCH 15/32] seed users plans scheduled nodes --- data/seedDb.php | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/data/seedDb.php b/data/seedDb.php index 3206372..922ca90 100644 --- a/data/seedDb.php +++ b/data/seedDb.php @@ -28,9 +28,22 @@ $nodes = [ ], ]; +$users = [ + [ + 'id' => 0, + 'email' => 'user@example.com', + ], +]; + +$plans = []; +$scheduledNodes = []; + $fileDataMap = [ 'texts.json' => $texts, 'nodes.json' => $nodes, + 'users.json' => $users, + 'plans.json' => $plans, + 'scheduledNodes.json' => $scheduledNodes, ]; foreach ($fileDataMap as $file => $data) { From 73f3cab813bda347322dc4b98140e83ee9280e39 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 10:25:38 +0300 Subject: [PATCH 16/32] wipe users plans scheduled nodes --- data/wipeDb.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/data/wipeDb.php b/data/wipeDb.php index 4183d5b..658bb2f 100644 --- a/data/wipeDb.php +++ b/data/wipeDb.php @@ -3,6 +3,9 @@ $files = [ 'texts.json', 'nodes.json', + 'users.json', + 'plans.json', + 'scheduledNodes.json', ]; foreach ($files as $file) { From 0e57b9050935c6e53e4bccb34f986b64626eff04 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 10:25:53 +0300 Subject: [PATCH 17/32] add plan controller --- app/Plan/PlanController.php | 55 +++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 app/Plan/PlanController.php diff --git a/app/Plan/PlanController.php b/app/Plan/PlanController.php new file mode 100644 index 0000000..3dd6ab4 --- /dev/null +++ b/app/Plan/PlanController.php @@ -0,0 +1,55 @@ +getParsedBody(); + $userId = $data['userId'] ?? null; + $textId = $data['textId'] ?? null; + $name = $data['name'] ?? null; + $dateStart = $data['dateStart'] ?? null; + $dateEnd = $data['dateEnd'] ?? null; + + try { + $plan = $createPlanUseCase->execute(new CreatePlanRequest( + userId: $userId, + textId: $textId, + name: $name, + dateStart: $dateStart, + dateEnd: $dateEnd, + )); + } catch (BadRequestException $exception) { + $response->getBody()->write( + json_encode(['error' => $exception->getMessage()]) + ); + return $response->withStatus(400) + ->withHeader('Content-Type', 'application/json'); + } catch (DomainException $exception) { + $response->getBody()->write( + json_encode(['error' => $exception->getMessage()]) + ); + return $response->withStatus(404) + ->withHeader('Content-Type', 'application/json'); + } + + $response->getBody()->write(json_encode([ + 'id' => $plan->getId(), + 'name' => $plan->getName(), + ])); + return $response->withStatus(201) + ->withHeader('Content-Type', 'application/json'); + } +} From 814e5e11d287974bf27f65ae8bfe9b3c24b4e4c8 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 10:26:07 +0300 Subject: [PATCH 18/32] add create plan route --- bootstrap/app.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bootstrap/app.php b/bootstrap/app.php index db0dd63..1ceeb8e 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -6,6 +6,7 @@ use DI\Bridge\Slim\Bridge; use App\View\ViewController; use App\Text\TextController; use App\Node\NodeController; +use App\Plan\PlanController; $container = require __DIR__ . '/container.php'; $app = Bridge::create($container); @@ -26,4 +27,6 @@ $app->get('/api/nodes/{textId}', [NodeController::class, 'getNodesOfText']); $app->post('/api/nodes/bulk', [NodeController::class, 'bulkCreateNodes']); $app->post('/api/nodes', [NodeController::class, 'createNode']); +$app->post('/api/plans', [PlanController::class, 'createPlan']); + return $app; From e664fead2c20ffa1c019d3d7936e0ac0c2bde946 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 10:28:00 +0300 Subject: [PATCH 19/32] parse json body in plan controller --- app/Plan/PlanController.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app/Plan/PlanController.php b/app/Plan/PlanController.php index 3dd6ab4..cb88de8 100644 --- a/app/Plan/PlanController.php +++ b/app/Plan/PlanController.php @@ -16,9 +16,10 @@ class PlanController Response $response, CreatePlan $createPlanUseCase, ): Response { - $data = $request->getParsedBody(); - $userId = $data['userId'] ?? null; - $textId = $data['textId'] ?? null; + $data = json_decode((string) $request->getBody(), true) ?? []; + + $userId = isset($data['userId']) ? (int) $data['userId'] : null; + $textId = isset($data['textId']) ? (int) $data['textId'] : null; $name = $data['name'] ?? null; $dateStart = $data['dateStart'] ?? null; $dateEnd = $data['dateEnd'] ?? null; From f836a09d025795e85372512511d5067bc153d054 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 10:28:04 +0300 Subject: [PATCH 20/32] submit create plan via fetch --- public/js/home.js | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/public/js/home.js b/public/js/home.js index bf76104..b46a051 100644 --- a/public/js/home.js +++ b/public/js/home.js @@ -17,6 +17,9 @@ document.addEventListener('DOMContentLoaded', () => { const cancelPlanButton = createPlanModal.querySelector( 'button.cancel-plan' ); + const savePlanButton = createPlanModal.querySelector( + 'button.save-plan' + ); const planNameInput = createPlanModal.querySelector('input.plan-name'); const planDateStartInput = createPlanModal.querySelector( 'input.plan-date-start' @@ -51,5 +54,32 @@ document.addEventListener('DOMContentLoaded', () => { closeCreatePlanModal(); }); + savePlanButton.addEventListener('click', async () => { + const planName = planNameInput.value; + const dateStart = planDateStartInput.value; + const dateEnd = planDateEndInput.value; + const textId = Number(createPlanModal.dataset.textId); + + if (planName === '' || dateStart === '' || dateEnd === '') { + return; + } + + const response = await fetch('/api/plans', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + userId: 0, + textId: textId, + name: planName, + dateStart: dateStart, + dateEnd: dateEnd, + }), + }); + + if (response.ok) { + closeCreatePlanModal(); + } + }); + loadTexts(); }); From 6b310c8c9c0d31603853b5581ca6ff9bf31443dc Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 11:00:06 +0300 Subject: [PATCH 21/32] add plan controller test scaffold --- tests/e2e/Controllers/PlanControllerTest.php | 73 ++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 tests/e2e/Controllers/PlanControllerTest.php diff --git a/tests/e2e/Controllers/PlanControllerTest.php b/tests/e2e/Controllers/PlanControllerTest.php new file mode 100644 index 0000000..50ddfe1 --- /dev/null +++ b/tests/e2e/Controllers/PlanControllerTest.php @@ -0,0 +1,73 @@ +planRepo = new FakePlanRepository(); + $this->userRepo = new FakeUserRepository(); + $this->textRepo = new FakeTextRepository(); + $this->nodeRepo = new FakeNodeRepository(); + $this->scheduledNodeRepo = new FakeScheduledNodeRepository(); + + $this->userRepo->create(new CreateUserDto( + email: new EmailAddress('test@test.com'), + )); + $text = $this->textRepo->create(new CreateTextDto('testname')); + $this->nodeRepo->create(new CreateNodeDto( + text: $text, + title: 'Root Node', + parentNode: null, + )); + + $createScheduledNode = new CreateScheduledNode( + scheduledNodeRepo: $this->scheduledNodeRepo, + planRepo: $this->planRepo, + ); + $this->createPlan = new CreatePlan( + $this->planRepo, + $this->userRepo, + $this->textRepo, + $this->nodeRepo, + $createScheduledNode, + ); + $this->controller = new PlanController(); + } + + private function makeRequest(array $data): ServerRequestInterface + { + $body = new StreamFactory()->createStream(json_encode($data)); + return new ServerRequestFactory() + ->createServerRequest('POST', 'http://localhost/api/plans') + ->withHeader('Content-Type', 'application/json') + ->withBody($body); + } +} From ef71708abe4d39e84f8c3935fd10d843b9b74b7b Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 11:00:23 +0300 Subject: [PATCH 22/32] test create plan returns 201 --- tests/e2e/Controllers/PlanControllerTest.php | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/e2e/Controllers/PlanControllerTest.php b/tests/e2e/Controllers/PlanControllerTest.php index 50ddfe1..e5390b8 100644 --- a/tests/e2e/Controllers/PlanControllerTest.php +++ b/tests/e2e/Controllers/PlanControllerTest.php @@ -70,4 +70,24 @@ class PlanControllerTest extends TestCase ->withHeader('Content-Type', 'application/json') ->withBody($body); } + + public function test_create_plan_returns_201_with_id_and_name(): void + { + $response = $this->controller->createPlan( + $this->makeRequest([ + 'userId' => 0, + 'textId' => 0, + 'name' => 'My Plan', + 'dateStart' => '2025-01-01', + 'dateEnd' => '2025-01-01', + ]), + new Response(), + $this->createPlan, + ); + + $this->assertEquals(201, $response->getStatusCode()); + $body = json_decode($response->getBody(), true); + $this->assertArrayHasKey('id', $body); + $this->assertEquals('My Plan', $body['name']); + } } From ecb43fda2d4acea50843a9f34772221412c11323 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 11:00:37 +0300 Subject: [PATCH 23/32] test create plan 400 when user id missing --- tests/e2e/Controllers/PlanControllerTest.php | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/e2e/Controllers/PlanControllerTest.php b/tests/e2e/Controllers/PlanControllerTest.php index e5390b8..54f541e 100644 --- a/tests/e2e/Controllers/PlanControllerTest.php +++ b/tests/e2e/Controllers/PlanControllerTest.php @@ -90,4 +90,22 @@ class PlanControllerTest extends TestCase $this->assertArrayHasKey('id', $body); $this->assertEquals('My Plan', $body['name']); } + + public function test_create_plan_returns_400_when_user_id_missing(): void + { + $response = $this->controller->createPlan( + $this->makeRequest([ + 'textId' => 0, + 'name' => 'My Plan', + 'dateStart' => '2025-01-01', + 'dateEnd' => '2025-01-01', + ]), + new Response(), + $this->createPlan, + ); + + $this->assertEquals(400, $response->getStatusCode()); + $body = json_decode($response->getBody(), true); + $this->assertArrayHasKey('error', $body); + } } From 088fa5b533845c693fa48cdeb06092389a9eb848 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 11:00:53 +0300 Subject: [PATCH 24/32] test create plan 400 when text id missing --- tests/e2e/Controllers/PlanControllerTest.php | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/e2e/Controllers/PlanControllerTest.php b/tests/e2e/Controllers/PlanControllerTest.php index 54f541e..fbb1a8e 100644 --- a/tests/e2e/Controllers/PlanControllerTest.php +++ b/tests/e2e/Controllers/PlanControllerTest.php @@ -108,4 +108,22 @@ class PlanControllerTest extends TestCase $body = json_decode($response->getBody(), true); $this->assertArrayHasKey('error', $body); } + + public function test_create_plan_returns_400_when_text_id_missing(): void + { + $response = $this->controller->createPlan( + $this->makeRequest([ + 'userId' => 0, + 'name' => 'My Plan', + 'dateStart' => '2025-01-01', + 'dateEnd' => '2025-01-01', + ]), + new Response(), + $this->createPlan, + ); + + $this->assertEquals(400, $response->getStatusCode()); + $body = json_decode($response->getBody(), true); + $this->assertArrayHasKey('error', $body); + } } From 0b4f7c61ef671f4ad03c909b4055bf08df91e529 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 11:01:06 +0300 Subject: [PATCH 25/32] test create plan 400 when name missing --- tests/e2e/Controllers/PlanControllerTest.php | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/e2e/Controllers/PlanControllerTest.php b/tests/e2e/Controllers/PlanControllerTest.php index fbb1a8e..468d9ec 100644 --- a/tests/e2e/Controllers/PlanControllerTest.php +++ b/tests/e2e/Controllers/PlanControllerTest.php @@ -126,4 +126,22 @@ class PlanControllerTest extends TestCase $body = json_decode($response->getBody(), true); $this->assertArrayHasKey('error', $body); } + + public function test_create_plan_returns_400_when_name_missing(): void + { + $response = $this->controller->createPlan( + $this->makeRequest([ + 'userId' => 0, + 'textId' => 0, + 'dateStart' => '2025-01-01', + 'dateEnd' => '2025-01-01', + ]), + new Response(), + $this->createPlan, + ); + + $this->assertEquals(400, $response->getStatusCode()); + $body = json_decode($response->getBody(), true); + $this->assertArrayHasKey('error', $body); + } } From 21bed284e98640b4a023afd022b599b92ab9fcf5 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 11:01:21 +0300 Subject: [PATCH 26/32] test create plan 400 when date start missing --- tests/e2e/Controllers/PlanControllerTest.php | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/e2e/Controllers/PlanControllerTest.php b/tests/e2e/Controllers/PlanControllerTest.php index 468d9ec..8bf03ce 100644 --- a/tests/e2e/Controllers/PlanControllerTest.php +++ b/tests/e2e/Controllers/PlanControllerTest.php @@ -144,4 +144,22 @@ class PlanControllerTest extends TestCase $body = json_decode($response->getBody(), true); $this->assertArrayHasKey('error', $body); } + + public function test_create_plan_returns_400_when_date_start_missing(): void + { + $response = $this->controller->createPlan( + $this->makeRequest([ + 'userId' => 0, + 'textId' => 0, + 'name' => 'My Plan', + 'dateEnd' => '2025-01-01', + ]), + new Response(), + $this->createPlan, + ); + + $this->assertEquals(400, $response->getStatusCode()); + $body = json_decode($response->getBody(), true); + $this->assertArrayHasKey('error', $body); + } } From 2a76f69553eee0b6a77c2ef1deac1d0ba2868c16 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 11:01:34 +0300 Subject: [PATCH 27/32] test create plan 400 when date end missing --- tests/e2e/Controllers/PlanControllerTest.php | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/e2e/Controllers/PlanControllerTest.php b/tests/e2e/Controllers/PlanControllerTest.php index 8bf03ce..6ade21a 100644 --- a/tests/e2e/Controllers/PlanControllerTest.php +++ b/tests/e2e/Controllers/PlanControllerTest.php @@ -162,4 +162,22 @@ class PlanControllerTest extends TestCase $body = json_decode($response->getBody(), true); $this->assertArrayHasKey('error', $body); } + + public function test_create_plan_returns_400_when_date_end_missing(): void + { + $response = $this->controller->createPlan( + $this->makeRequest([ + 'userId' => 0, + 'textId' => 0, + 'name' => 'My Plan', + 'dateStart' => '2025-01-01', + ]), + new Response(), + $this->createPlan, + ); + + $this->assertEquals(400, $response->getStatusCode()); + $body = json_decode($response->getBody(), true); + $this->assertArrayHasKey('error', $body); + } } From 68554cb362775e6991b454b2c8af8a8ebdcb03de Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 11:01:47 +0300 Subject: [PATCH 28/32] test create plan 400 when date end before start --- tests/e2e/Controllers/PlanControllerTest.php | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/e2e/Controllers/PlanControllerTest.php b/tests/e2e/Controllers/PlanControllerTest.php index 6ade21a..5d99621 100644 --- a/tests/e2e/Controllers/PlanControllerTest.php +++ b/tests/e2e/Controllers/PlanControllerTest.php @@ -180,4 +180,23 @@ class PlanControllerTest extends TestCase $body = json_decode($response->getBody(), true); $this->assertArrayHasKey('error', $body); } + + public function test_create_plan_returns_400_when_date_end_before_start(): void + { + $response = $this->controller->createPlan( + $this->makeRequest([ + 'userId' => 0, + 'textId' => 0, + 'name' => 'My Plan', + 'dateStart' => '2025-01-02', + 'dateEnd' => '2025-01-01', + ]), + new Response(), + $this->createPlan, + ); + + $this->assertEquals(400, $response->getStatusCode()); + $body = json_decode($response->getBody(), true); + $this->assertArrayHasKey('error', $body); + } } From 2238202384e19e25b72a43ebd0bece19909f59e7 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 11:02:00 +0300 Subject: [PATCH 29/32] test create plan 404 when user not found --- tests/e2e/Controllers/PlanControllerTest.php | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/e2e/Controllers/PlanControllerTest.php b/tests/e2e/Controllers/PlanControllerTest.php index 5d99621..913583a 100644 --- a/tests/e2e/Controllers/PlanControllerTest.php +++ b/tests/e2e/Controllers/PlanControllerTest.php @@ -199,4 +199,23 @@ class PlanControllerTest extends TestCase $body = json_decode($response->getBody(), true); $this->assertArrayHasKey('error', $body); } + + public function test_create_plan_returns_404_when_user_not_found(): void + { + $response = $this->controller->createPlan( + $this->makeRequest([ + 'userId' => 99, + 'textId' => 0, + 'name' => 'My Plan', + 'dateStart' => '2025-01-01', + 'dateEnd' => '2025-01-01', + ]), + new Response(), + $this->createPlan, + ); + + $this->assertEquals(404, $response->getStatusCode()); + $body = json_decode($response->getBody(), true); + $this->assertArrayHasKey('error', $body); + } } From 165f08f04d46403aae991a403cb3d56edf6a2942 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 11:02:20 +0300 Subject: [PATCH 30/32] test create plan 404 when text not found --- tests/e2e/Controllers/PlanControllerTest.php | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/e2e/Controllers/PlanControllerTest.php b/tests/e2e/Controllers/PlanControllerTest.php index 913583a..2392b33 100644 --- a/tests/e2e/Controllers/PlanControllerTest.php +++ b/tests/e2e/Controllers/PlanControllerTest.php @@ -218,4 +218,23 @@ class PlanControllerTest extends TestCase $body = json_decode($response->getBody(), true); $this->assertArrayHasKey('error', $body); } + + public function test_create_plan_returns_404_when_text_not_found(): void + { + $response = $this->controller->createPlan( + $this->makeRequest([ + 'userId' => 0, + 'textId' => 99, + 'name' => 'My Plan', + 'dateStart' => '2025-01-01', + 'dateEnd' => '2025-01-01', + ]), + new Response(), + $this->createPlan, + ); + + $this->assertEquals(404, $response->getStatusCode()); + $body = json_decode($response->getBody(), true); + $this->assertArrayHasKey('error', $body); + } } From 0ff5043ba54eae6814c3074bb76856e8b016e379 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 11:02:38 +0300 Subject: [PATCH 31/32] test create plan persists plan --- tests/e2e/Controllers/PlanControllerTest.php | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/e2e/Controllers/PlanControllerTest.php b/tests/e2e/Controllers/PlanControllerTest.php index 2392b33..6d3796f 100644 --- a/tests/e2e/Controllers/PlanControllerTest.php +++ b/tests/e2e/Controllers/PlanControllerTest.php @@ -237,4 +237,23 @@ class PlanControllerTest extends TestCase $body = json_decode($response->getBody(), true); $this->assertArrayHasKey('error', $body); } + + public function test_create_plan_persists_plan_in_repository(): void + { + $this->controller->createPlan( + $this->makeRequest([ + 'userId' => 0, + 'textId' => 0, + 'name' => 'Persistent Plan', + 'dateStart' => '2025-01-01', + 'dateEnd' => '2025-01-01', + ]), + new Response(), + $this->createPlan, + ); + + $storedPlan = $this->planRepo->find(0); + $this->assertNotNull($storedPlan); + $this->assertEquals('Persistent Plan', $storedPlan->getName()); + } } From 9d831915de8b04fb18153f03d39c533bcea2b548 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 11:02:55 +0300 Subject: [PATCH 32/32] test create plan schedules nodes --- tests/e2e/Controllers/PlanControllerTest.php | 21 ++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/e2e/Controllers/PlanControllerTest.php b/tests/e2e/Controllers/PlanControllerTest.php index 6d3796f..ee51012 100644 --- a/tests/e2e/Controllers/PlanControllerTest.php +++ b/tests/e2e/Controllers/PlanControllerTest.php @@ -256,4 +256,25 @@ class PlanControllerTest extends TestCase $this->assertNotNull($storedPlan); $this->assertEquals('Persistent Plan', $storedPlan->getName()); } + + public function test_create_plan_schedules_nodes(): void + { + $this->controller->createPlan( + $this->makeRequest([ + 'userId' => 0, + 'textId' => 0, + 'name' => 'Scheduling Plan', + 'dateStart' => '2025-01-01', + 'dateEnd' => '2025-01-01', + ]), + new Response(), + $this->createPlan, + ); + + $this->assertEquals( + 1, + $this->scheduledNodeRepo->getNumberOfTimesCreateCalled() + ); + $this->assertNotNull($this->scheduledNodeRepo->find(0)); + } }