Compare commits
14 Commits
14ece0bed0
...
b13cee614e
| Author | SHA1 | Date | |
|---|---|---|---|
| b13cee614e | |||
| bee3fdf2ca | |||
| b2d7b4983a | |||
| 2962c3aace | |||
| eddaf02824 | |||
| f63f5ccc5a | |||
| 19ff88c661 | |||
| 95826cde40 | |||
| a4a09380b6 | |||
| e2d902d9ca | |||
| fb5a45587a | |||
| 9c219054d3 | |||
| 5bc27f6061 | |||
| 0990cf5c56 |
@ -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"
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
12
src/lib/server/services/service.types.ts
Normal file
12
src/lib/server/services/service.types.ts
Normal 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>;
|
||||||
@ -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",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
134
src/lib/ui/Tasks/TaskEdit.svelte
Normal file
134
src/lib/ui/Tasks/TaskEdit.svelte
Normal 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>
|
||||||
171
src/lib/ui/Tasks/TaskView.svelte
Normal file
171
src/lib/ui/Tasks/TaskView.svelte
Normal 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}
|
||||||
|
|
||||||
@ -1,36 +1,36 @@
|
|||||||
<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>
|
||||||
<td><strong>Id</strong></td>
|
<td><strong>Id</strong></td>
|
||||||
<td><strong>Description</strong></td>
|
<td><strong>Description</strong></td>
|
||||||
<td><strong>Status</strong></td>
|
<td><strong>Status</strong></td>
|
||||||
</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>
|
||||||
<td>{task.status}</td>
|
<td>{task.status}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{:else}
|
{:else}
|
||||||
<p>There was an error accessing the database.</p>
|
<p>There was an error accessing the database.</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@ -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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|||||||
@ -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 {
|
||||||
display: flex;
|
margin-bottom: 2rem;
|
||||||
gap: 1rem;
|
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;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -96,4 +97,4 @@ async function main() {
|
|||||||
main().catch((e) => {
|
main().catch((e) => {
|
||||||
logger.error(e, "An error occurred during the script execution.");
|
logger.error(e, "An error occurred during the script execution.");
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user