Compare commits

...

35 commits

Author SHA1 Message Date
38d72ba1a8
Merge branch 'today-scheduled-nodes-page' 2026-05-01 10:54:51 +03:00
7091eefd4a
add caveman ai plugin config 2026-05-01 10:52:56 +03:00
ed4440eec2
test scheduled node controller returns 404 when user missing 2026-05-01 10:25:24 +03:00
f315db6d00
wrap get todays schedule in try catch 2026-05-01 10:25:00 +03:00
659f9b88f1
test scheduled node controller surfaces date error from use case 2026-05-01 10:24:43 +03:00
2eafe67f31
test scope schedule to requesting user 2026-05-01 10:19:03 +03:00
3711840669
test exclude future scheduled nodes 2026-05-01 10:18:47 +03:00
54e33f9b03
test empty schedule returns empty array 2026-05-01 10:18:32 +03:00
c0f35c88b7
throw on missing user in get todays schedule 2026-05-01 10:18:19 +03:00
dd217e4142
test get todays schedule missing user 2026-05-01 10:18:02 +03:00
120e5ee9a1
validate get todays schedule user id 2026-05-01 10:17:49 +03:00
6d0b5d61e1
test get todays schedule null user id 2026-05-01 10:17:38 +03:00
f0fd076fb9
validate get todays schedule date 2026-05-01 10:17:24 +03:00
4e2904a2b4
test get todays schedule null date 2026-05-01 10:17:10 +03:00
c9f1379496
remove default values from test helpers 2026-05-01 10:05:07 +03:00
8b5767e6f4
add no default parameters rule
Forcing every call site to be explicit eliminates a class of bugs where an unintended default silently slips through. Codifies the convention already established by prior commits (cd40483, b41652a, 8eb0f23).
2026-05-01 10:04:32 +03:00
4294521dfc
add scheduled nodes endpoint 2026-05-01 09:59:18 +03:00
636d2dc517
test get scheduled nodes endpoint 2026-05-01 09:58:28 +03:00
0c76773ef0
opencode config 2026-05-01 09:57:20 +03:00
7ec91e3869
reformat ai files 2026-05-01 09:56:25 +03:00
669bcf8d5e
style 2026-05-01 09:56:14 +03:00
e04931ac08
test that todays schedule only returns uncompleted nodes 2026-05-01 09:06:13 +03:00
8eeff2c4fe
add update method to fake scheduled node repo 2026-05-01 09:05:55 +03:00
1b2e44389c
add completed bool to scheduled node 2026-05-01 09:04:18 +03:00
f2840a3eb1
add find by user method in json scheduled node repo 2026-05-01 09:03:52 +03:00
07e34ffd46
add find by user method in plan repo 2026-05-01 09:02:34 +03:00
ec4dca87a6
test that all nodes up until given date are returned 2026-04-28 22:48:25 +03:00
2047cd72e7
test get todays schedule and use case with request 2026-04-28 22:38:43 +03:00
0ea300f4d2
add find by user method for scheduled nodes 2026-04-28 22:37:28 +03:00
a9265abeae
add node to scheduled node 2026-04-27 09:28:43 +03:00
d47a0235d2
add node to scheduled node entity and dto 2026-04-27 09:10:40 +03:00
46d01aa813
test today page lists scheduled nodes 2026-04-26 21:24:56 +03:00
6314f1c38c
add link to today page on home 2026-04-26 21:24:55 +03:00
bfacb5b62c
add today view route and template 2026-04-26 21:24:35 +03:00
0b4d7238af
add today page cypress spec 2026-04-26 20:50:52 +03:00
36 changed files with 1186 additions and 138 deletions

4
.gitignore vendored
View file

