Menu
CoddyTech

Tenggat Waktu

Termasuk bagian REST API dalam perjalanan Node.js di Coddy. Pelajaran 25 dari 25.

Tugas memiliki tanggal tenggat, dan papan menampilkan tugas mana yang terlambat. Tanggal tanpa waktu ditulis sebagai string ISO, 2026-03-15, yang diperiksa oleh zod dengan z.iso.date():

z.iso.date().safeParse('2026-07-01').success   // true
z.iso.date().safeParse('2026-02-30').success   // false: tanggal tersebut tidak ada
'2026-03-01' < '2026-03-15'                    // true

Tanggal ISO memiliki panjang yang sama dan menempatkan tahun di awal, jadi membandingkannya sebagai string menghasilkan urutan waktu. Jalur tetap seperti /overdue harus ditulis sebelum /:id di router: Express mencoba rute secara berurutan, dan /:id cocok dengan kata apa pun.

challenge icon

Tantangan

Mudah

Board menampilkan tugas yang terlambat. Tambahkan tanggal tenggat opsional pada tugas, lalu tambahkan daftar tugas yang terlambat.

  1. Di routes/tasks.js, tambahkan field due opsional ke skema Task, dengan validasi z.iso.date(). Skema ini hanya menerima tanggal yang valid dengan format YYYY-MM-DD. "2026-03-15" lolos, sedangkan "15/03/2026", "2026-02-30" dan "tomorrow" menghasilkan error invalid_body 400 seperti biasanya. Pastikan POST menyimpan due pada tugas baru.
  2. Tambahkan GET /api/v1/tasks/overdue ke router. Deklarasikan rute ini di atas rute /:id, atau Express akan mengirim overdue ke /:id sebagai id.
  3. Rute ini membaca tanggal pembanding dari ?today=, misalnya ?today=2026-03-20. Jika today tidak ada, rute menggunakan tanggal saat ini dengan format YYYY-MM-DD. Nilai today yang bukan tanggal valid, seperti ?today=soon, menghasilkan status 400:
    { "error": { "code": "invalid_query", "message": "today must be a date like 2026-03-10" } }
  4. Rute ini mengembalikan array tugas biasa, bukan object halaman yang dikembalikan oleh daftar tugas. Tugas terlambat jika memenuhi ketiga kondisi ini: tugas belum selesai, memiliki tanggal due, dan tanggal tersebut sebelum today. Tugas yang tanggal tenggatnya tepat pada today belum terlambat. Urutkan array berdasarkan due, dari tanggal paling awal.

Coba: setelah menjalankan Run dengan data baru, kirim POST { "title": "Renew the lease", "due": "2026-03-15" }, lalu { "title": "File the taxes", "due": "2026-03-01" } ke /api/v1/tasks. Keduanya mengembalikan 201 beserta due-nya. Lalu GET /api/v1/tasks/overdue?today=2026-03-20 mengembalikan tanggal tenggat yang lebih awal terlebih dahulu (waktu createdAt-mu akan berbeda):

[
  { "id": 5, "title": "File the taxes", "done": false, "labels": [], "due": "2026-03-01", "createdAt": "2026-10-30T09:15:02.118Z" },
  { "id": 4, "title": "Renew the lease", "done": false, "labels": [], "due": "2026-03-15", "createdAt": "2026-10-30T09:14:40.006Z" }
]

GET /api/v1/tasks/overdue?today=2026-03-15 hanya mengembalikan "File the taxes". GET /api/v1/tasks/overdue?today=soon mengembalikan 400 dengan kode invalid_query.

Coba sendiri

import { Router } from 'express';
import { z } from 'zod';
import { config } from '../config.js';
import { sendError, sendInvalid } from '../errors.js';
import { nextId, tasks } from '../store.js';

const router = Router();

// Task lengkap, seperti yang dikirim oleh PUT. .strict() menolak field yang tidak tercantum.
const Task = z
    .object({
        title: z.string().trim().min(1).max(100),
        done: z.boolean(),
        labels: z.array(z.string().trim().min(1).max(20)).max(5).optional(),
    })
    .strict();
const NewTask = Task.partial({ done: true });
const TaskChanges = Task.partial();
const SORT_FIELDS = ['title', 'createdAt'];

