Compare commits

...

14 Commits

Author SHA1 Message Date
b13cee614e Task editing proof of concept.
This method uses url query params to detect when the page should be in
edit mode. There are two issues with the current approach:

1. The url query param is actually a pretty unintuitive pattern. It's
   disorienting to hit the back button and have it take you from edit
   mode to view mode rather than returning to the main task list.
2. The submit button does return the user to view mode, but caching
   results in the need for a refresh to see the updated task record.
   This can be solved.
2025-10-03 22:41:27 -05:00
bee3fdf2ca Update error handling and logging. 2025-10-03 22:35:16 -05:00
b2d7b4983a Update to match the schema changes. 2025-10-03 21:56:11 -05:00
2962c3aace Change tasks schema.
Existing date fields in the tasks table have been changed from TEXT to
INTEGER with mode set to timestamp. This will simplify date storage.
There were a number of issues encountered with storing dates as typed
strings.
2025-10-03 21:53:37 -05:00
eddaf02824 Add method to intelligently insert/update.
The `upsert()` method can both create and update tasks. The method
checks for an `id` propery to determine whether to `INSERT` or `UPDATE`.

A successful operation returns a `ServiceResponse` object with the
inserted task `id`, which can be used to fetch updated information if
needed.

The `NewTask` type is exported for use in form actions, etc.
2025-10-02 00:01:52 -05:00
f63f5ccc5a Rework data fetching and error handling.
Added server-side error handling for obvious total failure cases. If
a task is missing or has unexpected duplicates, SveltKit with now throw
a built in http error.

Added support for parent and child task fetching. Serverside, the raw
response object is passed to the client. The client is now able to
handle the parent and child response objects.
2025-10-01 23:04:04 -05:00
19ff88c661 Add TaskView component. 2025-10-01 23:04:04 -05:00
95826cde40 Add method to search by parent_id. 2025-10-01 01:07:20 -05:00
a4a09380b6 Export task types. 2025-09-29 23:10:03 -05:00
e2d902d9ca Get dprint working with svelte. 2025-09-29 22:53:48 -05:00
fb5a45587a Formatting. 2025-09-29 22:43:20 -05:00
9c219054d3 Implement changes from TaskService. 2025-09-29 22:42:42 -05:00
5bc27f6061 Improve typing and internal abstraction.
The only breaking change here is the 'tasks' field is now 'data'. All
other changes were made to improve TypeScripts ability to infer complex
types or internal abstractions that don't affect client consumption.
2025-09-29 22:39:25 -05:00
0990cf5c56 Add service response types for standardizing returns.
Typescript is currently having difficulty parsing Service class return
types. Providing more explicit return types should improve the
situation.
2025-09-29 21:38:19 -05:00
10 changed files with 586 additions and 80 deletions

View File

