Files
coms3011a-lab1/lib/tasks.ts
2026-08-11 20:54:37 +02:00

224 lines
5.1 KiB
TypeScript

import type {
Task,
TaskInput,
TaskSort,
TaskStatus,
TaskUpdate,
} from "@/types/task";
import { getDatabase } from "./db";
type TaskRow = {
id: number;
title: string;
description: string;
due_date: string;
topic: string;
status: TaskStatus;
archived_at: string | null;
created_at: string;
updated_at: string;
};
const statuses: TaskStatus[] = ["Todo", "In-Progress", "Complete"];
function isTaskStatus(value: unknown): value is TaskStatus {
return typeof value === "string" && statuses.includes(value as TaskStatus);
}
function todayIsoDate() {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, "0");
const day = String(now.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
function isDateInput(value: unknown): value is string {
return typeof value === "string" && /^\d{4}-\d{2}-\d{2}$/.test(value);
}
function cleanString(value: unknown, field: string) {
if (typeof value !== "string") {
throw new Error(`${field} must be text.`);
}
return value.trim();
}
function normalizeTaskInput(input: Partial<TaskInput>): TaskInput {
const title = cleanString(input.title, "Title");
const description =
typeof input.description === "string" ? input.description.trim() : "";
const dueDate = input.dueDate;
const topic = cleanString(input.topic, "Topic");
if (!title) {
throw new Error("Title is required.");
}
if (!topic) {
throw new Error("Topic is required.");
}
if (!isDateInput(dueDate)) {
throw new Error("Due date must use YYYY-MM-DD format.");
}
return {
title,
description,
dueDate,
topic,
};
}
function toTask(row: TaskRow): Task {
return {
id: row.id,
title: row.title,
description: row.description,
dueDate: row.due_date,
topic: row.topic,
status: row.status,
archivedAt: row.archived_at,
createdAt: row.created_at,
updatedAt: row.updated_at,
isOverdue:
!row.archived_at && row.status !== "Complete" && row.due_date < todayIsoDate(),
};
}
function rowToTask(row: unknown) {
if (!row) {
return null;
}
return toTask(row as TaskRow);
}
function sortClause(sort: TaskSort) {
if (sort === "topic") {
return "topic COLLATE NOCASE ASC, due_date ASC, id ASC";
}
if (sort === "status") {
return "CASE status WHEN 'Todo' THEN 1 WHEN 'In-Progress' THEN 2 ELSE 3 END ASC, due_date ASC, id ASC";
}
if (sort === "dueDate") {
return "due_date ASC, topic COLLATE NOCASE ASC, id ASC";
}
return "created_at DESC, id DESC";
}
export function listTasks({
includeArchived = false,
sort = "createdAt",
}: {
includeArchived?: boolean;
sort?: TaskSort;
} = {}) {
const database = getDatabase();
const where = includeArchived ? "" : "WHERE archived_at IS NULL";
const rows = database
.prepare(`SELECT * FROM tasks ${where} ORDER BY ${sortClause(sort)}`)
.all() as TaskRow[];
return rows.map(toTask);
}
export function getTask(id: number) {
const database = getDatabase();
return rowToTask(database.prepare("SELECT * FROM tasks WHERE id = ?").get(id));
}
export function createTask(input: Partial<TaskInput>) {
const database = getDatabase();
const task = normalizeTaskInput(input);
const result = database
.prepare(
`INSERT INTO tasks (title, description, due_date, topic, status)
VALUES (?, ?, ?, ?, 'Todo')`
)
.run(task.title, task.description, task.dueDate, task.topic);
const created = getTask(Number(result.lastInsertRowid));
if (!created) {
throw new Error("Task could not be created.");
}
return created;
}
export function updateTask(id: number, updates: TaskUpdate) {
const current = getTask(id);
if (!current) {
throw new Error("Task not found.");
}
const nextStatus = updates.status ?? current.status;
if (!isTaskStatus(nextStatus)) {
throw new Error("Status must be Todo, In-Progress, or Complete.");
}
const nextTask = normalizeTaskInput({
title: updates.title ?? current.title,
description: updates.description ?? current.description,
dueDate: updates.dueDate ?? current.dueDate,
topic: updates.topic ?? current.topic,
});
const database = getDatabase();
database
.prepare(
`UPDATE tasks
SET title = ?, description = ?, due_date = ?, topic = ?, status = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?`
)
.run(
nextTask.title,
nextTask.description,
nextTask.dueDate,
nextTask.topic,
nextStatus,
id
);
const updated = getTask(id);
if (!updated) {
throw new Error("Task could not be updated.");
}
return updated;
}
export function archiveTask(id: number) {
if (!getTask(id)) {
throw new Error("Task not found.");
}
const database = getDatabase();
database
.prepare(
`UPDATE tasks
SET archived_at = COALESCE(archived_at, CURRENT_TIMESTAMP),
updated_at = CURRENT_TIMESTAMP
WHERE id = ?`
)
.run(id);
const archived = getTask(id);
if (!archived) {
throw new Error("Task could not be archived.");
}
return archived;
}