@ -1,4 +1,6 @@
vendor/
node_modules/
data/*.json
.direnv/
.direnv/
cypress/screenshots/
cypress/videos/

7
.opencode/opencode.json Normal file
View file

@ -0,0 +1,7 @@
{
"$schema": "https://opencode.ai/config.json",
"plugin": [
"caveman",
"caveman-opencode-plugin@latest"
]
}

7
AGENTS.md Normal file
View file

@ -0,0 +1,7 @@
# Project context
Read these on every session. Rules in them override defaults.
@ai/shared.md
@ai/backend-context.md
@ai/frontend-context.md

View file

@ -16,11 +16,14 @@
<mxCell id="UlVOh7WOaItsqOB8hf6W-7" edge="1" parent="1" source="UlVOh7WOaItsqOB8hf6W-5" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=1;entryY=0.5;entryDx=0;entryDy=0;" target="UlVOh7WOaItsqOB8hf6W-2">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="UlVOh7WOaItsqOB8hf6W-21" edge="1" parent="1" source="UlVOh7WOaItsqOB8hf6W-5" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.5;entryY=1;entryDx=0;entryDy=0;" target="UlVOh7WOaItsqOB8hf6W-9">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="UlVOh7WOaItsqOB8hf6W-5" parent="1" style="whiteSpace=wrap;html=1;aspect=fixed;" value="Scheduled Node" vertex="1">
<mxGeometry height="80" width="80" x="610" y="290" as="geometry" />
</mxCell>
<mxCell id="UlVOh7WOaItsqOB8hf6W-8" parent="1" style="whiteSpace=wrap;html=1;aspect=fixed;" value="Text" vertex="1">
<mxGeometry height="80" width="80" x="240" y="60" as="geometry" />
<mxGeometry height="80" width="80" x="450" y="90" as="geometry" />
</mxCell>
<mxCell id="UlVOh7WOaItsqOB8hf6W-12" edge="1" parent="1" source="UlVOh7WOaItsqOB8hf6W-9" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" target="UlVOh7WOaItsqOB8hf6W-8">
<mxGeometry relative="1" as="geometry" />
@ -28,14 +31,14 @@
<mxCell id="UlVOh7WOaItsqOB8hf6W-14" edge="1" parent="1" source="UlVOh7WOaItsqOB8hf6W-9" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=1;exitY=0.25;exitDx=0;exitDy=0;entryX=1;entryY=0.75;entryDx=0;entryDy=0;" target="UlVOh7WOaItsqOB8hf6W-9" value="">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="590" y="80" />
<mxPoint x="590" y="120" />
<mxPoint x="800" y="110" />
<mxPoint x="800" y="150" />
</Array>
<mxPoint x="590" y="120" as="targetPoint" />
<mxPoint x="800" y="150" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="UlVOh7WOaItsqOB8hf6W-9" parent="1" style="whiteSpace=wrap;html=1;aspect=fixed;" value="Node" vertex="1">
<mxGeometry height="80" width="80" x="400" y="60" as="geometry" />
<mxGeometry height="80" width="80" x="610" y="90" as="geometry" />
</mxCell>
<mxCell id="UlVOh7WOaItsqOB8hf6W-19" edge="1" parent="1" source="UlVOh7WOaItsqOB8hf6W-17" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" target="UlVOh7WOaItsqOB8hf6W-1">
<mxGeometry relative="1" as="geometry" />

45
ai/backend-context.md Normal file
View file

@ -0,0 +1,45 @@
# Backend context
> Read `ai/shared.md` first. This file only covers backend-specific rules.
## Project Context
**Stack:** PHP 8.5, Slim 4, PHP-DI/Slim-Bridge, PHPUnit 13, Composer.
Persistence is JSON-file based (see `Json*Repository` classes); no ORM.
**Architecture:** Domain-Driven Design. Code is organized by domain entity
under `app/` (Auth, Node, Plan, ScheduledNode, Text, User, View,
ValueObjects) into Entities, DTOs, Repositories, Use Cases, and Fakes
(in-memory repos for tests).
## Code patterns
- Look at similar entities (e.g. `Node`, `Text`) for reference
- Entities: constructor with properties, getters
- DTOs: simple data containers for creation (e.g. `CreateTextDto`)
- Repositories: interfaces that define data access
- Do not write unit tests for concrete repository implementations
(e.g. `JsonNodeRepository`). They are exercised by e2e tests. Use
cases are tested with fake repositories.
- Use cases: business logic with Request objects
- When throwing exceptions, add `@throws` docblock
- Fakes: in-memory implementations for testing
- Look at `tests/Fakes/` for examples
- Find/lookup methods must return a new instance of the entity, not the
stored reference
- Tests: follow existing patterns in `tests/Unit/[Entity]/UseCases/`
- In `setUp`, only use fake repositories for entities under test - construct
dependency objects directly with `new` (e.g.
`new Text(...)`) instead of creating them through their fake
repositories
## PHP rules
- Imports: always put `use` statements at the top of the file, never use inline
imports (e.g. `\App\Foo\Bar::class`)
- Closures: never use arrow functions (`fn () =>`) - always use regular
anonymous functions (`function () { return ...; }`)
## Pre-commit
Run `php-cs-fixer fix` on worked-on directories before committing (uses the
existing `.php-cs-fixer.dist.php` config).

View file

@ -1,68 +0,0 @@
# Entity Creation Prompt Template
Follow the existing patterns in this codebase to:
- create a new entity called [EntityName].
Requirements:
- The entity encapsulates [one or more Entities]
- Include [any other fields]
Process (TDD - Test Driven Development):
1. Write a test first
2. Run the test to confirm it fails
3. Implement the code to make the test pass
4. Run the test to confirm it passes
5. Repeat for each new behavior
Code patterns to follow:
- First, explore the codebase to understand existing entity patterns
- Look at similar entities (e.g. Node, Text, etc.) for reference
- Entities: constructor with properties, getters
- DTOs: simple data containers for creation
- Repositories: interfaces that define data access
- Do not write unit tests for concrete repository implementations
(e.g., Doctrine/persistence-backed). They are exercised by e2e
tests. Use cases are tested with fake repositories.
- Use cases: business logic with Request objects
- When throwing exceptions, add @throws docblock
- Fakes: in-memory implementations for testing
- Look at tests/Fakes/ for examples
- Find/lookup methods must return a new instance of the entity, not the stored reference
- Tests: follow existing patterns in tests/Unit/[Entity]/UseCases/
- In setUp, only use fake repositories for entities under test - construct dependency objects directly with `new` (e.g., `new Text(....)`) instead of creating them through their fake repositories
- Lines should not exceed 80 columns, but should use up to 80 columns when possible - do not split lines unnecessarily
- Imports: always put use statements at the top of the file, never use inline imports (e.g., \App\Foo\Bar::class)
- Variable names: use explicit, descriptive names - never single-letter or abbreviated variables (e.g., use $sponsorship not $s, $event not $e)
- Never use em-dashes (—) in code, comments, commit messages, or any
written output. Use a regular hyphen (-), a colon, or rephrase
with parentheses instead.
Git commit style:
- Subject: present tense, imperative mood (add, create, test, fix)
- Subject: lowercase, short (3-6 words)
- Match subject patterns found in git history
- Add a body when the change needs explanation beyond the subject -
e.g., why the change was made, non-obvious tradeoffs, or notable
implementation details. Skip the body for trivial/self-evident commits.
- Separate subject and body with a blank line; wrap body at ~72 columns
Git commits:
- Tests should be committed first, before implementation
- Group related changes together in a single commit (e.g., a new class
plus its registration, or a getter plus the property it exposes).
Avoid mixing unrelated concerns in one commit.
- Keep commits small and focused - prefer many small commits over few
large ones, but don't artificially split a single logical change
across multiple commits
- Commits are for reviewing and documenting the development of code
- Don't wait to commit - commit as you go
- Run `php-cs-fixer fix` on worked on directories before committing
Branch naming:
- Use kebab-case (e.g., presenting-track, agenda-slots)
- Use descriptive feature names
- Examples: "presenting-track", "agenda-slots", "confirm-application"
- Or use type/description: "feature/presenting-track", "fix/bug-name"
- NEVER work directly on master/main - always create and work on a branch
Do not push anything. Make commits as you go.

33
ai/frontend-context.md Normal file
View file

@ -0,0 +1,33 @@
# Frontend context
> Read `ai/shared.md` first. This file only covers frontend-specific rules.
## Project Context
**Stack:** vanilla PHP templates in `views/templates/`, plain ES JavaScript in
`public/js/`, no framework, no build step. Cypress 15 for E2E.
**Entry point:** `public/index.php` (Slim app); page templates are rendered
via the existing templating layer.
## Code patterns
- Look at existing pages (`home.php`/`home.js`, `text.php`/`text.js`,
`today.php`/`today.js`) for reference before writing anything
- **Templates:** `views/templates/<page>.php`, one file per page
- **Page JS:** `public/js/<page>.js`, one file per page, paired with the
matching template
- **Testing:** Cypress E2E only, mirror existing `cypress/e2e/*.cy.js` style
(note: this project uses `.cy.js`, not `.cy.ts`)
- **Imports / script tags:** keep at the top of the file
- **Variable names:** explicit, descriptive (e.g. `text` not `t`)
## Pre-commit
No JS formatter or linter is configured yet; format manually for consistency
with surrounding files. (TODO: wire up format/lint when added.)
## Note on commit granularity
Frontend changes are often a template plus its page-level JS counterpart -
commit them together as a single logical unit, per the "one logical change
per commit" rule in `shared.md`.

View file

@ -1,52 +0,0 @@
# Frontend Prompt Template
Follow the existing patterns in this codebase to:
- xxxxxxxx
Requirements:
- xxxxx
Process (TDD - Test Driven Development):
1. Write a test first
2. Run the test to confirm it fails
3. Implement the code to make the test pass
4. Run the test to confirm it passes
5. Repeat for each new behavior
Code patterns to follow:
- First, explore the codebase to understand existing entity patterns
- Look at similar pages for reference
- Tests: follow existing patterns in cypress/e2e/
- Lines should not exceed 80 columns, but should use up to 80 columns when possible - do not split lines unnecessarily
- Imports: always put imports at the top of the file
- Variable names: use explicit, descriptive names - never single-letter or abbreviated variables (e.g., use sponsorship not s, event not e)
- Never use em-dashes (—) in code, comments, commit messages, or any
written output. Use a regular hyphen (-), a colon, or rephrase
with parentheses instead.
Git commit style:
- Subject: present tense, imperative mood (add, create, test, fix)
- Subject: lowercase, short (3-6 words)
- Match subject patterns found in git history
- Add a body when the change needs explanation beyond the subject -
e.g., why the change was made, non-obvious tradeoffs, or notable
implementation details. Skip the body for trivial/self-evident commits.
- Separate subject and body with a blank line; wrap body at ~72 columns
Git commits:
- Tests should be committed first, before implementation
- Group related changes together in a single commit (e.g., a new class
plus its registration, or a getter plus the property it exposes).
Avoid mixing unrelated concerns in one commit.
- Keep commits small and focused - prefer many small commits over few
large ones, but don't artificially split a single logical change
across multiple commits
- Commits are for reviewing and documenting the development of code
- Don't wait to commit - commit as you go
Branch naming:
- Use kebab-case (e.g., node-page text-page)
- Use descriptive feature names
- NEVER work directly on master - always create and work on a branch
Do not push anything. Make commits as you go.

74
ai/shared.md Normal file
View file

@ -0,0 +1,74 @@
# Shared rules
Rules that apply to both backend and frontend work in this repo. Stack-specific
guides (`backend-context.md`, `frontend-context.md`) extend these.
## Process (TDD)
0. Before editing any file, ensure you are on a feature branch
(`git status` to confirm). If on master/main, create a branch
first.
1. Write the test first
2. Run the test to confirm it fails
3. Commit the failing test (the "tests committed first" rule in
action - the test commit precedes the implementation commit, not
merely the implementation lines)
4. Implement the code to make the test pass
5. Run the test to confirm it passes
6. Commit the implementation
7. Repeat for each new behavior
## Code style
- Lines should not exceed 80 columns, but should use up to 80 columns when
possible - do not split lines unnecessarily
- Variable names: use explicit, descriptive names - never single-letter or
abbreviated variables (e.g. `$text` not `$t`, `$node` not `$n`)
- Method/function/constructor parameters: do not use default values - every
call site must pass every argument explicitly. This eliminates a class of
bugs where an unintended default silently slips through (e.g. an
`isAdmin=false` or an empty `passwordHash`). Apply the same rule in tests
and fakes - if a helper accepts a value, every caller must supply it.
- First, explore the codebase to understand existing patterns - look at similar
files for reference before writing anything
- Never use em dashes (—) in code, comments, or docblocks - use hyphens (-)
instead
## Git commit style
- Present tense, imperative mood (add, create, wire, fix, test)
- Lowercase
- Short (3-6 words)
- Match patterns found in git history
- Do not add any section mentioning claude as a coauthor
- Add a commit body when the subject alone cannot convey the change - e.g.
non-obvious motivation, multi-file coordination, or notable complexity
- Body: wrap at ~72 columns, separated from subject by a blank line, explain
the why and any non-obvious what
- Skip the body for trivial or self-explanatory commits
## Git commits
- Tests should be committed first, before implementation
- One logical change per commit - a commit may span multiple files when they
form a single logical unit (e.g. a use case with its request and exception,
or a template with its page-level JS)
- Keep commits focused: not one file per commit, not unrelated work batched
- Make commits frequent - commit each meaningful logical step as you go
- Commits are for reviewing and documenting the development of code
- When the formatter or linter modifies files outside your intended
change, either `git restore` them or land them as a separate
`format <area>` / `lint <area>` commit - never bundle drive-by
formatter churn into a feature commit
- If pre-commit lint fails on code you did not touch, do not bundle
the fix - either land the unrelated fix as its own commit first, or
note the pre-existing failure and proceed
## Branching
- Use kebab-case (e.g. `text-page`, `scheduled-node`, `auth-flow`)
- Use descriptive feature names
- Or use type/description: `feature/text-page`, `fix/bug-name`
- NEVER work directly on master/main - always create and work on a branch
Do not push anything. Make commits as you go.

View file

@ -90,4 +90,29 @@ class JsonPlanRepository implements PlanRepository
return $maxId + 1;
}
public function findByUser(User $user): array
{
$plans = array_filter(
$this->readPlans(),
function ($data) use ($user) {
return $data['userId'] === $user->getId();
}
);
return array_map(
function ($data) {
$user = $this->userRepository->find($data['userId']);
if ($user === null) {
return null;
}
return new Plan(
id: $data['id'],
name: $data['name'],
user: $user,
);
},
$plans
);
}
}

View file

@ -2,8 +2,14 @@
namespace App\Plan;
use App\User\User;
interface PlanRepository
{
public function create(CreatePlanDto $dto): Plan;
public function find(int $id): ?Plan;
/**
* @return Plan[]
*/
public function findByUser(User $user): array;
}

