224 lines
5.1 KiB
TypeScript
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;
|
||
|
|
}
|