Compare commits

..

No commits in common. "b13cee614efb1878fafde391f8d17b6f8e4f2a9d" and "14ece0bed08f5943156751f9de91b2f85d578af7" have entirely different histories.

10 changed files with 82 additions and 588 deletions

View File

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

View File

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

View File

@ -1,12 +0,0 @@
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,19 +1,6 @@
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";
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 {
private db: DB;
private caller: "internal" | "api";
@ -23,31 +10,20 @@ class TasksService {
this.caller = caller;
}
private async _executeQuery(
query: () => Promise<TaskOrNull[]>,
): Promise<ServiceResponse<Task[], "INTERNAL_ERROR" | "DATA_INTEGRITY_VIOLATION">> {
try {
const tasks = await query();
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(", ")}`);
return {
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) {
logger.error({ msg: "Error querying the database.", 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 } }));
try {
const tasks = await this.db.query.tasks.findMany({
with: {
type: true,
},
});
logger.debug(`Found ${tasks.length} records.`);
return { tasks, status: "ok" };
} catch (error) {
logger.error({ msg: "Error querying the database.", error });
return { status: "failed", error };
}
}
public async getByTaskId(taskIds: Array<string>) {
@ -65,73 +41,29 @@ class TasksService {
: `${taskIds.length} records`
}.`,
);
return this._executeQuery(() =>
this.db.query.tasks.findMany({
try {
const tasks = await db.query.tasks.findMany({
with: { type: true },
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>) {
logger.info(`Fetching ${ids.length} records.`);
return this._executeQuery(() =>
this.db.query.tasks.findMany({
try {
const tasks = await db.query.tasks.findMany({
with: { type: true },
where: (tasks, { inArray }) => inArray(tasks.id, ids),
})
);
}
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 } };
}
});
return { tasks, status: "ok" };
} catch (error) {
logger.error(error, "Error upserting task.");
return {
status: "failure",
error: "An internal server error occurred while trying to save the task.",
code: "INTERNAL_ERROR",
};
logger.error({ msg: "Error querying the database.", error });
return { status: "failed", error };
}
}
}

View File

@ -1,134 +0,0 @@
<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

@ -1,171 +0,0 @@
<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,36 +1,36 @@
<script lang="ts">
import type { PageProps } from "./$types";
import type { PageProps } from "./$types";
let { data }: PageProps = $props();
let { data }: PageProps = $props();
</script>
{#if data.tasks.status === "ok" && data.tasks.data !== undefined}
<p>{data.tasks.data.length} total records.</p>
<table>
<thead>
<tr>
<td><strong>Id</strong></td>
<td><strong>Description</strong></td>
<td><strong>Status</strong></td>
</tr>
</thead>
<tbody>
{#each data.tasks.data as task (task.id)}
<tr>
<td>
<a href={`/tasks/${task.type.prefix}${task.taskId}`}>
{
task.type?.prefix
+ task.taskId
}
</a>
</td>
<td>{task.description}</td>
<td>{task.status}</td>
</tr>
{/each}
</tbody>
</table>
{#if data.tasks.status === "ok" && data.tasks.tasks !== undefined}
<p>{data.tasks.tasks.length} total records.</p>
<table>
<thead>
<tr>
<td><strong>Id</strong></td>
<td><strong>Description</strong></td>
<td><strong>Status</strong></td>
</tr>
</thead>
<tbody>
{#each data.tasks.tasks as task (task.id)}
<tr>
<td>
<a
href={`/tasks/${task.type.prefix}${task.taskId}`}
>
{task.type?.prefix +
task.taskId}
</a>
</td>
<td>{task.description}</td>
<td>{task.status}</td>
</tr>
{/each}
</tbody>
</table>
{:else}
<p>There was an error accessing the database.</p>
<p>There was an error accessing the database.</p>
{/if}

View File

@ -1,77 +1,9 @@
import logger from "$lib/server/logger";
import type { NewTask } from "$lib/server/services/tasks";
import TasksService from "$lib/server/services/tasks";
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),
]);
import type { PageServerData } from "./$types";
export const load: PageServerData = async ({ params }) => {
const tasks = new TasksService("internal");
return {
task,
parent,
children,
task: await tasks.getByTaskId([params.task_id]),
};
};
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,70 +1,23 @@
<script lang="ts">
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";
import type { PageProps } from "./$types";
let { data, form }: PageProps = $props();
// 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,
}));
let { data }: PageProps = $props();
const task = data.task.tasks[0];
</script>
<nav class="view-controls">
{#if isEditing}
<a href={$page.url.pathname} class="button">Cancel</a>
{:else}
<a href={`${$page.url.pathname}?edit=true`} class="button">Edit Task</a>
{/if}
</nav>
{#if isEditing}
<TaskEdit {task} {form} />
{:else}
<TaskView {task} {parent} {children} />
{/if}
<h1>{`[[${task.type.prefix}${task.taskId}]] - ${task.description}`}</h1>
<div class="container">
<p><strong>{task.status}</strong></p>
<p><strong>{task.priority}</strong></p>
</div>
<div>
<h2>Body</h2>
<p>{task.body}</p>
</div>
<style>
.view-controls {
margin-bottom: 2rem;
display: flex;
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;
}
.container {
display: flex;
gap: 1rem;
}
</style>

View File

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