View file

@ -64,6 +64,7 @@ class CreatePlan
new CreateScheduledNodeRequest(
date: $scheduledDate->format('Y-m-d'),
planId: $plan->getId(),
nodeId: $node->getId(),
)
);
}

View file

@ -2,6 +2,7 @@
namespace App\ScheduledNode;
use App\Node\Node;
use App\Plan\Plan;
use DateTimeImmutable;
@ -10,5 +11,6 @@ class CreateScheduledNodeDto
public function __construct(
public DateTimeImmutable $date,
public Plan $plan,
public Node $node,
) {}
}

View file

@ -2,12 +2,19 @@
namespace App\ScheduledNode;
use App\Node\NodeRepository;
use App\Plan\PlanRepository;
use App\User\User;
use DateTimeImmutable;
class JsonScheduledNodeRepository implements ScheduledNodeRepository
{
private string $filePath;
public function __construct()
{
public function __construct(
private PlanRepository $planRepo,
private NodeRepository $nodeRepo,
) {
$this->filePath = __DIR__ . '/../../data/scheduledNodes.json';
}
@ -20,6 +27,8 @@ class JsonScheduledNodeRepository implements ScheduledNodeRepository
'id' => $id,
'date' => $dto->date->format('Y-m-d'),
'planId' => $dto->plan->getId(),
'nodeId' => $dto->node->getId(),
'completed' => false,
];
$this->writeScheduledNodes($scheduledNodes);
@ -27,6 +36,8 @@ class JsonScheduledNodeRepository implements ScheduledNodeRepository
id: $id,
date: $dto->date,
plan: $dto->plan,
node: $dto->node,
completed: false,
);
}
@ -64,4 +75,34 @@ class JsonScheduledNodeRepository implements ScheduledNodeRepository
return $maxId + 1;
}
public function findByUser(User $user): array
{
$allScheduledNodes = $this->readScheduledNodes();
$planIds = array_map(
function ($plan) {
return $plan->getId();
},
$this->planRepo->findByUser($user)
);
$usersScheduledNodes = array_filter(
$allScheduledNodes,
function ($node) use ($planIds) {
return in_array($node['planId'], $planIds);
}
);
return array_map(
function ($data) {
return new ScheduledNode(
id: $data['id'],
date: new DateTimeImmutable($data['date']),
plan: $this->planRepo->find($data['planId']),
node: $this->nodeRepo->find($data['nodeId']),
completed: $data['completed']
);
},
$usersScheduledNodes
);
}
}

