feat: add persistent task operations and API
This commit is contained in:
223
lib/tasks.ts
Normal file
223
lib/tasks.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user