lots more stuff

This commit is contained in:
2026-07-16 19:24:39 -07:00
parent a06f21a5c2
commit 228e2e99f7
22 changed files with 1605 additions and 405 deletions
+79
View File
@@ -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>
+34
View File
@@ -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>
+100
View File
@@ -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>
+4 -2
View File
@@ -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 };
}
+85
View File
@@ -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);
}
+6 -5
View File
@@ -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 }
});
}
+12 -10
View File
@@ -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 }
});
}
+221
View File
@@ -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();