View file

@ -2,6 +2,7 @@
namespace App\ScheduledNode;
use App\Node\Node;
use App\Plan\Plan;
use DateTimeImmutable;
@ -11,6 +12,8 @@ class ScheduledNode
private int $id,
private DateTimeImmutable $date,
private Plan $plan,
private Node $node,
private bool $completed,
) {}
public function getId(): int
@ -27,4 +30,19 @@ class ScheduledNode
{
return $this->date;
}
public function getNode(): Node
{
return $this->node;
}
public function getCompleted(): bool
{
return $this->completed;
}
public function setCompleted(bool $complete): void
{
$this->completed = $complete;
}
}

View file

@ -0,0 +1,73 @@
<?php
namespace App\ScheduledNode;
use App\Exceptions\BadRequestException;
use App\ScheduledNode\UseCases\GetTodaysSchedule;
use App\ScheduledNode\UseCases\GetTodaysScheduleRequest;
use App\User\User;
use DomainException;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
class ScheduledNodeController
{
public function getScheduledNodes(
Request $request,
Response $response,
GetTodaysSchedule $getTodaysSchedule,
): Response {
$user = $request->getAttribute('user');
if (!$user instanceof User) {
$response->getBody()->write(
json_encode(['error' => 'unauthenticated'])
);
return $response->withStatus(401)
->withHeader('Content-Type', 'application/json');
}
$queryParams = $request->getQueryParams();
$date = $queryParams['date'] ?? null;
if ($date === '') {
$date = null;
}
try {
$scheduledNodes = $getTodaysSchedule->execute(
new GetTodaysScheduleRequest(
date: $date,
userId: $user->getId(),
)
);
} 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');
}
$data = array_values(array_map(
function (ScheduledNode $scheduledNode) {
return [
'id' => $scheduledNode->getId(),
'date' => $scheduledNode->getDate()->format('Y-m-d'),
'planName' => $scheduledNode->getPlan()->getName(),
'nodeTitle' => $scheduledNode->getNode()->getTitle(),
'completed' => $scheduledNode->getCompleted(),
];
},
$scheduledNodes,
));
$response->getBody()->write(json_encode($data));
return $response->withStatus(200)
->withHeader('Content-Type', 'application/json');
}
}

View file

@ -2,7 +2,13 @@
namespace App\ScheduledNode;
use App\User\User;
interface ScheduledNodeRepository
{
public function create(CreateScheduledNodeDto $dto): ScheduledNode;
/**
* @return ScheduledNode[]
*/
public function findByUser(User $user): array;
}

View file

@ -3,6 +3,7 @@
namespace App\ScheduledNode\UseCases;
use App\Exceptions\BadRequestException;
use App\Node\NodeRepository;
use App\Plan\PlanRepository;
use App\ScheduledNode\ScheduledNode;
use App\ScheduledNode\CreateScheduledNodeDto;
@ -15,6 +16,7 @@ class CreateScheduledNode
public function __construct(
private ScheduledNodeRepository $scheduledNodeRepo,
private PlanRepository $planRepo,
private NodeRepository $nodeRepo,
) {}
/**
@ -24,24 +26,40 @@ class CreateScheduledNode
public function execute(
CreateScheduledNodeRequest $request
): ScheduledNode {
if ($request->date === null) {
$nodeId = $request->nodeId;
$planId = $request->planId;
$date = $request->date;
if ($date === null) {
throw new BadRequestException('date is required');
}
if ($request->planId === null) {
if ($planId === null) {
throw new BadRequestException('planId is required');
}
$id = $request->planId;
$plan = $this->planRepo->find($id);
if ($nodeId === null) {
throw new BadRequestException('nodeId is required');
}
$plan = $this->planRepo->find($planId);
if ($plan === null) {
throw new DomainException("Plan with id: $id doesnt exist");
throw new DomainException(
"Plan with id: $planId doesnt exist"
);
}
$node = $this->nodeRepo->find($nodeId);
if ($node === null) {
throw new DomainException(
"Node with id: $nodeId doesnt exist"
);
}
return $this->scheduledNodeRepo->create(
new CreateScheduledNodeDto(
date: new DateTimeImmutable($request->date),
date: new DateTimeImmutable($date),
plan: $plan,
node: $node,
)
);
}

View file

@ -7,5 +7,6 @@ class CreateScheduledNodeRequest
public function __construct(
public ?string $date,
public ?int $planId,
public ?int $nodeId,
) {}
}

View file

@ -0,0 +1,51 @@
<?php
namespace App\ScheduledNode\UseCases;
use App\Exceptions\BadRequestException;
use App\ScheduledNode\ScheduledNode;
use App\ScheduledNode\ScheduledNodeRepository;
use App\User\UserRepository;
use DateTimeImmutable;
use DomainException;
class GetTodaysSchedule
{
public function __construct(
private UserRepository $userRepo,
private ScheduledNodeRepository $scheduledNodeRepo,
) {}
/**
* @return ScheduledNode[]
*
* @throws BadRequestException
* @throws DomainException
*/
public function execute(GetTodaysScheduleRequest $request): array
{
if ($request->date === null) {
throw new BadRequestException('date is required');
}
if ($request->userId === null) {
throw new BadRequestException('userId is required');
}
$date = new DateTimeImmutable($request->date);
$userId = $request->userId;
$user = $this->userRepo->find($userId);
if ($user === null) {
throw new DomainException(
"User with id: $userId doesnt exist"
);
}
$scheduledNodes = $this->scheduledNodeRepo->findByUser($user);
return array_filter(
$scheduledNodes,
function (ScheduledNode $node) use ($date) {
return $node->getDate() <= $date
&& $node->getCompleted() === false;
}
);
}
}

