Menu
CoddyTech

תאריכי יעד

חלק מהיחידה REST APIs במסלול ה-Node.js של Coddy. שיעור 25 מתוך 25.

למשימות יש תאריך יעד, והלוח מציג אילו מהן באיחור. תאריך ללא שעה מועבר כמחרוזת ISO, 2026-03-15, ש-zod בודקת באמצעות z.iso.date():

z.iso.date().safeParse('2026-07-01').success   // true
z.iso.date().safeParse('2026-02-30').success   // false: יום כזה אינו קיים
'2026-03-01' < '2026-03-15'                    // true

לתאריכי ISO יש אורך זהה, והשנה מופיעה בהם ראשונה, לכן השוואה שלהם כמחרוזות נותנת סדר כרונולוגי. נתיב קבוע כמו /overdue חייב להופיע לפני /:id בנתב: Express מנסה את הנתיבים לפי הסדר, ו-/:id תואם לכל מילה.

challenge icon

אתגר

קל

הלוח מציג אילו משימות באיחור. הוסיפו למשימות תאריך יעד אופציונלי והוסיפו רשימה של המשימות שעבר מועד היעד שלהן.

  1. ב־routes/tasks.js, הוסיפו שדה אופציונלי due לסכמת Task, ובדקו אותו באמצעות z.iso.date(). הוא מקבל רק תאריכים אמיתיים הכתובים בפורמט YYYY-MM-DD. הערך "2026-03-15" עובר את הבדיקה, ואילו "15/03/2026", "2026-02-30" ו־"tomorrow" גורמים לשגיאת invalid_body הרגילה עם סטטוס 400. ודאו ש־POST שומר את due במשימה החדשה.
  2. הוסיפו לנתב את GET /api/v1/tasks/overdue. הכריזו עליו מעל הנתיב /:id, אחרת Express ישלח את overdue אל /:id כמזהה.
  3. הנתיב קורא את התאריך להשוואה מתוך ?today=, למשל ?today=2026-03-20. אם today לא מצוין, הוא משתמש בתאריך הנוכחי, הכתוב בפורמט YYYY-MM-DD. ערך today שאינו תאריך אמיתי, כמו ?today=soon, מחזיר סטטוס 400:
    { "error": { "code": "invalid_query", "message": "today must be a date like 2026-03-10" } }
  4. הנתיב מחזיר מערך רגיל של משימות, ולא את אובייקט העמוד שמחזירה רשימת המשימות. משימה נמצאת באיחור כאשר מתקיימים כל שלושת התנאים: היא לא הושלמה, יש לה תאריך due, והתאריך הזה קודם ל־today. משימה שתאריך היעד שלה הוא today עצמו עדיין אינה באיחור. מיינו את המערך לפי due, מהתאריך המוקדם ביותר ואילך.

נסו זאת: בהרצה חדשה, שלחו POST עם { "title": "Renew the lease", "due": "2026-03-15" } ולאחר מכן עם { "title": "File the taxes", "due": "2026-03-01" } אל /api/v1/tasks. שניהם מחזירים 201 עם due שלהם. לאחר מכן, GET /api/v1/tasks/overdue?today=2026-03-20 מחזיר קודם את המשימה עם תאריך היעד המוקדם יותר (ערכי createdAt שלכם יהיו שונים):

[
  { "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 מחזיר רק את "File the taxes". GET /api/v1/tasks/overdue?today=soon מחזיר 400 עם הקוד invalid_query.

נסו בעצמכם

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();

// משימה שלמה, כפי ש-PUT שולח אותה. .strict() דוחה שדות שאינם מופיעים ברשימה שלו.
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'];

// True אם למשימה אחרת כבר יש כותרת זו, ללא הבחנה בין אותיות גדולות לקטנות.
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;

כל השיעורים ביחידה REST APIs

תרגלו בעצמכם: Express אונליין