diff --git a/.gitignore b/.gitignore
index 68bd285..f8b367e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,6 @@
vendor/
node_modules/
data/*.json
-.direnv/
\ No newline at end of file
+.direnv/
+cypress/screenshots/
+cypress/videos/
\ No newline at end of file
diff --git a/.opencode/opencode.json b/.opencode/opencode.json
new file mode 100644
index 0000000..e2342fc
--- /dev/null
+++ b/.opencode/opencode.json
@@ -0,0 +1,7 @@
+{
+ "$schema": "https://opencode.ai/config.json",
+ "plugin": [
+ "caveman",
+ "caveman-opencode-plugin@latest"
+ ]
+}
\ No newline at end of file
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..5dea88b
--- /dev/null
+++ b/AGENTS.md
@@ -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
diff --git a/DailyGoals.drawio b/DailyGoals.drawio
index b0937cb..aa76945 100644
--- a/DailyGoals.drawio
+++ b/DailyGoals.drawio
@@ -16,11 +16,14 @@
+
+
+
-
+
@@ -28,14 +31,14 @@
-
-
+
+
-
+
-
+
diff --git a/ai/backend-context.md b/ai/backend-context.md
new file mode 100644
index 0000000..fca41f0
--- /dev/null
+++ b/ai/backend-context.md
@@ -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).
diff --git a/ai/backend_prompt_template.md b/ai/backend_prompt_template.md
deleted file mode 100644
index c16ef26..0000000
--- a/ai/backend_prompt_template.md
+++ /dev/null
@@ -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.
diff --git a/ai/frontend-context.md b/ai/frontend-context.md
new file mode 100644
index 0000000..8757e1f
--- /dev/null
+++ b/ai/frontend-context.md
@@ -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/.php`, one file per page
+- **Page JS:** `public/js/.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`.
diff --git a/ai/frontend_prompt_template.md b/ai/frontend_prompt_template.md
deleted file mode 100644
index a865577..0000000
--- a/ai/frontend_prompt_template.md
+++ /dev/null
@@ -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.
diff --git a/ai/shared.md b/ai/shared.md
new file mode 100644
index 0000000..cc85dd1
--- /dev/null
+++ b/ai/shared.md
@@ -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 ` / `lint ` 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.
diff --git a/app/Plan/JsonPlanRepository.php b/app/Plan/JsonPlanRepository.php
index 8a88838..8c73d8a 100644
--- a/app/Plan/JsonPlanRepository.php
+++ b/app/Plan/JsonPlanRepository.php
@@ -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
+ );
+ }
}
diff --git a/app/Plan/PlanRepository.php b/app/Plan/PlanRepository.php
index a962c0f..0585edc 100644
--- a/app/Plan/PlanRepository.php
+++ b/app/Plan/PlanRepository.php
@@ -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;
}
diff --git a/app/Plan/UseCases/CreatePlan.php b/app/Plan/UseCases/CreatePlan.php
index b12fc50..8a3cc91 100644
--- a/app/Plan/UseCases/CreatePlan.php
+++ b/app/Plan/UseCases/CreatePlan.php
@@ -64,6 +64,7 @@ class CreatePlan
new CreateScheduledNodeRequest(
date: $scheduledDate->format('Y-m-d'),
planId: $plan->getId(),
+ nodeId: $node->getId(),
)
);
}
diff --git a/app/ScheduledNode/CreateScheduledNodeDto.php b/app/ScheduledNode/CreateScheduledNodeDto.php
index 5e7a695..086c975 100644
--- a/app/ScheduledNode/CreateScheduledNodeDto.php
+++ b/app/ScheduledNode/CreateScheduledNodeDto.php
@@ -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,
) {}
}
diff --git a/app/ScheduledNode/JsonScheduledNodeRepository.php b/app/ScheduledNode/JsonScheduledNodeRepository.php
index bee31ea..1870844 100644
--- a/app/ScheduledNode/JsonScheduledNodeRepository.php
+++ b/app/ScheduledNode/JsonScheduledNodeRepository.php
@@ -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
+ );
+ }
}
diff --git a/app/ScheduledNode/ScheduledNode.php b/app/ScheduledNode/ScheduledNode.php
index 7d479db..83f29ef 100644
--- a/app/ScheduledNode/ScheduledNode.php
+++ b/app/ScheduledNode/ScheduledNode.php
@@ -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;
+ }
}
diff --git a/app/ScheduledNode/ScheduledNodeController.php b/app/ScheduledNode/ScheduledNodeController.php
new file mode 100644
index 0000000..3a71ccb
--- /dev/null
+++ b/app/ScheduledNode/ScheduledNodeController.php
@@ -0,0 +1,73 @@
+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');
+ }
+}
diff --git a/app/ScheduledNode/ScheduledNodeRepository.php b/app/ScheduledNode/ScheduledNodeRepository.php
index b13b7c5..055a939 100644
--- a/app/ScheduledNode/ScheduledNodeRepository.php
+++ b/app/ScheduledNode/ScheduledNodeRepository.php
@@ -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;
}
diff --git a/app/ScheduledNode/UseCases/CreateScheduledNode.php b/app/ScheduledNode/UseCases/CreateScheduledNode.php
index 752b658..444f4d0 100644
--- a/app/ScheduledNode/UseCases/CreateScheduledNode.php
+++ b/app/ScheduledNode/UseCases/CreateScheduledNode.php
@@ -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,
)
);
}
diff --git a/app/ScheduledNode/UseCases/CreateScheduledNodeRequest.php b/app/ScheduledNode/UseCases/CreateScheduledNodeRequest.php
index 5931cb3..4c0a2fd 100644
--- a/app/ScheduledNode/UseCases/CreateScheduledNodeRequest.php
+++ b/app/ScheduledNode/UseCases/CreateScheduledNodeRequest.php
@@ -7,5 +7,6 @@ class CreateScheduledNodeRequest
public function __construct(
public ?string $date,
public ?int $planId,
+ public ?int $nodeId,
) {}
}
diff --git a/app/ScheduledNode/UseCases/GetTodaysSchedule.php b/app/ScheduledNode/UseCases/GetTodaysSchedule.php
new file mode 100644
index 0000000..3301549
--- /dev/null
+++ b/app/ScheduledNode/UseCases/GetTodaysSchedule.php
@@ -0,0 +1,51 @@
+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;
+ }
+ );
+ }
+}
diff --git a/app/ScheduledNode/UseCases/GetTodaysScheduleRequest.php b/app/ScheduledNode/UseCases/GetTodaysScheduleRequest.php
new file mode 100644
index 0000000..ccb38b8
--- /dev/null
+++ b/app/ScheduledNode/UseCases/GetTodaysScheduleRequest.php
@@ -0,0 +1,11 @@
+getBody()->write($html);
+
+ return $response;
+ }
+
public function login(Response $response): Response
{
$html = file_get_contents(
diff --git a/bootstrap/app.php b/bootstrap/app.php
index c05dc0f..b4e9907 100644
--- a/bootstrap/app.php
+++ b/bootstrap/app.php
@@ -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
diff --git a/bootstrap/container.php b/bootstrap/container.php
index 2ae6e4c..02666ed 100644
--- a/bootstrap/container.php
+++ b/bootstrap/container.php
@@ -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),
diff --git a/caveman.json b/caveman.json
new file mode 100644
index 0000000..fb19545
--- /dev/null
+++ b/caveman.json
@@ -0,0 +1,9 @@
+{
+ "enabled": true,
+ "defaultMode": "full",
+ "features": {
+ "caveman": true,
+ "commit": ,
+ "review": true
+ }
+}
diff --git a/cypress/e2e/today.cy.js b/cypress/e2e/today.cy.js
new file mode 100644
index 0000000..1f24450
--- /dev/null
+++ b/cypress/e2e/today.cy.js
@@ -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)
+ })
+})
diff --git a/public/js/today.js b/public/js/today.js
new file mode 100644
index 0000000..6f14627
--- /dev/null
+++ b/public/js/today.js
@@ -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) =>
+ '