View file

@ -0,0 +1,11 @@
<?php
namespace App\ScheduledNode\UseCases;
class GetTodaysScheduleRequest
{
public function __construct(
public ?string $date,
public ?int $userId,
) {}
}

View file

@ -38,6 +38,16 @@ class ViewController
return $response;
}
public function today(Response $response): Response
{
$html = file_get_contents(
__DIR__ . '/../../views/templates/today.php'
);
$response->getBody()->write($html);
return $response;
}
public function login(Response $response): Response
{
$html = file_get_contents(

View file

@ -11,6 +11,7 @@ use App\View\ViewController;
use App\Text\TextController;
use App\Node\NodeController;
use App\Plan\PlanController;
use App\ScheduledNode\ScheduledNodeController;
$container = require __DIR__ . '/container.php';
$app = Bridge::create($container);
@ -27,6 +28,7 @@ $app->post('/api/auth/register', [AuthController::class, 'register']);
// Authenticated routes (any logged-in user)
$app->group('', function (RouteCollectorProxy $group) {
$group->get('/home', [ViewController::class, 'home']);
$group->get('/today', [ViewController::class, 'today']);
$group->post('/api/auth/logout', [AuthController::class, 'logout']);
$group->get('/api/auth/me', [AuthController::class, 'me']);
@ -43,6 +45,11 @@ $app->group('', function (RouteCollectorProxy $group) {
);
$group->post('/api/plans', [PlanController::class, 'createPlan']);
$group->get(
'/api/scheduled-nodes',
[ScheduledNodeController::class, 'getScheduledNodes']
);
})->add(AuthMiddleware::class);
// Admin-only routes

View file

@ -26,10 +26,10 @@ $container = new Container([
NodeRepository::class => DI\autowire(JsonNodeRepository::class),
PlanRepository::class => DI\autowire(JsonPlanRepository::class),
UserRepository::class => DI\autowire(JsonUserRepository::class),
ScheduledNodeRepository::class =>
DI\autowire(JsonScheduledNodeRepository::class),
SessionRepository::class =>
DI\autowire(JsonSessionRepository::class),
ScheduledNodeRepository::class
=> DI\autowire(JsonScheduledNodeRepository::class),
SessionRepository::class
=> DI\autowire(JsonSessionRepository::class),
TokenGenerator::class => DI\autowire(RandomTokenGenerator::class),
Clock::class => DI\autowire(SystemClock::class),
PasswordHasher::class => DI\autowire(BcryptPasswordHasher::class),

9
caveman.json Normal file
View file

@ -0,0 +1,9 @@
{
"enabled": true,
"defaultMode": "full",
"features": {
"caveman": true,
"commit": ,
"review": true
}
}

75
cypress/e2e/today.cy.js Normal file
View file

@ -0,0 +1,75 @@
describe('The today page', () => {
beforeEach(() => {
cy.exec('npm run db:seed')
})
afterEach(() => {
cy.exec('npm run db:wipe')
})
it('redirects to login when not authenticated', () => {
cy.visit('/today')
cy.url().should('include', '/login')
})
it('displays a Today heading when authenticated', () => {
cy.loginAsUser()
cy.visit('/today')
cy.get('h1').should('contain', 'Today')
})
it('has a list element for scheduled nodes', () => {
cy.loginAsUser()
cy.visit('/today')
cy.get('#scheduled-nodes-list').should('exist')
})
it('home page links to the today page', () => {
cy.loginAsUser()
cy.visit('/home')
cy.get('a[href="/today"]').should('be.visible')
})
it('lists scheduled nodes for today', () => {
const today = new Date()
const year = today.getFullYear()
const month = String(today.getMonth() + 1).padStart(2, '0')
const day = String(today.getDate()).padStart(2, '0')
const todayString = year + '-' + month + '-' + day
cy.loginAsUser()
cy.request({
method: 'POST',
url: '/api/plans',
body: {
textId: 0,
name: 'My reading plan',
dateStart: todayString,
dateEnd: todayString,
},
})
cy.intercept('GET', '/api/scheduled-nodes*')
.as('getScheduledNodes')
cy.visit('/today')
cy.wait('@getScheduledNodes').then((interception) => {
expect(interception.request.url).to.include(
'date=' + todayString
)
})
cy.get('#scheduled-nodes-list').should(
'contain',
'My reading plan'
)
cy.get('#scheduled-nodes-list').should('contain', 'Bereishis')
})
it('shows an empty list when no nodes are scheduled today', () => {
cy.loginAsUser()
cy.intercept('GET', '/api/scheduled-nodes*')
.as('getScheduledNodes')
cy.visit('/today')
cy.wait('@getScheduledNodes')
cy.get('#scheduled-nodes-list li').should('have.length', 0)
})
})

33
public/js/today.js Normal file
View file

@ -0,0 +1,33 @@
document.addEventListener('DOMContentLoaded', () => {
const scheduledNodesList = document.getElementById(
'scheduled-nodes-list'
);
function todayDateString() {
const today = new Date();
const year = today.getFullYear();
const month = String(today.getMonth() + 1).padStart(2, '0');
const day = String(today.getDate()).padStart(2, '0');
return year + '-' + month + '-' + day;
}
async function loadScheduledNodes() {
const date = todayDateString();
const response = await fetch(
'/api/scheduled-nodes?date=' + date,
{ credentials: 'same-origin' }
);
if (!response.ok) {
return;
}
const scheduledNodes = await response.json();
scheduledNodesList.innerHTML = scheduledNodes
.map((scheduledNode) =>
'<li>' + scheduledNode.planName + ': ' +
scheduledNode.nodeTitle + '</li>'
)
.join('');
}
loadScheduledNodes();
});

View file

@ -5,9 +5,13 @@ namespace Tests\Fakes;
use App\Plan\CreatePlanDto;
use App\Plan\Plan;
use App\Plan\PlanRepository;
use App\User\User;
class FakePlanRepository implements PlanRepository
{
/**
* @var Plan[]
*/
private array $existingPlans = [];
public function create(CreatePlanDto $dto): Plan
@ -37,4 +41,24 @@ class FakePlanRepository implements PlanRepository
}
);
}
public function findByUser(User $user): array
{
$plans = array_filter(
$this->existingPlans,
function (Plan $plan) use ($user) {
return $plan->getUser()->getId() === $user->getId();
}
);
return array_map(
function (Plan $plan) {
return new Plan(
id: $plan->getId(),
name: $plan->getName(),
user: $plan->getUser(),
);
},
$plans
);
}
}

View file

@ -5,6 +5,7 @@ namespace Tests\Fakes;
use App\ScheduledNode\CreateScheduledNodeDto;
use App\ScheduledNode\ScheduledNode;
use App\ScheduledNode\ScheduledNodeRepository;
use App\User\User;
class FakeScheduledNodeRepository implements ScheduledNodeRepository
{
@ -20,12 +21,27 @@ class FakeScheduledNodeRepository implements ScheduledNodeRepository
id: $id,
date: $dto->date,
plan: $dto->plan,
node: $dto->node,
completed: false,
);
$this->existingScheduledNodes[$id] = $scheduledNode;
return $scheduledNode;
}
public function update(ScheduledNode $node): ScheduledNode
{
$this->existingScheduledNodes[$node->getId()] = $node;
return new ScheduledNode(
id: $node->getId(),
date: $node->getDate(),
plan: $node->getPlan(),
node: $node->getNode(),
completed: $node->getCompleted()
);
}
public function find(int $id): ?ScheduledNode
{
return array_find(
@ -45,4 +61,27 @@ class FakeScheduledNodeRepository implements ScheduledNodeRepository
{
return count($this->existingScheduledNodes);
}
public function findByUser(User $user): array
{
$scheduledNodes = array_filter(
$this->existingScheduledNodes,
function (ScheduledNode $node) use ($user) {
return $node->getPlan()->getUser()->getId() === $user->getId();
}
);
return array_map(
function (ScheduledNode $node) {
return new ScheduledNode(
id: $node->getId(),
date: $node->getDate(),
plan: $node->getPlan(),
node: $node->getNode(),
completed: $node->getCompleted(),
);
},
$scheduledNodes,
);
}
}

View file

@ -45,6 +45,7 @@ class CreatePlanTest extends TestCase
$this->createScheduledNode = new CreateScheduledNode(
scheduledNodeRepo: $this->scheduledNodeRepo,
planRepo: $this->planRepo,
nodeRepo: $this->nodeRepo,
);
$this->textRepo->create(new CreateTextDto('testname'));
$this->useCase = new CreatePlan(

View file

@ -3,15 +3,19 @@
namespace Tests\Unit\ScheduledNode\UseCases;
use App\Exceptions\BadRequestException;
use App\Node\CreateNodeDto;
use App\Node\Node;
use App\Plan\CreatePlanDto;
use App\Plan\Plan;
use App\ScheduledNode\ScheduledNode;
use App\ScheduledNode\ScheduledNodeRepository;
use App\ScheduledNode\UseCases\CreateScheduledNode;
use App\ScheduledNode\UseCases\CreateScheduledNodeRequest;
use App\Text\Text;
use App\User\User;
use App\ValueObjects\EmailAddress;
use DomainException;
use Tests\Fakes\FakeNodeRepository;
use Tests\Fakes\FakePlanRepository;
use Tests\Fakes\FakeScheduledNodeRepository;
use PHPUnit\Framework\TestCase;
@ -22,12 +26,20 @@ class CreateScheduledNodeTest extends TestCase
private FakePlanRepository $planRepo;
private FakeNodeRepository $nodeRepo;
private CreateScheduledNode $useCase;
public function setUp(): void
{
$this->scheduledNodeRepo = new FakeScheduledNodeRepository();
$this->planRepo = new FakePlanRepository();
$this->nodeRepo = new FakeNodeRepository();
$this->nodeRepo->create(new CreateNodeDto(
text: new Text(0, 'text name'),
title: 'test node',
parentNode: null,
));
$this->planRepo->create(new CreatePlanDto(
name: 'testplan',
user: new User(
@ -40,6 +52,7 @@ class CreateScheduledNodeTest extends TestCase
$this->useCase = new CreateScheduledNode(
$this->scheduledNodeRepo,
$this->planRepo,
$this->nodeRepo,
);
}
@ -49,6 +62,7 @@ class CreateScheduledNodeTest extends TestCase
new CreateScheduledNodeRequest(
date: '2025-01-01',
planId: 0,
nodeId: 0,
)
);
$this->assertInstanceOf(ScheduledNode::class, $scheduledNode);
@ -64,11 +78,24 @@ class CreateScheduledNodeTest extends TestCase
new CreateScheduledNodeRequest(
date: '2025-01-01',
planId: 0,
nodeId: 0
)
);
$this->assertInstanceOf(Plan::class, $scheduledNode->getPlan());
}
public function test_scheduled_node_belongs_to_node(): void
{
$scheduledNode = $this->useCase->execute(
new CreateScheduledNodeRequest(
date: '2025-01-01',
planId: 0,
nodeId: 0
)
);
$this->assertInstanceOf(Node::class, $scheduledNode->getNode());
}
public function test_nonexistant_plan_throws(): void
{
$this->expectException(DomainException::class);
@ -77,6 +104,20 @@ class CreateScheduledNodeTest extends TestCase
new CreateScheduledNodeRequest(
date: '2025-01-01',
planId: 1,
nodeId: 0,
)
);
}
public function test_nonexistant_node_throws(): void
{
$this->expectException(DomainException::class);
$this->expectExceptionMessage('Node with id: 1 doesnt exist');
$this->useCase->execute(
new CreateScheduledNodeRequest(
date: '2025-01-01',
planId: 0,
nodeId: 1,
)
);
}
@ -90,6 +131,7 @@ class CreateScheduledNodeTest extends TestCase
new CreateScheduledNodeRequest(
date: null,
planId: 0,
nodeId: 0
)
);
}
@ -103,6 +145,21 @@ class CreateScheduledNodeTest extends TestCase
new CreateScheduledNodeRequest(
date: '2025-01-01',
planId: null,
nodeId: 0,
)
);
}
public function test_throws_if_node_id_is_null(): void
{
$this->expectException(BadRequestException::class);
$this->expectExceptionMessage('nodeId is required');
$this->useCase->execute(
new CreateScheduledNodeRequest(
date: '2025-01-01',
planId: 0,
nodeId: null,
)
);
}