@ -12,9 +12,14 @@
"malva": { "malva": {
}, },
"markup": { "markup": {
"svelteAttrShorthand": true,
"svelteDirectiveShorthand": true
}, },
"yaml": { "yaml": {
}, },
"includes": [
"**/*.{ts,js,json,md,toml,dockerfile,css,scss,svelte,yaml,yml}"
],
"excludes": [ "excludes": [
"**/node_modules", "**/node_modules",
"**/*-lock.json" "**/*-lock.json"

View File

@ -30,8 +30,8 @@ export const tasks = sqliteTable("tasks", {
description: text("description"), description: text("description"),
type: int("type").references(() => taskTypes.id), type: int("type").references(() => taskTypes.id),
subtype: text("subtype"), subtype: text("subtype"),
openDate: text("open_date").$type<Date["toISOString"]>(), openDate: int("open_date", { mode: "timestamp" }),
closeDate: text("close_date").$type<Date["toISOString"]>(), closeDate: int("close_date", { mode: "timestamp" }),
status: text("status"), status: text("status"),
priority: text("priority"), priority: text("priority"),
parent: int("parent").references((): AnySQLiteColumn => tasks.id), parent: int("parent").references((): AnySQLiteColumn => tasks.id),

View File

@ -0,0 +1,12 @@
type ServiceResponseSuccess<T, D> = {
status: T;
data?: D;
};
type ServiceResponseFailure<T, E> = {
status: T;
code?: E;
error?: Error | string;
};
export type ServiceResponse<D, E, S = "ok", F = "failure"> =
| ServiceResponseSuccess<S, D>
| ServiceResponseFailure<F, E>;

View File

@ -1,6 +1,19 @@
import { type DB, db } from "$lib/server/db/db"; import { type DB, db } from "$lib/server/db/db";
import { tasks, type taskTypes } from "$lib/server/db/schema/tasks";
import type { ServiceResponse } from "$lib/server/services/service.types";
import { eq, type InferSelectModel } from "drizzle-orm";
import logger from "../logger"; import logger from "../logger";
export type Task = InferSelectModel<typeof tasks> & {
type: InferSelectModel<typeof taskTypes>;
};
export type TaskOrNull = InferSelectModel<typeof tasks> & {
type: InferSelectModel<typeof taskTypes> | null;
};
export type NewTask = typeof tasks.$inferInsert;
class TasksService { class TasksService {
private db: DB; private db: DB;
private caller: "internal" | "api"; private caller: "internal" | "api";
@ -10,22 +23,33 @@ class TasksService {
this.caller = caller; this.caller = caller;
} }
public async getAll() { private async _executeQuery(
logger.info("Fetching all task records..."); query: () => Promise<TaskOrNull[]>,
): Promise<ServiceResponse<Task[], "INTERNAL_ERROR" | "DATA_INTEGRITY_VIOLATION">> {
try { try {
const tasks = await this.db.query.tasks.findMany({ const tasks = await query();
with: {
type: true, if (tasks.some(x => x.type === null)) {
}, const badTaskIds = tasks.filter(t => t.type === null).map(t => t.id);
}); logger.error(`Data integrity issue: The following tasks have invalid type IDs: ${badTaskIds.join(", ")}`);
logger.debug(`Found ${tasks.length} records.`); return {
return { tasks, status: "ok" }; status: "failure",
error: "One or more tasks are invalid because they are not associated with a type.",
code: "DATA_INTEGRITY_VIOLATION",
};
}
return { data: tasks as Task[], status: "ok" };
} catch (error) { } catch (error) {
logger.error({ msg: "Error querying the database.", error }); logger.error({ msg: "Error querying the database.", error });
return { status: "failed", error }; return { status: "failure", error: "An internal server error occurred.", code: "INTERNAL_ERROR" };
} }
} }
public async getAll() {
logger.info("Fetching all task records...");
return this._executeQuery(() => this.db.query.tasks.findMany({ with: { type: true } }));
}
public async getByTaskId(taskIds: Array<string>) { public async getByTaskId(taskIds: Array<string>) {
const mappedTasks = taskIds.map(x => { const mappedTasks = taskIds.map(x => {
const prefix = x.slice(0, 2); const prefix = x.slice(0, 2);
@ -41,29 +65,73 @@ class TasksService {
: `${taskIds.length} records` : `${taskIds.length} records`
}.`, }.`,
); );
try { return this._executeQuery(() =>
const tasks = await db.query.tasks.findMany({ this.db.query.tasks.findMany({
with: { type: true }, with: { type: true },
where: (tasks, { inArray }) => inArray(tasks.taskId, mappedTasks.map(x => x.task_id)), where: (tasks, { inArray }) => inArray(tasks.taskId, mappedTasks.map(x => x.task_id)),
}); })
return { tasks, status: "ok" }; );
} catch (error) {
logger.error({ msg: "Error querying the database.", error });
return { status: "failed", error };
}
} }
public async getByDbId(ids: Array<number>) { public async getByDbId(ids: Array<number>) {
logger.info(`Fetching ${ids.length} records.`); logger.info(`Fetching ${ids.length} records.`);
try { return this._executeQuery(() =>
const tasks = await db.query.tasks.findMany({ this.db.query.tasks.findMany({
with: { type: true }, with: { type: true },
where: (tasks, { inArray }) => inArray(tasks.id, ids), where: (tasks, { inArray }) => inArray(tasks.id, ids),
}); })
return { tasks, status: "ok" }; );
}
public async getByParent(id: NonNullable<Task["id"]>) {
logger.info(`Searching for records with parent '${id}'.`);
return this._executeQuery(() =>
this.db.query.tasks.findMany({
with: { type: true },
where: (tasks, { eq }) => eq(tasks.parent, id),
})
);
}
public async upsert(
taskData: NewTask,
): Promise<ServiceResponse<{ id: number }, "INTERNAL_ERROR" | "MISSING_DATABASE_ENTRY" | "RECORD_CREATION_FAILURE">> {
try {
if (taskData.id) {
const updated = await this.db.update(tasks)
.set(taskData)
.where(eq(tasks.id, taskData.id))
.returning({ id: tasks.id });
if (updated.length === 0) {
logger.error({ msg: `Failed updating ${taskData.id}.`, data: taskData });
return {
status: "failure",
code: "MISSING_DATABASE_ENTRY",
error: `Unable to update ${taskData.id}. Likely no associated database entry exist.`,
};
}
return { status: "ok", data: { id: updated[0].id } };
} else {
const created = await this.db.insert(tasks)
.values(taskData)
.returning({ id: tasks.id });
if (created.length === 0) {
logger.error({ msg: "Failed creating task record.", data: taskData });
return {
status: "failure",
code: "RECORD_CREATION_FAILURE",
error: "The database was unable to insert the requested record.",
};
}
return { status: "ok", data: { id: created[0].id } };
}
} catch (error) { } catch (error) {
logger.error({ msg: "Error querying the database.", error }); logger.error(error, "Error upserting task.");
return { status: "failed", error }; return {
status: "failure",
error: "An internal server error occurred while trying to save the task.",
code: "INTERNAL_ERROR",
};
} }
} }
} }

View File

@ -0,0 +1,134 @@
<script lang="ts">
import { enhance } from "$app/forms";
import type { Task } from "$lib/server/services/tasks";
import type { ActionData } from "../../../routes/tasks/[task_id]/$types";
let { task, form }: {
task: Task;
form?: ActionData;
} = $props();
const statusOptions = ["Open", "Pending", "Closed"];
const priorityOptions = ["High", "Medium", "Low"];
let currentStatus = $state(form?.data?.status ?? task.status);
let isClosed = $derived(currentStatus === "Closed" ? true : false);
$effect(() => {
if (isClosed) {
closeDateLocal = formatForDateTimeLocal(new Date(Date.now()).toISOString());
currentStatus = "Closed";
} else {
closeDateLocal = formatForDateTimeLocal("");
currentStatus = currentStatus === "Closed" ? "Open" : currentStatus;
}
});
const formatForDateTimeLocal = (isoString: string | null | undefined) => {
if (!isoString) return "";
return new Date(isoString).toISOString().slice(0, 16);
};
// Local, user-facing state for the input controls
let openDateLocal = $state(
formatForDateTimeLocal(form?.data?.openDate ?? task.openDate),
);
let closeDateLocal = $state(
formatForDateTimeLocal(form?.data?.closeDate ?? task.closeDate),
);
// Derived state that creates the full ISO string for form submission
let openDateForForm = $derived(
openDateLocal ? new Date(openDateLocal).toISOString() : "",
);
let closeDateForForm = $derived(
closeDateLocal ? new Date(closeDateLocal).toISOString() : "",
);
</script>
<form method="POST" use:enhance>
<input type="hidden" name="id" value={task.id} />
<!-- Hidden inputs hold the true UTC value to be submitted -->
<input type="hidden" name="openDate" value={openDateForForm} />
<input type="hidden" name="closeDate" value={closeDateForForm} />
<!-- General Details -->
<fieldset>
<legend>Details</legend>
<div>
<label for="description">Description</label>
<input
id="description"
name="description"
type="text"
value={form?.data?.description ?? task.description ?? ""}
/>
{#if form?.error && form.field === "description"}
<p>{form.error}</p>
{/if}
</div>
<div>
<label for="subtype">Sub-Type</label>
<input
id="subtype"
name="subtype"
type="text"
value={form?.data?.subtype ?? task.subtype ?? ""}
/>
</div>
<div>
<label for="status">Status</label>
<select
id="status"
name="status"
bind:value={currentStatus}
>
{#each statusOptions as option (option)}
<option value={option}>{option}</option>
{/each}
</select>
</div>
<div>
<label for="priority">Priority</label>
<select
id="priority"
name="priority"
value={form?.data?.priority ?? task.priority}
>
{#each priorityOptions as option (option)}
<option value={option}>{option}</option>
{/each}
</select>
</div>
<div>
<label for="openDate-local">Open Date</label>
<input
id="openDate-local"
type="datetime-local"
bind:value={openDateLocal}
/>
</div>
<div>
<label for="closeDate-local">Close Date</label>
<input
id="closeDate-local"
type="datetime-local"
bind:value={closeDateLocal}
/>
<input type="checkbox" bind:checked={isClosed}>
</div>
</fieldset>
<!-- Body -->
<fieldset>
<legend>Body</legend>
<textarea id="body" name="body" rows="10"
>{form?.data?.body ?? task.body ?? ''}</textarea>
</fieldset>
<button type="submit">Save Task</button>
{#if form?.error && !form.field}
<p>{form.error}</p>
{/if}
</form>

View File

@ -0,0 +1,171 @@
<script lang="ts">
import type { Task } from "$lib/server/services/tasks";
type DisplayableTask = Task & {
taskId: NonNullable<Task["taskId"]>;
type: Task["type"] & {
prefix: NonNullable<Task["type"]["prefix"]>;
};
};
let { task, parent, children }: {
task: DisplayableTask;
parent: {
prefix: Task["type"]["prefix"];
taskId: Task["taskId"];
description: Task["description"];
};
children: Array<{
prefix: Task["type"]["prefix"];
taskId: Task["taskId"];
description: Task["description"];
status: Task["status"];
}>;
} = $props();
</script>
{@render description(task.type.prefix, task.taskId, task.description)}
{@render details(
task.status,
task.priority,
task.openDate,
task.closeDate,
parent,
children,
)}
{@render checklist(task.checklist)}
{@render body(task.body, task.bodyHistory)}
{@render integrations(task.integrations)}
<style>
.container {
display: flex;
gap: 1rem;
}
.id-bg {
background: beige;
color: cadetblue;
border-radius: 1rem;
border: 2px solid cadetblue;
padding: 0.5rem;
}
.desc-separator {}
.desc {}
</style>
{#snippet description(
prefix: NonNullable<Task["type"]["prefix"]>,
taskId: NonNullable<Task["taskId"]>,
description: Task["description"],
)}
<h1>
<span class="id-bg">{prefix + taskId}</span>
<span class="desc-separator"></span>
<span class="desc">{description}</span>
</h1>
{/snippet}
{#snippet details(
status: Task["status"],
priority: Task["priority"],
opened: Task["openDate"],
closed: Task["closeDate"],
parent: {
prefix: Task["type"]["prefix"];
taskId: Task["taskId"];
description: Task["description"];
},
children: Array<
{
prefix: Task["type"]["prefix"];
taskId: Task["taskId"];
description: Task["description"];
status: Task["status"];
}
>,
)}
<div class="details">
<div class="labeled-field">
<span class="label">Status:</span>
<span>{status ?? "--"}</span>
</div>
<div class="labeled-field">
<span class="label">Priority:</span>
<span>{priority ?? "--"}</span>
</div>
<div class="labeled-field">
<span class="label">Opened:</span>
<span>{opened ?? "--"}</span>
</div>
<div class="labeled-field">
<span class="label">Closed:</span>
<span>{closed ?? "--"}</span>
</div>
<div>
<span class="label">Parent:</span>
<span>{
!Object.values(parent).some(value => value === null)
? `${parent.prefix}${parent.taskId} | ${parent.description}`
: "--"
}</span>
</div>
<div>
<h3>Children</h3>
<table>
<tbody>
{#each children as child (child.taskId)}
<tr>
<td>{child.prefix + child.taskId}</td>
<td>{child.description}</td>
<td>{child.status}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
{/snippet}
{#snippet body(body: Task["body"], history: Task["bodyHistory"])}
<div>
<h2>Body</h2>
<p>{body}</p>
{#if history}
<h3>History</h3>
<p>{history}</p>
{/if}
</div>
<div>
<h2>Updates</h2>
<p>
{
task.updateChain
? JSON.stringify(task.updateChain)
: "--"
}
</p>
</div>
{/snippet}
{#snippet checklist(checklist: Task["checklist"])}
<div>
<h2>Checklist</h2>
<p>
{checklist ? JSON.stringify(checklist) : "--"}
</p>
</div>
{/snippet}
{#snippet integrations(integrations: Task["integrations"])}
<div>
<h2>Integrations</h2>
<p>
{integrations ? JSON.stringify(integrations) : "--"}
</p>
</div>
{/snippet}

View File

@ -1,11 +1,11 @@
<script lang="ts"> <script lang="ts">
import type { PageProps } from "./$types"; import type { PageProps } from "./$types";
let { data }: PageProps = $props(); let { data }: PageProps = $props();
</script> </script>
{#if data.tasks.status === "ok" && data.tasks.tasks !== undefined} {#if data.tasks.status === "ok" && data.tasks.data !== undefined}
<p>{data.tasks.tasks.length} total records.</p> <p>{data.tasks.data.length} total records.</p>
<table> <table>
<thead> <thead>
<tr> <tr>
@ -15,14 +15,14 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{#each data.tasks.tasks as task (task.id)} {#each data.tasks.data as task (task.id)}
<tr> <tr>
<td> <td>
<a <a href={`/tasks/${task.type.prefix}${task.taskId}`}>
href={`/tasks/${task.type.prefix}${task.taskId}`} {
> task.type?.prefix
{task.type?.prefix + + task.taskId
task.taskId} }
</a> </a>
</td> </td>
<td>{task.description}</td> <td>{task.description}</td>

View File

@ -1,9 +1,77 @@
import logger from "$lib/server/logger";
import type { NewTask } from "$lib/server/services/tasks";
import TasksService from "$lib/server/services/tasks"; import TasksService from "$lib/server/services/tasks";
import type { PageServerData } from "./$types"; import { error, fail, redirect } from "@sveltejs/kit";
import type { Actions, PageServerLoad } from "./$types";
const tasks = new TasksService("internal");
export const load: PageServerLoad = async ({ params }) => {
const taskResult = await tasks.getByTaskId([params.task_id]);
if (taskResult.status === "failure" || !taskResult.data || taskResult.data.length === 0) {
error(404, `No record for '${params.task_id}'.`);
}
if (taskResult.data.length > 1) {
logger.error(`Mulitple database entries match '${params.task_id}' when there should only be one.`);
error(500, "Internal error. Check the logs.");
}
const task = taskResult.data[0];
const [parent, children] = await Promise.all([
task.parent
? tasks.getByDbId([task.parent])
: Promise.resolve({ status: "ok", data: [] }),
tasks.getByParent(task.id),
]);
export const load: PageServerData = async ({ params }) => {
const tasks = new TasksService("internal");
return { return {
task: await tasks.getByTaskId([params.task_id]), task,
parent,
children,
}; };
}; };
export const actions: Actions = {
default: async ({ request, url }) => {
const formData = await request.formData();
const data = Object.fromEntries(formData);
if (!data.description || typeof data.description !== "string" || !data.description.trim()) {
return fail(400, { data, error: "Description is required.", field: "description" });
}
const createDateFn = (dateStr: unknown) => {
// The client now sends a full ISO string or an empty string
if (dateStr && typeof dateStr === "string") {
return new Date(dateStr);
}
return null;
};
const taskData: NewTask = {
id: data.id ? Number(data.id) : undefined,
description: typeof data.description === "string" ? data.description : null,
body: typeof data.body === "string" ? data.body : null,
status: typeof data.status === "string" ? data.status : null,
priority: typeof data.priority === "string" ? data.priority : null,
subtype: typeof data.subtype === "string" ? data.subtype : null,
openDate: createDateFn(data.openDate),
closeDate: createDateFn(data.closeDate),
};
const upsertResult = await tasks.upsert(taskData);
if (upsertResult.status === "failure") {
return fail(500, { data, error: upsertResult.error, code: upsertResult.code });
}
if (!upsertResult.data) {
return fail(500, { data, error: "An unexpected error occurred: missing result data." });
}
// On a successful save, redirect back to the main task page
throw redirect(303, url.pathname);
},
};

View File

@ -1,23 +1,70 @@
<script lang="ts"> <script lang="ts">
import type { PageProps } from "./$types"; import { page } from "$app/stores";
import type { Task } from "$lib/server/services/tasks";
import TaskEdit from "$lib/ui/Tasks/TaskEdit.svelte";
import TaskView from "$lib/ui/Tasks/TaskView.svelte";
import type { PageProps } from "./$types";
let { data }: PageProps = $props(); let { data, form }: PageProps = $props();
const task = data.task.tasks[0];
// The editing state is now derived directly from the URL query parameter.
let isEditing = $derived($page.url.searchParams.get("edit") === "true");
// --- Data Transformation for TaskView ---
const parentTask: Task | null =
(data.parent?.status === "ok" && data.parent.data)
? data.parent.data[0] ?? null
: null;
const childrenTask: Task[] =
(data.children.status === "ok" && data.children.data)
? data.children.data
: [];
const task = data.task;
const parent = {
prefix: parentTask?.type.prefix ?? null,
taskId: parentTask?.taskId ?? null,
description: parentTask?.description ?? null,
};
const children = childrenTask.map(x => ({
prefix: x.type.prefix,
taskId: x.taskId,
description: x.description,
status: x.status,
}));
</script> </script>
<h1>{`[[${task.type.prefix}${task.taskId}]] - ${task.description}`}</h1> <nav class="view-controls">
<div class="container"> {#if isEditing}
<p><strong>{task.status}</strong></p> <a href={$page.url.pathname} class="button">Cancel</a>
<p><strong>{task.priority}</strong></p> {:else}
</div> <a href={`${$page.url.pathname}?edit=true`} class="button">Edit Task</a>
<div> {/if}
<h2>Body</h2> </nav>
<p>{task.body}</p>
</div> {#if isEditing}
<TaskEdit {task} {form} />
{:else}
<TaskView {task} {parent} {children} />
{/if}
<style> <style>
.container { .view-controls {
margin-bottom: 2rem;
display: flex; display: flex;
gap: 1rem; justify-content: flex-end;
}
.button {
/* Basic button styling for links */
display: inline-block;
padding: 0.5rem 1rem;
border: 1px solid #ccc;
border-radius: 4px;
text-decoration: none;
color: #333;
background-color: #f0f0f0;
}
.button:hover {
background-color: #e0e0e0;
} }
</style> </style>

View File

@ -1,6 +1,6 @@
import { faker } from "@faker-js/faker";
import { db } from "../src/lib/server/db/db"; import { db } from "../src/lib/server/db/db";
import { tasks, taskTypes } from "../src/lib/server/db/schema/tasks"; import { tasks, taskTypes } from "../src/lib/server/db/schema/tasks";
import { faker } from "@faker-js/faker";
import logger from "../src/lib/server/logger"; import logger from "../src/lib/server/logger";
async function resetDatabase() { async function resetDatabase() {
@ -41,7 +41,8 @@ async function seedDatabase(count: number) {
// a random phrase in each description // a random phrase in each description
description: faker.lorem.sentence(), description: faker.lorem.sentence(),
// open_date with a valid date // open_date with a valid date
openDate: faker.date.recent({ days: 30 }).toISOString(), openDate: faker.date.recent({ days: 30 }),
closeDate: faker.date.soon({ days: 30 }),
// a random paragraph in body // a random paragraph in body
body: faker.lorem.paragraphs(3), body: faker.lorem.paragraphs(3),
// other fields as needed // other fields as needed
@ -86,7 +87,7 @@ async function main() {
await resetDatabase(); await resetDatabase();
await seedDatabase(count); await seedDatabase(count);
} else { } else {
logger.error('Invalid mode. Use "seed" or "reset".'); logger.error("Invalid mode. Use \"seed\" or \"reset\".");
process.exit(1); process.exit(1);
} }