150 lines
6.3 KiB
Markdown
150 lines
6.3 KiB
Markdown
|
|
# Frontend Architecture
|
||
|
|
|
||
|
|
## Role of the frontend
|
||
|
|
|
||
|
|
The frontend owns **all business logic**. It defines the object types, builds the tree from parent/child references, renders the UI, and is responsible for keeping the local state in sync with the backend.
|
||
|
|
|
||
|
|
The backend is treated as a dumb encrypted sync target: it stores opaque JSON blobs and returns them on request. The frontend encrypts and decrypts data, parses blobs into typed models, and maintains all relationships.
|
||
|
|
|
||
|
|
## Object model
|
||
|
|
|
||
|
|
All domain objects are stored as rows in the backend `objects` table. Each row contains an `objectId`, a `userId`, and an `objectData` string (eventually encrypted JSON).
|
||
|
|
|
||
|
|
The frontend recognizes four typed objects:
|
||
|
|
|
||
|
|
### `Root`
|
||
|
|
|
||
|
|
```ts
|
||
|
|
{
|
||
|
|
objectId: string;
|
||
|
|
type: 'root';
|
||
|
|
childIds: string[];
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Every user has exactly one root object. It is the entry point for rendering the tree.
|
||
|
|
|
||
|
|
### `Project`
|
||
|
|
|
||
|
|
```ts
|
||
|
|
{
|
||
|
|
objectId: string;
|
||
|
|
type: 'project';
|
||
|
|
name: string;
|
||
|
|
childIds: string[];
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
A project is a container for tasks, notes, and other projects.
|
||
|
|
|
||
|
|
### `Task`
|
||
|
|
|
||
|
|
```ts
|
||
|
|
{
|
||
|
|
objectId: string;
|
||
|
|
type: 'task';
|
||
|
|
text: string;
|
||
|
|
status: 'not-started' | 'in-progress' | 'completed' | 'cancelled';
|
||
|
|
childIds: string[];
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
A task is a checkbox-style item that may contain child tasks or notes.
|
||
|
|
|
||
|
|
### `Note`
|
||
|
|
|
||
|
|
```ts
|
||
|
|
{
|
||
|
|
objectId: string;
|
||
|
|
type: 'note';
|
||
|
|
text: string;
|
||
|
|
childIds: string[];
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
A note is a free-text object that may contain child notes or tasks.
|
||
|
|
|
||
|
|
## Data flow
|
||
|
|
|
||
|
|
1. **Load**: `objectsStore.loadObjects(userId)` calls `GET /users/:id`, which returns the user profile (`userId`, `name`, `rootObjectId`) and all associated objects.
|
||
|
|
2. **Parse**: `objectParser.js` parses each `objectData` JSON string into a typed object and stores them in a `Map<objectId, TypedObject>`.
|
||
|
|
3. **Render**: Components read from the store map and render children by following `childIds`.
|
||
|
|
4. **Mutate**: User actions update the local store immediately, then persist the changed object(s) to the backend with `PUT /objects/:id`.
|
||
|
|
5. **Sync**: Eventually, the frontend will compare local state with server state using per-object versioning and resolve conflicts.
|
||
|
|
|
||
|
|
## Key modules
|
||
|
|
|
||
|
|
### `src/lib/models/*.js`
|
||
|
|
|
||
|
|
Pure factory functions for creating typed objects. They contain no side effects and no backend knowledge.
|
||
|
|
|
||
|
|
### `src/lib/services/objectParser.js`
|
||
|
|
|
||
|
|
Converts backend `ObjectRecord` rows into typed domain objects and serializes typed objects back into the JSON payload stored in `objectData`.
|
||
|
|
|
||
|
|
- `parseObjectRecord(record)` — parse a single row.
|
||
|
|
- `parseObjectRecords(records)` — parse many rows into a `Map` and identify the root object.
|
||
|
|
- `serializeTypedObject(obj)` — convert a typed object into the JSON string stored in `objectData`.
|
||
|
|
|
||
|
|
### `src/lib/services/objects.js`
|
||
|
|
|
||
|
|
Thin wrapper around the object HTTP endpoints.
|
||
|
|
|
||
|
|
### `src/lib/services/users.js`
|
||
|
|
|
||
|
|
Thin wrapper around the user HTTP endpoints. Routes use plural REST conventions (`/users`, `/objects`).
|
||
|
|
|
||
|
|
### `src/lib/stores/objects.js`
|
||
|
|
|
||
|
|
The central Svelte store for the object graph. Responsibilities:
|
||
|
|
|
||
|
|
- Load all objects for the current user.
|
||
|
|
- Maintain the `Map` of parsed objects and the `rootId`.
|
||
|
|
- Provide mutation helpers:
|
||
|
|
- `addObject(userId, type, parentId, nameOrText, status)` — create a child and update the parent's `childIds`.
|
||
|
|
- `updateProjectName(objectId, name)` — update a project name.
|
||
|
|
- `updateText(objectId, text)` — update task/note text.
|
||
|
|
- `updateTaskStatus(objectId, status)` — update a task status.
|
||
|
|
- `removeObject(objectId)` — delete an object and remove it from its parent's `childIds`.
|
||
|
|
|
||
|
|
## Rendering
|
||
|
|
|
||
|
|
Components render the tree recursively:
|
||
|
|
|
||
|
|
- `ObjectRenderer.svelte` dispatches to the correct component based on `object.type`.
|
||
|
|
- `Root.svelte`, `Project.svelte`, `Task.svelte`, and `Note.svelte` each render their own data and iterate over `childIds` to render children.
|
||
|
|
|
||
|
|
Components look up child objects from `$objectsStore.objects` by ID. If a child is missing, it is skipped.
|
||
|
|
|
||
|
|
## Parent/child maintenance
|
||
|
|
|
||
|
|
Because the backend cannot decrypt object data, the frontend is responsible for keeping `childIds` arrays consistent.
|
||
|
|
|
||
|
|
- **Create**: insert the new object's ID into the parent's `childIds`, then persist both parent and child.
|
||
|
|
- **Delete**: remove the object's ID from its parent's `childIds`, persist the parent, then delete the object.
|
||
|
|
- **Move**: remove the ID from the old parent's `childIds`, add it to the new parent's `childIds`, and persist both parents.
|
||
|
|
|
||
|
|
## Orphan handling
|
||
|
|
|
||
|
|
Network interruptions can leave orphaned objects in the backend (objects that are no longer referenced by any parent). The planned strategy:
|
||
|
|
|
||
|
|
1. The client-side delete method walks the tree and deletes all descendants explicitly.
|
||
|
|
2. On startup, a future `cleanupOrphans()` method will compare all loaded objects against the reachable tree and delete any object that is not referenced.
|
||
|
|
|
||
|
|
## Known gaps and roadmap
|
||
|
|
|
||
|
|
| Priority | Item | Notes |
|
||
|
|
| -------- | ---- | ----- |
|
||
|
|
| High | Use bulk `GET /users/:id` for loading | Replace the current `getUser` + `getObjectsByUser` + `populateTree` flow with a single request that returns all objects. |
|
||
|
|
| High | Remove N+1 tree walking | `populateTree` fetches missing children one by one. Once the bulk endpoint is used, this can be deleted. |
|
||
|
|
| Medium | Encryption/decryption | `objectData` should be encrypted before sending and decrypted after receiving. Deferred until core flow is solid. |
|
||
|
|
| Medium | Offline support / local-first | Client-generated IDs are a first step. Eventually the store should work against a local cache and sync in the background. |
|
||
|
|
| Medium | Optimistic mutation error handling | If a persist fails, local state and server drift. Need pending/conflict states. |
|
||
|
|
| Medium | Unknown type fallback | Currently unknown types are silently dropped. Add a fallback renderer or safe ignore policy. |
|
||
|
|
| Low | Parent index | `removeObject` scans all objects to find the parent. Maintain a `childId -> parentId` index for O(1) lookup. |
|
||
|
|
| Low | Subtree move / duplicate | Not yet implemented. |
|
||
|
|
|
||
|
|
## Why the business logic lives in the frontend
|
||
|
|
|
||
|
|
Because the backend is intended to be an encrypted sync target, it cannot understand the data it stores. Moving tree logic, validation, and rendering decisions to the frontend keeps the server simple and compatible with E2EE. The trade-off is that the frontend must be defensive about missing objects, unknown types, and sync conflicts.
|