View file

@ -0,0 +1,222 @@
<?php
namespace Tests\Unit\ScheduledNode\UseCases;
use App\Exceptions\BadRequestException;
use App\Node\Node;
use App\Plan\CreatePlanDto;
use App\ScheduledNode\CreateScheduledNodeDto;
use App\ScheduledNode\ScheduledNode;
use App\ScheduledNode\UseCases\GetTodaysSchedule;
use App\ScheduledNode\UseCases\GetTodaysScheduleRequest;
use App\Text\Text;
use App\User\UseCases\CreateUserDto;
use App\ValueObjects\EmailAddress;
use DateTimeImmutable;
use DomainException;
use PHPUnit\Framework\TestCase;
use Tests\Fakes\FakePlanRepository;
use Tests\Fakes\FakeScheduledNodeRepository;
use Tests\Fakes\FakeUserRepository;
class GetTodaysScheduleTest extends TestCase
{
private FakeUserRepository $userRepo;
private FakePlanRepository $planRepo;
private FakeScheduledNodeRepository $scheduledNodeRepo;
private GetTodaysSchedule $useCase;
protected function setUp(): void
{
$this->userRepo = new FakeUserRepository();
$this->scheduledNodeRepo = new FakeScheduledNodeRepository();
$this->planRepo = new FakePlanRepository();
$user = $this->userRepo->create(new CreateUserDto(
email: new EmailAddress('email@email.com'),
passwordHash: 'hash',
isAdmin: false,
));
$plan = $this->planRepo->create(new CreatePlanDto(
name: 'test plan',
user: $user,
));
$this->scheduledNodeRepo->create(new CreateScheduledNodeDto(
date: new DateTimeImmutable('2025-01-02'),
plan: $plan,
node: new Node(
id: 0,
title: 'test node',
text: new Text(id: 0, name: 'test text'),
parentNode: null,
),
));
$this->useCase = new GetTodaysSchedule(
userRepo: $this->userRepo,
scheduledNodeRepo: $this->scheduledNodeRepo,
);
}
public function test_returns_array_of_scheduled_nodes(): void
{
$result = $this->useCase->execute(new GetTodaysScheduleRequest(
date: '2025-01-02',
userId: 0,
));
$this->assertIsArray($result);
$this->assertInstanceOf(ScheduledNode::class, $result[0]);
}
public function test_returns_all_unfinished_scheduled_nodes_up_until_today(): void
{
$this->scheduledNodeRepo->create(new CreateScheduledNodeDto(
date: new DateTimeImmutable('2025-01-01'),
plan: $this->planRepo->find(0),
node: new Node(
id: 0,
title: 'test node',
text: new Text(id: 0, name: 'test text'),
parentNode: null,
),
));
$result = $this->useCase->execute(new GetTodaysScheduleRequest(
date: '2025-01-02',
userId: 0,
));
$this->assertEquals(2, count($result));
}
public function test_only_returns_uncompleted_nodes(): void
{
$node = $this->scheduledNodeRepo->create(
new CreateScheduledNodeDto(
date: new DateTimeImmutable('2025-01-01'),
plan: $this->planRepo->find(0),
node: new Node(
id: 0,
title: 'test node',
text: new Text(id: 0, name: 'test text'),
parentNode: null,
),
)
);
$node->setCompleted(true);
$this->scheduledNodeRepo->update($node);
$result = $this->useCase->execute(new GetTodaysScheduleRequest(
date: '2025-01-02',
userId: 0,
));
$this->assertEquals(1, count($result));
}
public function test_throws_if_date_is_null(): void
{
$this->expectException(BadRequestException::class);
$this->expectExceptionMessage('date is required');
$this->useCase->execute(new GetTodaysScheduleRequest(
date: null,
userId: 0,
));
}
public function test_throws_if_user_id_is_null(): void
{
$this->expectException(BadRequestException::class);
$this->expectExceptionMessage('userId is required');
$this->useCase->execute(new GetTodaysScheduleRequest(
date: '2025-01-02',
userId: null,
));
}
public function test_nonexistant_user_throws(): void
{
$this->expectException(DomainException::class);
$this->expectExceptionMessage('User with id: 99 doesnt exist');
$this->useCase->execute(new GetTodaysScheduleRequest(
date: '2025-01-02',
userId: 99,
));
}
public function test_returns_empty_array_when_user_has_no_scheduled_nodes(): void
{
$otherUser = $this->userRepo->create(new CreateUserDto(
email: new EmailAddress('other@email.com'),
passwordHash: 'hash',
isAdmin: false,
));
$result = $this->useCase->execute(new GetTodaysScheduleRequest(
date: '2025-01-02',
userId: $otherUser->getId(),
));
$this->assertIsArray($result);
$this->assertEquals(0, count($result));
}
public function test_excludes_scheduled_nodes_dated_after_today(): void
{
$this->scheduledNodeRepo->create(new CreateScheduledNodeDto(
date: new DateTimeImmutable('2025-01-05'),
plan: $this->planRepo->find(0),
node: new Node(
id: 0,
title: 'future node',
text: new Text(id: 0, name: 'test text'),
parentNode: null,
),
));
$result = $this->useCase->execute(new GetTodaysScheduleRequest(
date: '2025-01-02',
userId: 0,
));
$this->assertEquals(1, count($result));
}
public function test_does_not_return_other_users_scheduled_nodes(): void
{
$otherUser = $this->userRepo->create(new CreateUserDto(
email: new EmailAddress('other@email.com'),
passwordHash: 'hash',
isAdmin: false,
));
$otherPlan = $this->planRepo->create(new CreatePlanDto(
name: 'other plan',
user: $otherUser,
));
$this->scheduledNodeRepo->create(new CreateScheduledNodeDto(
date: new DateTimeImmutable('2025-01-02'),
plan: $otherPlan,
node: new Node(
id: 0,
title: 'other node',
text: new Text(id: 0, name: 'test text'),
parentNode: null,
),
));
$result = $this->useCase->execute(new GetTodaysScheduleRequest(
date: '2025-01-02',
userId: 0,
));
$this->assertEquals(1, count($result));
$resultNode = array_values($result)[0];
$this->assertEquals(
0,
$resultNode->getPlan()->getUser()->getId()
);
}
}

