lots more stuff
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,79 @@
|
||||
<script>
|
||||
/**
|
||||
* @typedef {import('$lib/models/note.js').Note} Note
|
||||
*/
|
||||
|
||||
import { objectsStore } from '$lib/stores/objects.js';
|
||||
import ObjectRenderer from './ObjectRenderer.svelte';
|
||||
|
||||
/** @type {{ object: Note, depth?: number }} */
|
||||
let { object, depth = 0 } = $props();
|
||||
|
||||
let editing = $state(false);
|
||||
let draftText = $state('');
|
||||
|
||||
function startEdit() {
|
||||
draftText = $state.snapshot(object).text;
|
||||
editing = true;
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editing = false;
|
||||
}
|
||||
|
||||
async function saveText() {
|
||||
await objectsStore.updateText(object.objectId, draftText);
|
||||
editing = false;
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
await objectsStore.removeObject(object.objectId);
|
||||
}
|
||||
</script>
|
||||
|
||||
<article class="note">
|
||||
<div class="header">
|
||||
{#if editing}
|
||||
<input type="text" bind:value={draftText} />
|
||||
<button onclick={saveText}>Save</button>
|
||||
<button onclick={cancelEdit}>Cancel</button>
|
||||
{:else}
|
||||
<p class="note-text">{object.text}</p>
|
||||
<button onclick={startEdit}>Edit</button>
|
||||
{/if}
|
||||
<button onclick={handleDelete}>Delete</button>
|
||||
</div>
|
||||
|
||||
<div class="children">
|
||||
{#each object.childIds as childId (childId)}
|
||||
{@const childObject = $objectsStore.objects.get(childId)}
|
||||
{#if childObject}
|
||||
<ObjectRenderer object={childObject} depth={depth + 1} />
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<style>
|
||||
.note {
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.note-text {
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.children {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script>
|
||||
/**
|
||||
* @typedef {import('$lib/services/objectParser.js').TypedObject} TypedObject
|
||||
*/
|
||||
|
||||
import Root from './Root.svelte';
|
||||
import Project from './Project.svelte';
|
||||
import Task from './Task.svelte';
|
||||
import Note from './Note.svelte';
|
||||
|
||||
/** @type {{ object: TypedObject, depth?: number }} */
|
||||
let { object, depth = 0 } = $props();
|
||||
</script>
|
||||
|
||||
<div class="object-renderer" style:padding-left={`${depth * 1.5}rem`}>
|
||||
{#if object.type === 'root'}
|
||||
<Root {object} {depth} />
|
||||
{:else if object.type === 'project'}
|
||||
<Project {object} {depth} />
|
||||
{:else if object.type === 'task'}
|
||||
<Task {object} {depth} />
|
||||
{:else if object.type === 'note'}
|
||||
<Note {object} {depth} />
|
||||
{:else}
|
||||
<p>Unknown object type: {object.type}</p>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,79 @@
|
||||
<script>
|
||||
/**
|
||||
* @typedef {import('$lib/models/project.js').Project} Project
|
||||
*/
|
||||
|
||||
import { objectsStore } from '$lib/stores/objects.js';
|
||||
import ObjectRenderer from './ObjectRenderer.svelte';
|
||||
|
||||
/** @type {{ object: Project, depth?: number }} */
|
||||
let { object, depth = 0 } = $props();
|
||||
|
||||
let editing = $state(false);
|
||||
let draftName = $state('');
|
||||
|
||||
function startEdit() {
|
||||
draftName = $state.snapshot(object).name;
|
||||
editing = true;
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editing = false;
|
||||
}
|
||||
|
||||
async function saveName() {
|
||||
await objectsStore.updateProjectName(object.objectId, draftName);
|
||||
editing = false;
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
await objectsStore.removeObject(object.objectId);
|
||||
}
|
||||
</script>
|
||||
|
||||
<article class="project">
|
||||
<div class="header">
|
||||
{#if editing}
|
||||
<input type="text" bind:value={draftName} />
|
||||
<button onclick={saveName}>Save</button>
|
||||
<button onclick={cancelEdit}>Cancel</button>
|
||||
{:else}
|
||||
<h3>{object.name}</h3>
|
||||
<button onclick={startEdit}>Edit</button>
|
||||
{/if}
|
||||
<button onclick={handleDelete}>Delete</button>
|
||||
</div>
|
||||
|
||||
<div class="children">
|
||||
{#each object.childIds as childId (childId)}
|
||||
{@const childObject = $objectsStore.objects.get(childId)}
|
||||
{#if childObject}
|
||||
<ObjectRenderer object={childObject} depth={depth + 1} />
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<style>
|
||||
.project {
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.header h3 {
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.children {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script>
|
||||
/**
|
||||
* @typedef {import('$lib/models/root.js').Root} Root
|
||||
* @typedef {import('$lib/services/objectParser.js').TypedObject} TypedObject
|
||||
*/
|
||||
|
||||
import { objectsStore } from '$lib/stores/objects.js';
|
||||
import ObjectRenderer from './ObjectRenderer.svelte';
|
||||
|
||||
/** @type {{ object: Root, depth?: number }} */
|
||||
let { object, depth = 0 } = $props();
|
||||
|
||||
/** @type {TypedObject | undefined} */
|
||||
let childObject = $derived(undefined);
|
||||
</script>
|
||||
|
||||
<section class="root">
|
||||
{#if depth === 0}
|
||||
<h2>Root</h2>
|
||||
{/if}
|
||||
|
||||
{#each object.childIds as childId (childId)}
|
||||
{@const childObject = $objectsStore.objects.get(childId)}
|
||||
{#if childObject}
|
||||
<ObjectRenderer object={childObject} depth={depth + 1} />
|
||||
{/if}
|
||||
{/each}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.root {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,100 @@
|
||||
<script>
|
||||
/**
|
||||
* @typedef {import('$lib/models/task.js').Task} Task
|
||||
* @typedef {import('$lib/models/task.js').TaskStatus} TaskStatus
|
||||
*/
|
||||
|
||||
import { objectsStore } from '$lib/stores/objects.js';
|
||||
import ObjectRenderer from './ObjectRenderer.svelte';
|
||||
|
||||
/** @type {{ object: Task, depth?: number }} */
|
||||
let { object, depth = 0 } = $props();
|
||||
|
||||
let editing = $state(false);
|
||||
let draftText = $state('');
|
||||
|
||||
const statusOptions = /** @type {TaskStatus[]} */ ([
|
||||
'not-started',
|
||||
'in-progress',
|
||||
'completed',
|
||||
'cancelled'
|
||||
]);
|
||||
|
||||
function startEdit() {
|
||||
draftText = $state.snapshot(object).text;
|
||||
editing = true;
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editing = false;
|
||||
}
|
||||
|
||||
async function saveText() {
|
||||
await objectsStore.updateText(object.objectId, draftText);
|
||||
editing = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Event} event
|
||||
*/
|
||||
async function handleStatusChange(event) {
|
||||
const target = /** @type {HTMLSelectElement} */ (event.target);
|
||||
await objectsStore.updateTaskStatus(object.objectId, /** @type {TaskStatus} */ (target.value));
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
await objectsStore.removeObject(object.objectId);
|
||||
}
|
||||
</script>
|
||||
|
||||
<article class="task">
|
||||
<div class="header">
|
||||
{#if editing}
|
||||
<input type="text" bind:value={draftText} />
|
||||
<button onclick={saveText}>Save</button>
|
||||
<button onclick={cancelEdit}>Cancel</button>
|
||||
{:else}
|
||||
<p class="task-text">{object.text}</p>
|
||||
<button onclick={startEdit}>Edit</button>
|
||||
{/if}
|
||||
<select value={object.status} onchange={handleStatusChange}>
|
||||
{#each statusOptions as status}
|
||||
<option value={status}>{status}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<button onclick={handleDelete}>Delete</button>
|
||||
</div>
|
||||
|
||||
<div class="children">
|
||||
{#each object.childIds as childId (childId)}
|
||||
{@const childObject = $objectsStore.objects.get(childId)}
|
||||
{#if childObject}
|
||||
<ObjectRenderer object={childObject} depth={depth + 1} />
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<style>
|
||||
.task {
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.task-text {
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.children {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* @typedef {Object} User
|
||||
* @property {string} userId
|
||||
* @property {string} name
|
||||
* @property {string} rootObjectId
|
||||
*/
|
||||
|
||||
@@ -8,9 +9,10 @@
|
||||
* Create a User object.
|
||||
*
|
||||
* @param {string} userId
|
||||
* @param {string} name
|
||||
* @param {string} rootObjectId
|
||||
* @returns {User}
|
||||
*/
|
||||
export function createUserModel(userId, rootObjectId) {
|
||||
return { userId, rootObjectId };
|
||||
export function createUserModel(userId, name, rootObjectId) {
|
||||
return { userId, name, rootObjectId };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { createRootModel } from '$lib/models/root.js';
|
||||
import { createProjectModel } from '$lib/models/project.js';
|
||||
import { createTaskModel } from '$lib/models/task.js';
|
||||
import { createNoteModel } from '$lib/models/note.js';
|
||||
|
||||
/**
|
||||
* @typedef {import('$lib/models/root.js').Root} Root
|
||||
* @typedef {import('$lib/models/project.js').Project} Project
|
||||
* @typedef {import('$lib/models/task.js').Task} Task
|
||||
* @typedef {import('$lib/models/note.js').Note} Note
|
||||
* @typedef {Root | Project | Task | Note} TypedObject
|
||||
* @typedef {import('./objects.js').ObjectRecord} ObjectRecord
|
||||
*/
|
||||
|
||||
/**
|
||||
* Parse a backend ObjectRecord into a typed domain object.
|
||||
*
|
||||
* @param {ObjectRecord} record
|
||||
* @returns {TypedObject | null}
|
||||
*/
|
||||
export function parseObjectRecord(record) {
|
||||
try {
|
||||
const data = JSON.parse(record.objectData);
|
||||
|
||||
switch (data.type) {
|
||||
case 'root':
|
||||
return createRootModel(record.objectId, data.childIds ?? []);
|
||||
case 'project':
|
||||
return createProjectModel(record.objectId, data.name ?? '', data.childIds ?? []);
|
||||
case 'task':
|
||||
return createTaskModel(
|
||||
record.objectId,
|
||||
data.text ?? '',
|
||||
data.status ?? 'not-started',
|
||||
data.childIds ?? []
|
||||
);
|
||||
case 'note':
|
||||
return createNoteModel(record.objectId, data.text ?? '', data.childIds ?? []);
|
||||
default:
|
||||
console.warn(`Unknown object type: ${data.type}`);
|
||||
return null;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed to parse object ${record.objectId}:`, err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a list of ObjectRecords into a map of typed objects.
|
||||
* Also returns the root object ID if one is found.
|
||||
*
|
||||
* @param {ObjectRecord[]} records
|
||||
* @returns {{ objects: Map<string, TypedObject>, rootId: string | null }}
|
||||
*/
|
||||
export function parseObjectRecords(records) {
|
||||
/** @type {Map<string, TypedObject>} */
|
||||
const objects = new Map();
|
||||
/** @type {string | null} */
|
||||
let rootId = null;
|
||||
|
||||
for (const record of records) {
|
||||
const parsed = parseObjectRecord(record);
|
||||
if (!parsed) continue;
|
||||
|
||||
objects.set(parsed.objectId, parsed);
|
||||
|
||||
if (parsed.type === 'root') {
|
||||
rootId = parsed.objectId;
|
||||
}
|
||||
}
|
||||
|
||||
return { objects, rootId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a typed domain object into the JSON payload stored in objectData.
|
||||
*
|
||||
* @param {TypedObject} obj
|
||||
* @returns {string}
|
||||
*/
|
||||
export function serializeTypedObject(obj) {
|
||||
const { objectId, ...data } = obj;
|
||||
return JSON.stringify(data);
|
||||
}
|
||||
@@ -8,17 +8,18 @@ import { apiFetch } from './api.js';
|
||||
*/
|
||||
|
||||
/**
|
||||
* Create a new object.
|
||||
* Create a new object. The backend will use the supplied objectId if provided,
|
||||
* otherwise it will assign a UUID.
|
||||
*
|
||||
* @param {string} objectId
|
||||
* @param {string} userId
|
||||
* @param {string} objectData
|
||||
* @returns {Promise<{status: string}>}
|
||||
* @param {string} [objectId]
|
||||
* @returns {Promise<{status: string, objectId: string}>}
|
||||
*/
|
||||
export function createObject(objectId, userId, objectData) {
|
||||
export function createObject(userId, objectData, objectId) {
|
||||
return apiFetch('/objects', {
|
||||
method: 'POST',
|
||||
body: { objectId, userId, objectData }
|
||||
body: { userId, objectData, objectId }
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -3,25 +3,27 @@ import { apiFetch } from './api.js';
|
||||
/**
|
||||
* @typedef {Object} User
|
||||
* @property {string} userId
|
||||
* @property {string} name
|
||||
* @property {string} rootObjectId
|
||||
* @property {Object[]} objects
|
||||
*/
|
||||
|
||||
/**
|
||||
* Create a new user.
|
||||
* Create a new user. The backend will create an empty root object.
|
||||
*
|
||||
* @param {string} userId
|
||||
* @param {string} rootObjectId
|
||||
* @returns {Promise<{status: string}>}
|
||||
* @param {string} name
|
||||
* @returns {Promise<{status: string, rootObjectId: string}>}
|
||||
*/
|
||||
export function createUser(userId, rootObjectId) {
|
||||
export function createUser(userId, name) {
|
||||
return apiFetch('/users', {
|
||||
method: 'POST',
|
||||
body: { userId, rootObjectId }
|
||||
body: { userId, name }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a user by ID.
|
||||
* Fetch a user by ID. Returns the user profile and all associated objects.
|
||||
*
|
||||
* @param {string} userId
|
||||
* @returns {Promise<User>}
|
||||
@@ -31,16 +33,16 @@ export function getUser(userId) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a user's root object.
|
||||
* Update a user's display name.
|
||||
*
|
||||
* @param {string} userId
|
||||
* @param {string} rootObjectId
|
||||
* @param {string} name
|
||||
* @returns {Promise<{status: string}>}
|
||||
*/
|
||||
export function updateUser(userId, rootObjectId) {
|
||||
export function updateUser(userId, name) {
|
||||
return apiFetch(`/users/${userId}`, {
|
||||
method: 'PUT',
|
||||
body: { rootObjectId }
|
||||
body: { name }
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
import { writable, get } from 'svelte/store';
|
||||
import { createObject, updateObject, deleteObject } from '$lib/services/objects.js';
|
||||
import { getUser } from '$lib/services/users.js';
|
||||
import { parseObjectRecords, serializeTypedObject } from '$lib/services/objectParser.js';
|
||||
import { createRootModel } from '$lib/models/root.js';
|
||||
import { createProjectModel } from '$lib/models/project.js';
|
||||
import { createTaskModel } from '$lib/models/task.js';
|
||||
import { createNoteModel } from '$lib/models/note.js';
|
||||
|
||||
/**
|
||||
* @typedef {import('$lib/services/objectParser.js').TypedObject} TypedObject
|
||||
* @typedef {import('$lib/models/task.js').TaskStatus} TaskStatus
|
||||
*/
|
||||
|
||||
function createObjectsStore() {
|
||||
const { subscribe, set, update } = writable({
|
||||
/** @type {Map<string, TypedObject>} */
|
||||
objects: new Map(),
|
||||
/** @type {string | null} */
|
||||
rootId: null,
|
||||
loading: false,
|
||||
error: /** @type {string | null} */ (null)
|
||||
});
|
||||
|
||||
/**
|
||||
* Load all objects for a user starting from the root object.
|
||||
* Creates a root object if the user has none.
|
||||
*
|
||||
* @param {string} userId
|
||||
*/
|
||||
async function loadObjects(userId) {
|
||||
update((state) => ({ ...state, loading: true, error: null }));
|
||||
|
||||
try {
|
||||
const user = await getUser(userId);
|
||||
let rootId = user.rootObjectId;
|
||||
|
||||
/** @type {Map<string, TypedObject>} */
|
||||
const objects = new Map();
|
||||
|
||||
// The bulk user endpoint returns all objects in one response.
|
||||
const records = user.objects ?? [];
|
||||
const parsed = parseObjectRecords(records);
|
||||
parsed.objects.forEach((obj, id) => objects.set(id, obj));
|
||||
if (parsed.rootId) {
|
||||
rootId = parsed.rootId;
|
||||
}
|
||||
|
||||
if (!rootId) {
|
||||
const root = createRootModel(crypto.randomUUID());
|
||||
rootId = root.objectId;
|
||||
const response = await createObject(userId, serializeTypedObject(root), rootId);
|
||||
rootId = response.objectId;
|
||||
objects.set(rootId, { ...root, objectId: rootId });
|
||||
}
|
||||
|
||||
update((state) => ({ ...state, objects, rootId, loading: false }));
|
||||
} catch (err) {
|
||||
update((state) => ({
|
||||
...state,
|
||||
loading: false,
|
||||
error: err instanceof Error ? err.message : 'Failed to load objects'
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a single object to the backend and update local state.
|
||||
*
|
||||
* @param {string} objectId
|
||||
*/
|
||||
async function persistObject(objectId) {
|
||||
const state = get({ subscribe });
|
||||
const obj = state.objects.get(objectId);
|
||||
if (!obj) return;
|
||||
|
||||
await updateObject(objectId, serializeTypedObject(obj));
|
||||
update((state) => ({ ...state, objects: new Map(state.objects) }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new object, attach it to a parent, and persist both.
|
||||
*
|
||||
* @param {string} userId
|
||||
* @param {'project' | 'task' | 'note'} type
|
||||
* @param {string} parentId
|
||||
* @param {string} [nameOrText='']
|
||||
* @param {TaskStatus} [status='not-started']
|
||||
*/
|
||||
async function addObject(userId, type, parentId, nameOrText = '', status = 'not-started') {
|
||||
const state = get({ subscribe });
|
||||
const parent = state.objects.get(parentId);
|
||||
if (!parent) throw new Error('Parent not found');
|
||||
|
||||
/** @type {TypedObject} */
|
||||
let obj;
|
||||
switch (type) {
|
||||
case 'project':
|
||||
obj = createProjectModel(crypto.randomUUID(), nameOrText);
|
||||
break;
|
||||
case 'task':
|
||||
obj = createTaskModel(crypto.randomUUID(), nameOrText, status);
|
||||
break;
|
||||
case 'note':
|
||||
obj = createNoteModel(crypto.randomUUID(), nameOrText);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unsupported object type: ${type}`);
|
||||
}
|
||||
|
||||
const response = await createObject(userId, serializeTypedObject(obj), obj.objectId);
|
||||
const created = { ...obj, objectId: response.objectId };
|
||||
|
||||
const updatedParent = { ...parent, childIds: [...parent.childIds, created.objectId] };
|
||||
|
||||
update((state) => {
|
||||
const objects = new Map(state.objects);
|
||||
objects.set(created.objectId, created);
|
||||
objects.set(parentId, updatedParent);
|
||||
return { ...state, objects };
|
||||
});
|
||||
|
||||
await persistObject(parentId);
|
||||
await persistObject(created.objectId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a project name.
|
||||
*
|
||||
* @param {string} objectId
|
||||
* @param {string} name
|
||||
*/
|
||||
async function updateProjectName(objectId, name) {
|
||||
update((state) => {
|
||||
const obj = state.objects.get(objectId);
|
||||
if (!obj || obj.type !== 'project') return state;
|
||||
const objects = new Map(state.objects);
|
||||
objects.set(objectId, { ...obj, name });
|
||||
return { ...state, objects };
|
||||
});
|
||||
await persistObject(objectId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update text for a task or note.
|
||||
*
|
||||
* @param {string} objectId
|
||||
* @param {string} text
|
||||
*/
|
||||
async function updateText(objectId, text) {
|
||||
update((state) => {
|
||||
const obj = state.objects.get(objectId);
|
||||
if (!obj || (obj.type !== 'task' && obj.type !== 'note')) return state;
|
||||
const objects = new Map(state.objects);
|
||||
objects.set(objectId, { ...obj, text });
|
||||
return { ...state, objects };
|
||||
});
|
||||
await persistObject(objectId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a task status.
|
||||
*
|
||||
* @param {string} objectId
|
||||
* @param {TaskStatus} status
|
||||
*/
|
||||
async function updateTaskStatus(objectId, status) {
|
||||
update((state) => {
|
||||
const obj = state.objects.get(objectId);
|
||||
if (!obj || obj.type !== 'task') return state;
|
||||
const objects = new Map(state.objects);
|
||||
objects.set(objectId, { ...obj, status });
|
||||
return { ...state, objects };
|
||||
});
|
||||
await persistObject(objectId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an object and remove it from its parent's childIds.
|
||||
*
|
||||
* @param {string} objectId
|
||||
*/
|
||||
async function removeObject(objectId) {
|
||||
const state = get({ subscribe });
|
||||
if (objectId === state.rootId) {
|
||||
throw new Error('Cannot delete root object');
|
||||
}
|
||||
|
||||
const obj = state.objects.get(objectId);
|
||||
if (!obj) return;
|
||||
|
||||
// Find parent and remove childId.
|
||||
for (const [parentId, parent] of state.objects) {
|
||||
if (parent.childIds.includes(objectId)) {
|
||||
parent.childIds = parent.childIds.filter((id) => id !== objectId);
|
||||
await persistObject(parentId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await deleteObject(objectId);
|
||||
|
||||
update((state) => {
|
||||
const objects = new Map(state.objects);
|
||||
objects.delete(objectId);
|
||||
return { ...state, objects };
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
loadObjects,
|
||||
addObject,
|
||||
updateProjectName,
|
||||
updateText,
|
||||
updateTaskStatus,
|
||||
removeObject
|
||||
};
|
||||
}
|
||||
|
||||
export const objectsStore = createObjectsStore();
|
||||
@@ -1,25 +1,52 @@
|
||||
<script>
|
||||
import { goto } from '$app/navigation';
|
||||
import { auth } from '$lib/stores/auth.js';
|
||||
import { createUser } from '$lib/services/users.js';
|
||||
|
||||
let userId = $state('');
|
||||
let name = $state('');
|
||||
let error = $state('');
|
||||
let creating = $state(false);
|
||||
|
||||
function enterApp() {
|
||||
if (userId.trim()) {
|
||||
auth.login(userId.trim());
|
||||
goto('/dashboard');
|
||||
async function enterApp() {
|
||||
const trimmedUserId = userId.trim();
|
||||
const trimmedName = name.trim() || trimmedUserId;
|
||||
|
||||
if (!trimmedUserId) return;
|
||||
|
||||
error = '';
|
||||
creating = true;
|
||||
|
||||
try {
|
||||
await createUser(trimmedUserId, trimmedName);
|
||||
} catch (err) {
|
||||
// User may already exist; that's fine for login.
|
||||
console.warn('Create user call failed, proceeding as login:', err);
|
||||
} finally {
|
||||
creating = false;
|
||||
}
|
||||
|
||||
auth.login(trimmedUserId);
|
||||
goto('/dashboard');
|
||||
}
|
||||
</script>
|
||||
|
||||
<h1>OwOrganizer</h1>
|
||||
|
||||
<p>Enter your user ID to open the app.</p>
|
||||
<p>Enter your user ID to open the app. If the user does not exist, it will be created.</p>
|
||||
|
||||
{#if error}
|
||||
<p style="color: red">{error}</p>
|
||||
{/if}
|
||||
|
||||
<form onsubmit={(e) => { e.preventDefault(); enterApp(); }}>
|
||||
<label>
|
||||
User ID
|
||||
<input type="text" bind:value={userId} placeholder="e.g., alice" />
|
||||
</label>
|
||||
<button type="submit">Open App</button>
|
||||
<label>
|
||||
Display Name
|
||||
<input type="text" bind:value={name} placeholder="e.g., Alice" />
|
||||
</label>
|
||||
<button type="submit" disabled={creating}>{creating ? 'Opening...' : 'Open App'}</button>
|
||||
</form>
|
||||
|
||||
@@ -1,27 +1,42 @@
|
||||
<script>
|
||||
import { auth } from '$lib/stores/auth.js';
|
||||
import { getObjectsByUser } from '$lib/services/objects.js';
|
||||
import { objectsStore } from '$lib/stores/objects.js';
|
||||
import Status from '$lib/components/Status.svelte';
|
||||
import ObjectRenderer from '$lib/components/ObjectRenderer.svelte';
|
||||
|
||||
let userId = $derived($auth);
|
||||
let objects = $state([]);
|
||||
let loading = $state(true);
|
||||
let error = $state(null);
|
||||
|
||||
let newType = $state('task');
|
||||
let newText = $state('');
|
||||
let selectedParentId = $state('');
|
||||
|
||||
$effect(() => {
|
||||
if (!userId) return;
|
||||
loading = true;
|
||||
getObjectsByUser(userId)
|
||||
.then((data) => {
|
||||
objects = data || [];
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err.message;
|
||||
})
|
||||
.finally(() => {
|
||||
loading = false;
|
||||
});
|
||||
objectsStore.loadObjects(userId);
|
||||
});
|
||||
|
||||
/** @type {import('$lib/services/objectParser.js').TypedObject | undefined} */
|
||||
let rootObject = $derived(
|
||||
$objectsStore.rootId ? $objectsStore.objects.get($objectsStore.rootId) : undefined
|
||||
);
|
||||
|
||||
/**
|
||||
* @param {Event} event
|
||||
*/
|
||||
async function handleCreate(event) {
|
||||
event.preventDefault();
|
||||
if (!userId || !selectedParentId || !newText.trim()) return;
|
||||
|
||||
await objectsStore.addObject(userId, /** @type {'project' | 'task' | 'note'} */ (newType), selectedParentId, newText.trim());
|
||||
newText = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} objectId
|
||||
*/
|
||||
function selectParent(objectId) {
|
||||
selectedParentId = objectId;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if !userId}
|
||||
@@ -31,19 +46,68 @@
|
||||
<p>Welcome, {userId}.</p>
|
||||
|
||||
<h2>Your Objects</h2>
|
||||
{#if loading}
|
||||
{#if $objectsStore.loading}
|
||||
<Status message="Loading objects..." />
|
||||
{:else if error}
|
||||
<p style="color: red">Error: {error}</p>
|
||||
{:else if objects.length === 0}
|
||||
<p>No objects found.</p>
|
||||
{:else if $objectsStore.error}
|
||||
<p style="color: red">Error: {$objectsStore.error}</p>
|
||||
{:else if !rootObject}
|
||||
<p>No root object found.</p>
|
||||
{:else}
|
||||
<ul>
|
||||
{#each objects as obj (obj.objectId)}
|
||||
<li>
|
||||
<strong>{obj.objectId}</strong>: {obj.objectData}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<section class="create-form">
|
||||
<h3>Create Object</h3>
|
||||
<form onsubmit={handleCreate}>
|
||||
<label>
|
||||
Type
|
||||
<select bind:value={newType}>
|
||||
<option value="project">Project</option>
|
||||
<option value="task">Task</option>
|
||||
<option value="note">Note</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Parent
|
||||
<select bind:value={selectedParentId}>
|
||||
<option value={$objectsStore.rootId}>Root</option>
|
||||
{#each $objectsStore.objects.values() as obj}
|
||||
{#if obj.type !== 'root'}
|
||||
<option value={obj.objectId}>{obj.type}: {obj.type === 'project' ? obj.name : obj.text}</option>
|
||||
{/if}
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Name / Text
|
||||
<input type="text" bind:value={newText} placeholder="Enter name or text" />
|
||||
</label>
|
||||
|
||||
<button type="submit">Create</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<ObjectRenderer object={rootObject} />
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.create-form {
|
||||
margin: 1rem 0;
|
||||
padding: 1rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.create-form form {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.create-form label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user