// Bernilai true jika task lain sudah memiliki judul ini, tanpa membedakan huruf besar dan kecil.
const titleTaken = (title, exceptId) =>
    tasks.some((t) => t.id !== exceptId && t.title.toLowerCase() === title.toLowerCase());

router.get('/', (req, res) => {
    const { done, q, sort, label } = req.query;
    let list = tasks;
    if (done !== undefined) {
        if (done !== 'true' && done !== 'false') {
            return sendError(res, 400, 'invalid_query', 'done must be true or false');
        }
        list = list.filter((t) => t.done === (done === 'true'));
    }
    if (q !== undefined) {
        const words = String(q).toLowerCase();
        list = list.filter((t) => t.title.toLowerCase().includes(words));
    }
    if (label !== undefined) {
        list = list.filter((t) => t.labels.includes(String(label)));
    }
    if (sort !== undefined) {
        const field = String(sort).startsWith('-') ? String(sort).slice(1) : String(sort);
        if (!SORT_FIELDS.includes(field)) {
            return sendError(
                res,
                400,
                'invalid_query',
                'sort must be title or createdAt, with - in front to reverse',
            );
        }
        const direction = String(sort).startsWith('-') ? -1 : 1;
        list = list.toSorted((a, b) => a[field].localeCompare(b[field]) * direction);
    }
    const page = req.query.page === undefined ? 1 : Number(req.query.page);
    const limit = req.query.limit === undefined ? config.pageSize : Number(req.query.limit);
    if (!Number.isInteger(page) || page < 1) {
        return sendError(res, 400, 'invalid_query', 'page must be a whole number from 1');
    }
    if (!Number.isInteger(limit) || limit < 1 || limit > 50) {
        return sendError(res, 400, 'invalid_query', 'limit must be a whole number from 1 to 50');
    }
    const start = (page - 1) * limit;
    res.json({
        items: list.slice(start, start + limit),
        page,
        total: list.length,
        pages: Math.ceil(list.length / limit),
    });
});

router.get('/:id', (req, res) => {
    const task = tasks.find((t) => t.id === Number(req.params.id));
    if (!task) {
        return sendError(res, 404, 'not_found', 'Task not found');
    }
    res.json(task);
});

router.post('/', (req, res) => {
    const result = NewTask.safeParse(req.body);
    if (!result.success) {
        return sendInvalid(res, result.error);
    }
    const { title, done = false, labels = [] } = result.data;
    if (titleTaken(title)) {
        return sendError(res, 409, 'conflict', 'A task with this title already exists');
    }
    const task = { id: nextId(), title, done, labels, createdAt: new Date().toISOString() };
    tasks.push(task);
    res.status(201).location(`/api/v1/tasks/${task.id}`).json(task);
});

router.patch('/:id', (req, res) => {
    const task = tasks.find((t) => t.id === Number(req.params.id));
    if (!task) {
        return sendError(res, 404, 'not_found', 'Task not found');
    }
    const result = TaskChanges.safeParse(req.body);
    if (!result.success) {
        return sendInvalid(res, result.error);
    }
    if (result.data.title !== undefined && titleTaken(result.data.title, task.id)) {
        return sendError(res, 409, 'conflict', 'A task with this title already exists');
    }
    Object.assign(task, result.data);
    res.json(task);
});

router.put('/:id', (req, res) => {
    const index = tasks.findIndex((t) => t.id === Number(req.params.id));
    if (index === -1) {
        return sendError(res, 404, 'not_found', 'Task not found');
    }
    const result = Task.safeParse(req.body);
    if (!result.success) {
        return sendInvalid(res, result.error);
    }
    const { id, createdAt } = tasks[index];
    if (titleTaken(result.data.title, id)) {
        return sendError(res, 409, 'conflict', 'A task with this title already exists');
    }
    tasks[index] = { id, ...result.data, labels: result.data.labels ?? [], createdAt };
    res.json(tasks[index]);
});

router.delete('/:id', (req, res) => {
    const index = tasks.findIndex((t) => t.id === Number(req.params.id));
    if (index === -1) {
        return sendError(res, 404, 'not_found', 'Task not found');
    }
    tasks.splice(index, 1);
    res.status(204).end();
});

export default router;

Semua pelajaran di REST API

Latihan sendiri: Playground Express