View file

@ -55,6 +55,7 @@ class PlanControllerTest extends TestCase
$createScheduledNode = new CreateScheduledNode(
scheduledNodeRepo: $this->scheduledNodeRepo,
planRepo: $this->planRepo,
nodeRepo: $this->nodeRepo,
);
$this->createPlan = new CreatePlan(
$this->planRepo,

View file

@ -0,0 +1,252 @@
<?php
namespace Tests\e2e\Controllers;
use App\Node\Node;
use App\Plan\CreatePlanDto;
use App\ScheduledNode\CreateScheduledNodeDto;
use App\ScheduledNode\ScheduledNodeController;
use App\ScheduledNode\UseCases\GetTodaysSchedule;
use App\Text\Text;
use App\User\UseCases\CreateUserDto;
use App\User\User;
use App\ValueObjects\EmailAddress;
use DateTimeImmutable;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\ServerRequestInterface;
use Slim\Psr7\Factory\ServerRequestFactory;
use Slim\Psr7\Response;
use Tests\Fakes\FakePlanRepository;
use Tests\Fakes\FakeScheduledNodeRepository;
use Tests\Fakes\FakeUserRepository;
class ScheduledNodeControllerTest extends TestCase
{
private FakeUserRepository $userRepo;
private FakePlanRepository $planRepo;
private FakeScheduledNodeRepository $scheduledNodeRepo;
private GetTodaysSchedule $getTodaysSchedule;
private ScheduledNodeController $controller;
private User $user;
public function setUp(): void
{
$this->userRepo = new FakeUserRepository();
$this->planRepo = new FakePlanRepository();
$this->scheduledNodeRepo = new FakeScheduledNodeRepository();
$this->user = $this->userRepo->create(new CreateUserDto(
email: new EmailAddress('test@test.com'),
passwordHash: '',
isAdmin: false,
));
$this->getTodaysSchedule = new GetTodaysSchedule(
userRepo: $this->userRepo,
scheduledNodeRepo: $this->scheduledNodeRepo,
);
$this->controller = new ScheduledNodeController();
}
private function makeRequest(
?string $date,
?User $user,
): ServerRequestInterface {
$request = new ServerRequestFactory()
->createServerRequest(
'GET',
'http://localhost/api/scheduled-nodes'
);
if ($user !== null) {
$request = $request->withAttribute('user', $user);
}
if ($date !== null) {
$request = $request->withQueryParams(['date' => $date]);
}
return $request;
}
private function seedScheduledNode(
User $user,
string $date,
string $planName,
string $nodeTitle,
): void {
$plan = $this->planRepo->create(new CreatePlanDto(
name: $planName,
user: $user,
));
$this->scheduledNodeRepo->create(new CreateScheduledNodeDto(
date: new DateTimeImmutable($date),
plan: $plan,
node: new Node(
id: 0,
title: $nodeTitle,
text: new Text(id: 0, name: 'test text'),
parentNode: null,
),
));
}
public function test_returns_200_with_scheduled_nodes_for_user(): void
{
$this->seedScheduledNode(
$this->user,
'2025-01-02',
'My reading plan',
'Bereishis',
);
$response = $this->controller->getScheduledNodes(
$this->makeRequest('2025-01-02', $this->user),
new Response(),
$this->getTodaysSchedule,
);
$this->assertEquals(200, $response->getStatusCode());
$body = json_decode((string) $response->getBody(), true);
$this->assertIsArray($body);
$this->assertCount(1, $body);
}
public function test_response_has_expected_fields(): void
{
$this->seedScheduledNode(
$this->user,
'2025-01-02',
'My reading plan',
'Bereishis',
);
$response = $this->controller->getScheduledNodes(
$this->makeRequest('2025-01-02', $this->user),
new Response(),
$this->getTodaysSchedule,
);
$body = json_decode((string) $response->getBody(), true);
$this->assertArrayHasKey('id', $body[0]);
$this->assertArrayHasKey('date', $body[0]);
$this->assertEquals('2025-01-02', $body[0]['date']);
$this->assertEquals('My reading plan', $body[0]['planName']);
$this->assertEquals('Bereishis', $body[0]['nodeTitle']);
$this->assertEquals(false, $body[0]['completed']);
}
public function test_returns_401_when_no_user_attribute(): void
{
$response = $this->controller->getScheduledNodes(
$this->makeRequest('2025-01-02', null),
new Response(),
$this->getTodaysSchedule,
);
$this->assertEquals(401, $response->getStatusCode());
}
public function test_returns_400_when_date_query_param_missing(): void
{
$response = $this->controller->getScheduledNodes(
$this->makeRequest(null, $this->user),
new Response(),
$this->getTodaysSchedule,
);
$this->assertEquals(400, $response->getStatusCode());
$body = json_decode((string) $response->getBody(), true);
$this->assertArrayHasKey('error', $body);
$this->assertEquals('date is required', $body['error']);
}
public function test_returns_400_when_date_query_param_empty_string(): void
{
$response = $this->controller->getScheduledNodes(
$this->makeRequest('', $this->user),
new Response(),
$this->getTodaysSchedule,
);
$this->assertEquals(400, $response->getStatusCode());
$body = json_decode((string) $response->getBody(), true);
$this->assertArrayHasKey('error', $body);
$this->assertEquals('date is required', $body['error']);
}
public function test_excludes_future_scheduled_nodes(): void
{
$this->seedScheduledNode(
$this->user,
'2025-01-10',
'My reading plan',
'Bereishis',
);
$response = $this->controller->getScheduledNodes(
$this->makeRequest('2025-01-02', $this->user),
new Response(),
$this->getTodaysSchedule,
);
$body = json_decode((string) $response->getBody(), true);
$this->assertCount(0, $body);
}
public function test_excludes_completed_scheduled_nodes(): void
{
$this->seedScheduledNode(
$this->user,
'2025-01-02',
'My reading plan',
'Bereishis',
);
$stored = $this->scheduledNodeRepo->find(0);
$stored->setCompleted(true);
$this->scheduledNodeRepo->update($stored);
$response = $this->controller->getScheduledNodes(
$this->makeRequest('2025-01-02', $this->user),
new Response(),
$this->getTodaysSchedule,
);
$body = json_decode((string) $response->getBody(), true);
$this->assertCount(0, $body);
}
public function test_returns_empty_array_when_user_has_no_nodes(): void
{
$response = $this->controller->getScheduledNodes(
$this->makeRequest('2025-01-02', $this->user),
new Response(),
$this->getTodaysSchedule,
);
$this->assertEquals(200, $response->getStatusCode());
$body = json_decode((string) $response->getBody(), true);
$this->assertEquals([], $body);
}
public function test_returns_404_when_use_case_throws_domain_exception(): void
{
$unknownUser = new User(
id: 999,
email: new EmailAddress('ghost@test.com'),
passwordHash: '',
isAdmin: false,
);
$response = $this->controller->getScheduledNodes(
$this->makeRequest('2025-01-02', $unknownUser),
new Response(),
$this->getTodaysSchedule,
);
$this->assertEquals(404, $response->getStatusCode());
$body = json_decode((string) $response->getBody(), true);
$this->assertArrayHasKey('error', $body);
$this->assertEquals(
'User with id: 999 doesnt exist',
$body['error']
);
}
}

View file

@ -6,6 +6,7 @@
<body>
<h1>Home</h1>
<button id="logout">Logout</button>
<a href="/today">Today's schedule</a>
<ul id="texts-list">
</ul>
<div id="create-plan-modal" hidden>

13
views/templates/today.php Normal file
View file

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html>
<head>
<title>Daily Goals - Today</title>
</head>
<body>
<h1>Today</h1>
<ul id="scheduled-nodes-list">
</ul>
<script src="/js/auth.js"></script>
<script src="/js/today.js"></script>
</body>
</html>