۱۱. پروژهٔ دفتر هزینه؛ مدل مستقل، ویرایش و گزارش قابل اعتماد
اکنون از مثال کوتاه به یک برنامهٔ چندبخشی میرسیم. دفتر هزینه باید افزودن، ویرایش، حذف و فیلتر داشته باشد؛ اما ارزش آموزشی پروژه در تعداد دکمهها نیست. باید بتوانیم قانون مبلغ و تاریخ را بدون مرورگر آزمون کنیم، تغییر را قبل از اعمال اعتبارسنجی کنیم و توضیح بدهیم مجموع گزارش از کدام داده محاسبه شده است. فایلهای projects/expenses نقطهٔ شروع این کارند.
سه مسئولیت را پیدا کنید
model.mjs داده و قانون را میشناسد: parseAmount، validateExpense، upsert، remove و total. app.mjs فرم، رویداد و وضعیت صفحه را هماهنگ میکند. ابزارهای shared/ui.mjs ساخت عنصر، نمایش مبلغ و ذخیرهٔ JSON را انجام میدهند. این تقسیمبندی مانع وارد شدن document به مدل است. اگر برای آزمون total مجبور به ساخت input شوید، مرزها به بازبینی نیاز دارند.
مثالهای این درس را در فایل .mjs واقع در ریشهٔ پروژهٔ دانلودی اجرا کنید؛ سپس همان فایل را با node اجرا کنید. شناسه و تاریخ نمونه ثابتاند تا خروجی قابل تکرار باشد. تاریخ ورودی پروژه میلادی و قالب آن YYYY-MM-DD است؛ نمایش فارسی مبلغ به معنی تبدیل تقویم تاریخ نیست.
مثال اول؛ ویرایش، افزودن دوباره نیست
import { upsert, total, parseAmount } from './projects/expenses/model.mjs';
const original = [{
id: '11111111-1111-4111-8111-111111111111',
title: 'کتاب', amount: 150000, category: 'آموزش', date: '2026-10-28'
}];
const edited = upsert(original, {
...original[0], amount: parseAmount('۱۸۰۰۰۰')
});
console.log(edited.length); // 1
console.log(total(edited)); // 180000
console.log(original[0].amount); // 150000
console.log(edited === original); // falseupsert بر اساس شناسه تصمیم میگیرد؛ شناسهٔ موجود را جایگزین و شناسهٔ تازه را اضافه میکند. در رابط، editing شناسهٔ هزینهٔ در حال ویرایش را نگه میدارد. اگر هنگام ذخیرهٔ ویرایش UUID تازه بسازیم، ردیف قبلی باقی میماند و هزینه دوبار حساب میشود. مدل آرایهٔ اولیه را تغییر نمیدهد؛ با این حال این قرارداد را به «کپی عمیق همهٔ دادهها» تعمیم ندهید. رفتار هر تابع را از کد خودش بخوانید.
مثال دوم؛ گزارش از داده، نه متن صفحه
به همان فایل نمونه، کد زیر را اضافه کنید. این گزارش کوتاه، الگوریتم گروهبندی را مستقل نشان میدهد. در نسخهٔ کامل همراه، تابع categorySummary در model.mjs همین مسئولیت را با اعتبارسنجی ورودی و محاسبهٔ میانگین انجام میدهد و رابط جدول گزارش دستهها را دارد. ابتدا الگوریتم ساده را بخوانید، سپس تفاوتش با نسخهٔ کامل را بررسی کنید.
const rows = [...edited, {
id: '22222222-2222-4222-8222-222222222222',
title: 'ناهار', amount: 90000, category: 'خوراک', date: '2026-10-28'
}];
function categoryReport(items) {
const groups = new Map();
for (const item of items) {
const group = groups.get(item.category) ?? [];
group.push(item);
groups.set(item.category, group);
}
return [...groups].map(([category, group]) => ({
category, count: group.length, amount: total(group)
})).sort((a, b) => b.amount - a.amount);
}
console.table(categoryReport(rows));
// آموزش: count=1, amount=180000
// خوراک: count=1, amount=90000
console.log(total(rows.filter(item => item.category === 'خوراک'))); // 90000از متن «۱۸۰٬۰۰۰ تومان» صفحه عدد استخراج نکردیم؛ نمایش از مدل مشتق میشود. گزارش نیز دادهٔ اصلی را مرتب نمیکند. این نکته جلوی خطای پنهانی را میگیرد که مرتبسازی گزارش، ترتیب فهرست را هم تغییر بدهد. جمع مبلغها باید safe integer باقی بماند؛ total در مدل همراه، خروج از این محدوده را خطا میداند. categorySummary همهٔ چهار دسته را حتی با تعداد صفر برمیگرداند؛ average با Math.round تا تومان صحیح گرد میشود. گزارش دستهها بر کل دادههاست، درحالیکه مجموع بالای فهرست بر فیلتر فعلی محاسبه میشود. عنوان این دو گزارش را در رابط بررسی کنید تا کاربر تفاوت آنها را بفهمد.
ترتیب یک تغییر موفق
در رویداد submit ابتدا FormData خوانده میشود، سپس مبلغ تبدیل و دادهٔ کامل اعتبارسنجی میشود. فقط بعد از آن مقدار جدید جایگزین items میشود. پس از تغییر، ذخیره و رندر انجام میشود. در این پروژه اگر ذخیرهٔ مرورگر شکست بخورد، تغییر در حافظه باقی میماند و پیام صریح نمایش داده میشود؛ «ثبت در حافظه» و «ذخیرهٔ ماندگار» را یک وضعیت ندانید. کاربر باید بتواند JSON بگیرد.
حذف نیز باید وضعیت ویرایش را در نظر بگیرد. اگر ردیفی که در فرم باز است حذف شود، فرم نباید هنوز در حالت ویرایش شناسهٔ حذفشده بماند. در فایل آماده، reset این هماهنگی را انجام میدهد. فیلتر هم فقط نمای داده را عوض میکند؛ حذف یا ذخیره نباید تنها روی زیرمجموعهٔ فیلترشده انجام شود و باقی داده را ناخواسته دور بریزد.
تمرین مستقل و پاسخ تشریحی
گزارش بازهٔ تاریخ بسازید: از روز شروع تا پایان، شامل هر دو مرز. ورودی خالی را «بدون مرز» تعریف کنید و شروع بزرگتر از پایان را خطا بدانید. از تاریخهای معتبر استاندارد استفاده کنید؛ مقایسهٔ رشتهٔ YYYY-MM-DD پس از اعتبارسنجی برای این قرارداد مفید است. پاسخ راهنما، تابعی خالص است که ابتدا قرارداد بازه را بررسی، سپس rows را filter و نتیجه را با total جمع میکند. عنوان تاریخ را نیز در رابط واضح بنویسید.
معیار تحویل: ویرایش تعداد ردیفها را زیاد نکند؛ فیلتر دادهٔ اصلی را تغییر ندهد؛ بازهٔ تکروزه درست باشد؛ تاریخ نامعتبر پذیرفته نشود؛ پس از حذف ردیف در حال ویرایش، فرم به حالت ثبت برگردد. برای هر مورد یک ورودی و خروجی واقعی در یادداشت تحویل بنویسید.
منابع انگلیسی برای مطالعهٔ تکمیلی
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/filter
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/isSafeInteger
تألیف و مثالهای مستقل: تحریریه درسآوند.