This document describes how the codebase is currently structured and why. It is not a rulebook - it is context. When something deviates from these patterns, that is worth a conversation, not necessarily a blocker.
Read docs/architecture.md first to understand the system structure.
Right-sized, not over-engineered.
Add abstraction when it removes real duplication or makes something genuinely clearer. The codebase should be understandable by reading it top to bottom. When in doubt, prefer the simpler approach and evolve later.
- Controllers handle HTTP concerns: routing, input validation, response codes.
- Controllers call
DbContextdirectly - no repository or service layer yet. - Request bodies use C#
recordtypes (immutable, concise). - All database operations are
async/await. - Route constraints enforce types at the URL level (e.g.
{id:int}). - Timestamps are stored in UTC (
DateTime.UtcNow).
- EF Core eager loading with
.Include()- used inTasksControllerto loadLabelsalongside tasks. All task queries use.Include(t => t.Labels)so callers always receive a populatedlabelsarray. Use the same pattern for any future navigation property that is always needed with the parent entity. - Many-to-many via implicit join table -
TaskModelandLabeluse EF Core'sHasMany/WithManyto configure a join table (LabelTaskModel) without an explicit join entity. Cascade deletes on the join table are handled automatically by the database.
JsonElementpartial PATCH (present-key detection) -TasksController.Update(PATCH /api/tasks/{id}) binds the body to[FromBody] JsonElementand inspects each field withTryGetProperty, rather than binding to a typed record. This is the only way to distinguish "field omitted" (keep the current value) from "field set tonull" (clear it). A typed record with nullable properties collapses both cases tonull, so it cannot express a clear-vs-keep difference. Reach for this whenever a PATCH needs nullable-clear semantics. Unlike query-string array binding, the present-key logic is directly unit-testable: build aJsonElementfrom a JSON string withJsonDocument.Parse(json).RootElement.Clone()and pass it to the action, which exercises the same branching the HTTP body would.
- Midnight = "date-only" sentinel for deadlines -
Deadlineis a singleDateTime, but a value at exactly00:00:00is treated as a date-only deadline (no specific time), while any non-midnight time is a real moment.ComputeDueStatusbranches ondeadline.TimeOfDay != TimeSpan.Zerofor the "overdue" rule (timed = past the instant; date-only = past the calendar day). This avoids a separateHasTimecolumn. Trade-off: a literal midnight deadline behaves as date-only - acceptable since "due at exactly 00:00" is rare and "end of day" is the sensible reading. The frontend mirrors this withhasTimeComponent(iso)(checks theHH:mmsubstring, timezone-safe).
- Non-zero column defaults go in
OnModelCreating, not just the property initializer -TaskModel.Prioritydefaults to 4 (P4 = none). EF Core derives a migration column's SQL default from the CLR default of the type (0 forint), NOT from a C# property initializer (= 4). Setting only the initializer means existing rows migrate to 0 (an invalid out-of-range value here). Configure the DB default explicitly:modelBuilder.Entity<TaskModel>().Property(t => t.Priority).HasDefaultValue(4), then generate the migration - it emitsdefaultValue: 4and existing rows migrate correctly. Always verify the generated migration'sdefaultValuefor any non-zero default. (See learnings/orm-migration-default-values.md.)
- Computed response field via
[NotMapped]getter -TaskModel.DueStatusis a read-only property ([NotMapped] public string DueStatus => ComputeDueStatus(Deadline, DateTime.Today);) rather than a stored column or a DTO field.[NotMapped](fromSystem.ComponentModel.DataAnnotations.Schema) tells EF Core to ignore it for mapping/migrations, while System.Text.Json still serializes the getter. The effect: the field appears on every response that returns the model, with zero per-action wiring and no schema change, and it is always computed fresh (so a value relative to "now" never goes stale). Use this for derived, response-only fields that have no business being persisted. Pair it with a pure static helper that takes its time-dependency as a parameter (ComputeDueStatus(deadline, today)) so the logic is unit-testable without freezing the clock - the instance getter just wires inDateTime.Today. Keeps the project's no-DTO convention intact.
- Pure domain helper in
Services/(not a DI service layer) - recurrence logic lives inServices/RecurrenceRule.cs: a static class that parses/validates an RRULE-shaped string and computes the next deadline (NextDeadline(current)). It is the first file underServices/, but it is deliberately NOT the "service layer" pattern below - no DI, no injectedDbContext, no instance state. It is the same shape asTaskModel.ComputeDueStatus: a pure, parameterized function the controller wires in (CompletecallsRecurrenceRule.TryParse(...).NextDeadline(deadline)). Because it is clock-free (it advances from the passed-in deadline, neverDateTime.Now), it sidesteps theDateTime.Now/UtcNowdeviation (#18) and is trivially unit-testable. Reach for this - a static helper underServices/- when logic is non-trivial and non-HTTP but still stateless and pure; reserve a true injected service (below) for when it needs theDbContextor other dependencies.
- Optional-
Includeon a list endpoint driven by a query flag -GET /api/tasks?includeSubtasks=trueconditionally.Include(t => t.Subtasks.OrderBy(...))so the web gets the full rows for its inline checklist, while the default (MCP) path stays lean and only stitches on two grouped-count fields (subtaskCount/completedSubtaskCount). The counts are[NotMapped]settable properties the controller fills (from the loaded collection when included, from a grouped query otherwise) - the same "computed response field" idea asDueStatus, but populated by the controller rather than a pure getter because the source isn't always loaded. Reach for a flag-gatedIncludewhen one caller needs a heavy nav and another does not, rather than always loading it or splitting the endpoint. - One shared inline component across three layouts -
SubtaskChecklistrenders the same clubbed, tickable list under a task on the mobile card, the desktop table (as a full-width sub-row via a keyedFragment+colSpan), and the board card. Keeping it one component (with amax+ "+N more" prop) meant the "tick a step" gesture reads identically everywhere and there is one place to change. When the same affordance must appear in structurally different containers (adivcard vs a<td>), lift it into a layout-agnostic component and let each host wrap it. @dnd-kitfor drag-reorder (first drag-and-drop dependency) -SubtaskSectionuses@dnd-kit/core+/sortable+/utilitiesfor touch-friendly subtask reordering (aPointerSensorwith a small activation distance + aKeyboardSensor). Admitted under the same "clear-benefit dependency" rule that allowedchrono-node: reorder-on-touch is fiddly and error-prone hand-rolled, and the phone is a first-class client. Reorder is optimistic -arrayMovelocally, POST the new id order, revert to the pre-drag snapshot on failure.DateTime.Now(notUtcNow) followed deliberately - subtask timestamps/comparisons useDateTime.Nowto match the surrounding controllers (the open #18 deviation), so a subtask's dates behave identically to its parent's rather than introducing a mixed-clock inconsistency inside one feature.
- JSON-as-TEXT columns -
JournalTemplate.SectionsJson,JournalEntry.ContentJson, andMoodCheckin.WordsJsonstore JSON as plainTEXT, serialized withSystem.Text.Json(NOT EF Core'sToJsonowned-entity mapping). Deliberate: the content is opaque to SQL by design, and this is the simplest understandable form for the codebase's first JSON columns. The rule that keeps it honest: anything you need to QUERY (energy, MoC level, dates) gets a real column; the JSON blob is for shape-flexible content only. - Paired cross-language contract - the journal's content shapes exist twice on purpose:
frontend/src/lib/journal.ts(the client contract) andServices/JournalMarkdown.cs(the renderer mirror). Plan bucket keys and evening field keys are duplicated verbatim in both. When touching either side, change both together - the files reference each other in comments. - Startup upsert for code-defined rows - journal templates are constants in
Services/JournalTemplates.cs, upserted into their table byKeyin Program.cs afterDatabase.Migrate(). Editing a definition updates the row on next run with no migration. Reach for this when data is code-owned but tables/FKs still want real rows. - Page-scoped theme tokens - the journal ships its own identity as
--color-j-*variables beside the app tokens inglobals.css(light +html.darkoverrides). Components usebg-j-card/text-j-inketc. This scopes a distinct look to one section without forking the theming mechanism; promoting it app-wide later is a token swap, not a rewrite. react-markdownfor display-only markdown - admitted under the clear-benefit dependency rule. Markdown is GENERATED backend-side (Services/JournalMarkdown.cs, a pure static helper - the RecurrenceRule shape); the frontend never parses or round-trips it. No Markdig: we only write markdown, never read it.
Repository pattern - not used currently. DbContext is injected directly into controllers. Worth considering if the data access layer needs to be swapped out or tested in isolation.
Service layer - not used currently. Business logic (minimal right now) lives in the controller. Worth introducing when controller actions contain logic that is not HTTP-specific and is shared across more than one action.
AutoMapper / object mapping - not used. Models are simple enough to map by hand. Worth considering if the gap between API response shapes and database models grows significantly.
Global exception handling middleware - not used. Controllers return explicit error responses. Worth adding when error handling becomes repetitive across many endpoints.
| Situation | Code |
|---|---|
| Success with data | 200 OK |
| Created | 201 Created (with CreatedAtAction) |
| Success, nothing to return | 204 No Content |
| Invalid input | 400 Bad Request |
| Not found | 404 Not Found |
Default to Server Components. Add "use client" when the component needs:
useStateoruseEffect- Browser event handlers (onClick, onChange, onSubmit)
- Browser-only APIs
Current component breakdown:
| Component | Type | Reason |
|---|---|---|
layout.tsx |
Server | Static shell, no interaction |
page.tsx |
Server | Renders client component, no state |
tasks/[id]/page.tsx |
Server | Fetches task and projects, no interaction |
ProjectLayout.tsx |
Client | Owns activeView and projects state, handles project CRUD |
ProjectSidebar.tsx |
Client | Project nav with create/rename/delete interactions |
TasksClient.tsx |
Client | Owns task list state, handles mutations, filters by activeView |
AddTaskForm.tsx |
Client | Controlled inputs, form submission, project dropdown |
AssignProjectButton.tsx |
Client | Select element, PATCH on change, router.refresh() |
DeleteTaskButton.tsx |
Client | Click handler, redirect |
CompleteTaskButton.tsx |
Client | Click handler, router.refresh() (no redirect) |
Fetch calls currently live in src/lib/api.ts. This keeps the API contract
in one place and makes it easy to see what the frontend depends on.
If a new pattern is needed (e.g. React Query, SWR), that is a good conversation
to have before adding ad-hoc fetch calls elsewhere.
- One component, one job as a starting point.
- Components that manage data should not also own complex layout - but use judgement.
- Error and loading states should always be handled. A blank screen is never intentional.
- Tailwind utility classes are the current approach.
- The default Tailwind colour scale maps to the UI spec - see
UI-SPEC.md. - Arbitrary values (e.g.
w-[137px]) are a signal to check if a scale value would work instead. - Responsive: mobile-first.
sm:prefix for desktop variants.
Reusable hooks live in src/hooks/. Current hooks:
| Hook | Purpose |
|---|---|
usePolling(fetchFn, intervalMs, enabled) |
Background data refresh on a timer. Pauses when the tab is hidden (Page Visibility API) and when enabled is false (e.g. during in-flight operations). Fires an immediate fetch when the tab becomes visible again. Used in TasksClient, ProjectLayout, and LabelsClient. |
When adding a new hook, place it in src/hooks/ and follow the same pattern: accept a callback, an interval or config, and an enabled flag for conditional execution.
When the same logic appears in more than one place, extracting it to src/lib/
is worth considering. Current candidates: formatDate, deadlineColorClass (issue #5).
- First frontend runtime dependency:
chrono-node(MIT) for natural-language date parsing in quick-add (lib/quickAdd.ts). The project's "avoid unnecessary frameworks" rule explicitly allows a clear-benefit dependency, and free-form date NL ("next friday", "jan 27", "in 3 days") is the textbook don't-reinvent case - chrono also returns the matched span, which the highlight overlay and title-stripping need. Recurrence and the#/@/pNtokens are still hand-rolled (bounded grammar we control), so the dependency is scoped to dates only.quickAdd.tsitself is a pure function, mirroring theformat.tsprecedent. - Backdrop-overlay highlighting over a normal
<input>(QuickAddInput.tsx) - a transparent-text-aware backdropdivrenders tint rectangles behind recognized token spans while the real<input>sits on top (scroll synced). This gives inline highlighting without acontenteditablerewrite (which true padded inline pills would require). "Unlink a wrong token" is offered as a removable-chips row below the field rather than in-place click, for the same reason. Reach for this overlay pattern when you need to decorate input text without giving up the native input/caret/a11y.
The MCP server is the first Node.js service in the repository. Different patterns from the .NET backend and Next.js frontend; documented here so the precedent is explicit.
- Hono with
@hono/node-serveras the HTTP framework. Chosen over Express for modern TypeScript-native types, smaller dep tree, equivalent capability for this scope. - TypeScript strict mode + NodeNext module resolution. All imports use explicit
.jsextensions (NodeNext requires this, even when importing from TypeScript source). - Hand-rolled OAuth 2.1 rather than a library. Visibility into every spec requirement was prioritized over ergonomics, given this was the project's first OAuth implementation.
- Opaque tokens (not JWT). Random 32-byte hex strings stored in SQLite with metadata. Simpler than JWT (no key management) and trivially revocable.
- Zod schemas inlined per tool file (not centralized in a
schemas.ts). With 16 small tools across 3 files, the indirection of a separate schemas file would not pay back. node:testfor unit tests (not Jest or Vitest). Built into Node 20+, no extra dependency. Test files are*.test.tsnext to the code they test; excluded from production build via tsconfig.better-sqlite3for the auth DB. Synchronous API matches the rest of the request-handler shape; performance is microsecond-scale for the kinds of lookups we do (single token, single client).
Refactor store.ts to accept an injected DB path / in-memory mode - currently it opens the configured file at module load. Worth doing if we ever want to add DB-backed unit tests (currently we rely on end-to-end smoke testing for rotation, audience, expiry).
ESLint / Prettier - skipped in the initial scaffold. TypeScript strict mode catches most issues; editor-side formatting handles consistency. Worth adding if collaborators join, or if drift becomes a problem.
A library-based OAuth server (e.g. mcp-auth) - hand-rolled was correct for learning, but if requirements grow (refresh token chains, multiple upstream IdPs, scoped consent), a library may be worth migrating to.
| Situation | Code |
|---|---|
Missing or bad Bearer token on /mcp |
401 + RFC 9728 WWW-Authenticate |
Wrong Origin on /mcp |
403 |
| Unsupported MCP-Protocol-Version | 400 |
| Bad OAuth request (missing params, bad PKCE, etc.) | 400 with RFC 6749 error code in JSON body |
GET /mcp (no server-initiated push) |
405 with Allow: POST |
These are open issues - areas where the current code does not yet match the patterns above. They are tracked rather than hidden so they can be addressed deliberately.
| Issue | What's not yet in place |
|---|---|
| #1 | CORS not applied outside dev; server-side fetch uses localhost |
| #2 | Feedback timer not cleared; optimistic delete before API confirms |
| #3 | Database path is relative; API URL has no startup validation |
| #4 | Contrast and focus indicators below WCAG AA in places |
| #5 | Utility functions duplicated; DateTime.Now instead of UtcNow |
| #6 | CORS policy too broad; AllowedHosts is wildcard |
| #17 | ProjectsController CreatedAtAction points to wrong route |
| #18 | Inconsistent DateTime.Now vs UtcNow across controllers |
| #19 | Assigning task to non-existent project returns 500 not 400 |
A useful checklist - not a gate:
- Read
docs/architecture.mdto understand where the change fits. - Glance at
docs/product-design.md- if the feature shifts the product scope, that is worth noting before building. - Add an API endpoint if new data operations are needed.
- Add a typed function in
src/lib/api.tsfor any new endpoint. - Build UI in the right component type (Server if read-only, Client if interactive).
- If logic is shared across components, extract it to
src/lib/rather than copying.