Merge branch 'today-scheduled-nodes-page'
This commit is contained in:
commit
38d72ba1a8
36 changed files with 1186 additions and 138 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -2,3 +2,5 @@ vendor/
|
|||
node_modules/
|
||||
data/*.json
|
||||
.direnv/
|
||||
cypress/screenshots/
|
||||
cypress/videos/
|
||||
7
.opencode/opencode.json
Normal file
7
.opencode/opencode.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"plugin": [
|
||||
"caveman",
|
||||
"caveman-opencode-plugin@latest"
|
||||
]
|
||||
}
|
||||
7
AGENTS.md
Normal file
7
AGENTS.md
Normal 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
|
||||
|
|
@ -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
45
ai/backend-context.md
Normal 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).
|
||||
|
|
@ -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
33
ai/frontend-context.md
Normal 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`.
|
||||
|
|
@ -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
74
ai/shared.md
Normal 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.
|
||||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ class CreatePlan
|
|||
new CreateScheduledNodeRequest(
|
||||
date: $scheduledDate->format('Y-m-d'),
|
||||
planId: $plan->getId(),
|
||||
nodeId: $node->getId(),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
) {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
73
app/ScheduledNode/ScheduledNodeController.php
Normal file
73
app/ScheduledNode/ScheduledNodeController.php
Normal 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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,5 +7,6 @@ class CreateScheduledNodeRequest
|
|||
public function __construct(
|
||||
public ?string $date,
|
||||
public ?int $planId,
|
||||
public ?int $nodeId,
|
||||
) {}
|
||||
}
|
||||
|
|
|
|||
51
app/ScheduledNode/UseCases/GetTodaysSchedule.php
Normal file
51
app/ScheduledNode/UseCases/GetTodaysSchedule.php
Normal 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;
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
11
app/ScheduledNode/UseCases/GetTodaysScheduleRequest.php
Normal file
11
app/ScheduledNode/UseCases/GetTodaysScheduleRequest.php
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<?php
|
||||
|
||||
namespace App\ScheduledNode\UseCases;
|
||||
|
||||
class GetTodaysScheduleRequest
|
||||
{
|
||||
public function __construct(
|
||||
public ?string $date,
|
||||
public ?int $userId,
|
||||
) {}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
9
caveman.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"enabled": true,
|
||||
"defaultMode": "full",
|
||||
"features": {
|
||||
"caveman": true,
|
||||
"commit": ,
|
||||
"review": true
|
||||
}
|
||||
}
|
||||
75
cypress/e2e/today.cy.js
Normal file
75
cypress/e2e/today.cy.js
Normal 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
33
public/js/today.js
Normal 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();
|
||||
});
|
||||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
222
tests/Unit/ScheduledNode/UseCases/GetTodaysScheduleTest.php
Normal file
222
tests/Unit/ScheduledNode/UseCases/GetTodaysScheduleTest.php
Normal 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
252
tests/e2e/Controllers/ScheduledNodeControllerTest.php
Normal file
252
tests/e2e/Controllers/ScheduledNodeControllerTest.php
Normal 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']
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
13
views/templates/today.php
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue