«كل أسبوع، وكل أسبوعين، إلا يوم الخميس.» كل من أدار جدولاً قال جملة كهذه. نادٍ رياضي يقدّم الملاكمة كل مساء، وحصة مصارعة مختلفة كل أسبوع. عيادة علاج طبيعي تُبدّل بين حصة في الصالة وجلسة في المسبح. مركز أعمال يستضيف اجتماعاً قصيراً كل صباح، وجلسة تخطيط كل أسبوعين يوم الأحد، واجتماعاً لمجلس الإدارة مرة كل أربعة أسابيع. ثم يأتي الواقع: أخصائية في إجازة يوم الثلاثاء، وقاعة قيد الطلاء، وعطلة رسمية تصادف يوم الأربعاء.
بنينا Koredyne Schedule ليتعامل مع كل ذلك بمحرك واحد. وهذا المقال عن القرار الذي جعل الأمر بسيطاً: لا نخزّن التقويم أبداً. نخزّن القواعد والاستثناءات منها، لا الحصص التي تنتج عن القواعد، ونحسب منها مواعيد كل يوم.
ويمكنك تجربته أثناء القراءة: النسخة التجريبية تمنح كل زائر نسخة خاصة به، فاسحب الحصص وألغِها واحذف ما تشاء.
طريقتان لبناء الجدول
الطريقة البديهية هي تخزين كل حصة على حدة. عندما ينشئ أحدهم «ملاكمة، كل أحد الساعة 6 مساءً»، يكتب النظام سجلاً لكل يوم أحد حتى تاريخ معيّن، وتقرأ شاشة التقويم هذه السجلات.
تنجح هذه الطريقة في اليوم الأول، ثم تبدأ الأسئلة. لأي فترة مقبلة نُنشئ السجلات، وما الذي يواصل إضافتها بعد ذلك؟ انقل الحصة إلى 7 مساءً، وستعيد كتابة مئات السجلات مع تجاوز ما عدّله أحدهم يدوياً. واسأل «هل سيحدث أي تعارض في مارس القادم؟»، ولن تجد إجابة إلا عن السجلات التي صادف أنك أنشأتها.
أما الطريقة الأخرى فهي تخزين القاعدة مرة واحدة: «ملاكمة، كل أحد من 6 إلى 7 مساءً، في الحلبة، في الأسبوع 1 من دورة مدتها أسبوعان، ابتداءً من 4 أكتوبر». وعندما يُفتح التقويم على أي أسبوع، حتى لو كان بعد عامين، يحدّد التطبيق حصص كل يوم. التقويم حقيقي، لكنه يُرسم من القواعد بدلاً من أن يُقرأ من جدول في قاعدة البيانات.
ويتطلب ذلك ثلاثة أمور: نموذج بيانات يفصل بين أجزاء الجدول، وطريقة لحساب التناوب، وطريقة لإضافة الاستثناءات فوق القواعد.
افصل بين «ماذا» و«أين» و«متى»
يجمع السطر الواحد في الجدول عدة معلومات. فعبارة «ملاكمة مع ماركو، الحلبة، كل أحد الساعة 6 مساءً، للأطفال، الأسبوع 1 من دورة مدتها أسبوعان» هي في الحقيقة أربع معلومات:
- الحصة: ماذا ومن. نشاط ومن يقدّمه: ملاكمة مع ماركو، أو اجتماع لمجلس الإدارة يديره مديران معاً.
- موعد الحصة: أين ومتى. قاعة ويوم من أيام الأسبوع ووقت، مع وصف اختياري مثل «Kids» أو «Stage 2».
- السلسلة: الحصص المرتبطة بكل موعد حصة، والأسبوع المخصّص لها من دورة السلسلة. للسلسلة أسبوع بداية، ونهاية اختيارية، وطول دورة.
- الخطة: كيف تُباع السلسلة. 8 حصص شهرياً، أو عدد غير محدود، أو 3 أشهر بسعر واحد.
هناك تفصيلان مهمان. رقم الأسبوع يتبع البند لا الحصة، لأن الحصة نفسها قد تكون في الأسبوع 1 في سلسلة، وفي الأسبوع 3 في سلسلة أخرى. والخطط منفصلة عن السلاسل: «شهر واحد، 12 حصة» و«3 أشهر، غير محدود» طريقتان لبيع الحصص نفسها.
التناوب مجرد عملية حسابية
تتكرر السلسلة في دورة من N أسابيع. الأسبوع 1 هو أسبوع انطلاقها، أي نقطة البداية (وهي دائماً أول يوم في الأسبوع، وترفض واجهة برمجة التطبيقات (API) أي تاريخ آخر)، ومنه يبدأ العدّ. لذلك يكفي سطر واحد من الحساب لمعرفة رقم أسبوع الدورة في أي تاريخ:
function cyclePosition(series, date) {
if (date < series.anchor_date) return null // not started yet
if (series.end_date && date >= series.end_date) return null // already ended
const weeks = Math.floor(daysBetween(series.anchor_date, date) / 7)
return (weeks % series.cycle_length) + 1 // 1-based
}هذا هو محرك التناوب كله. السلسلة الأسبوعية دورتها أسبوع واحد، فكل أسبوع هو الأسبوع 1. ودورة عمل (sprint) مدتها أسبوعان: التخطيط في الأسبوع 1، ومراجعة ما جرى في الأسبوع 2. واجتماع مجلس الإدارة كل 4 أسابيع له دورة مدتها 4 أسابيع، يُعقد فيها الاجتماع في الأسبوع 1 فقط. والاطلاع على أسبوع في السنة القادمة يتطلب القدر نفسه من الحساب الذي يتطلبه الاطلاع على هذا الأسبوع: لا شيء يُنشأ مسبقاً، ولا مهام تعمل في الخلفية، ولا بيانات تتقادم.
انتهاء سلسلة وبدء التالية. تاريخ نهاية السلسلة غير مشمول فيها؛ فهو يحدّد أول أسبوع لا تُقام فيه حصصها. وهذا يسمح بأن تنتهي سلسلة في يوم الأحد نفسه الذي تبدأ فيه التالية، دون فجوة أو تداخل. فبعد دورة إسعافات أولية مدتها 4 أسابيع، تبدأ دورة للسلامة من الحرائق في القاعة والمواعيد نفسها. وتبدأ المرحلة الثانية بعد برنامج ما بعد الولادة الذي يستمر 6 أسابيع. وتبقى السلسلتان في البيانات، فتظل دورة الإسعافات الأولية ظاهرة في تقويم الشهر الماضي، لا الدورة التي تلتها.
وهناك تفصيلان ينبغي توضيحهما. تُخزَّن التواريخ والأوقات كنصوص بسيطة، مثل 2026-10-04 و 18:00، دون منطقة زمنية: فالمنشأة في مكان واحد، و«الأحد 6 مساءً» يبقى 6 مساءً حتى عند تغيير التوقيت الصيفي، أما المنشأة الممتدة عبر مناطق زمنية مختلفة فتحتاج إلى أكثر من ذلك. وإن تساءلت عن RRULE في معيار iCalendar: يمكنه وصف تكرار كل حصة وحدها، لكنه لا يصف السلسلة كوحدة واحدة تضم حصصاً مختلفة في أسابيع مختلفة من دورة واحدة وتتشارك القاعات والأشخاص. والتصدير إليه سهل إذا احتاجه أحد.
الاستثناءات طبقة فوق القواعد، لا تعديل عليها
القواعد تصف الأسبوع المعتاد، والأسابيع الفعلية نادراً ما تكون معتادة. لذلك يمرّ المحرك بأربع خطوات لتحديد مواعيد أي يوم، بالترتيب:
- حصص السلاسل. كل بند تكون سلسلته سارية في ذلك اليوم، ويطابق رقم أسبوعه رقم الأسبوع الحالي من دورتها، ويقع موعده في يوم الأسبوع نفسه.
- تعديلات ذلك اليوم. يمكن إلغاء الحصة، أو نقلها إلى وقت أو قاعة أو تاريخ آخر، أو إسنادها لشخص آخر، لهذا التاريخ فقط.
- الحصص المنقولة إليه. الحصة التي نُقلت إلى هذا اليوم من يوم آخر تنضم إلى القائمة.
- الحجوزات لمرة واحدة. تقييم، أو مقابلة، أو درس خاص: حصة واحدة بتاريخ محدد خارج أي سلسلة.
القرار المهم هنا هو اختيار ما نربط به التعديل. نربطه بالتاريخ وموعد الحصة والحصة، لا بالسلسلة. فحصة الجوجيتسو البرازيلي مساء الاثنين موجودة في «BJJ Gi» و«BJJ Unlimited» و«All Access»، لكنها حصة واحدة حقيقية في قاعة واحدة، وإلغاؤها يجب أن يلغيها للجميع. وهذا الربط يحقق ذلك دون أي جهد إضافي.
كما تُحذف التعديلات التي لم يعد لها أثر تلقائياً: يُحفظ كل تعديل على أنه الفرق عن الأصل، فإذا سحبت حصة إلى 7 مساءً ثم أعدتها لا يبقى أي تعديل.
التعارضات، ومتى نتوقف عن الفحص
القواعد نفسها بسيطة. لا يمكن حجز القاعة لحصتين في الوقت نفسه، ولا يمكن لمن يقدّم الحصة أن يكون في مكانين. والحصص المتتالية لا مشكلة فيها: تنتهي إحداها الساعة 5:00 وتبدأ التالية الساعة 5:00. وتُحتسب فترة تنظيف القاعة، أما الحصص الملغاة فلا. وإذا تضمّنت سلسلتان الحصة نفسها في الموعد نفسه، فهذا ليس تعارضاً، بل حصة واحدة مشتركة.
السؤال الصعب هو: إلى أي تاريخ في المستقبل نواصل الفحص؟ قد لا يظهر تعارض ناتج عن تعديل اليوم إلا في الأسبوع 7، حين تتزامن دورتا تناوب. ولأننا نحسب التواريخ بدلاً من تخزينها، لا توجد سجلات نفحصها. لكن التناوب دوري بطبيعته، وهذا يعطي إجابة دقيقة: بعد آخر تاريخ محدد (بداية سلسلة أو نهايتها، أو حجز لمرة واحدة، أو حصة منقولة)، يتكرر النمط كله على فترات يساوي طولها بالأسابيع المضاعف المشترك الأصغر (LCM) لأطوال الدورات. افحص إلى هذا الحد، وتكون قد رأيت كل تركيبة يمكن أن تحدث.
// After the last dated thing, everything repeats every lcm(cycles) weeks.
function conflictHorizon(state, today) {
let last = weekStart(today)
let lcm = 1
for (const s of state.series) {
last = latest(last, s.anchor_date, s.end_date)
lcm = (lcm * s.cycle_length) / gcd(lcm, s.cycle_length)
}
for (const d of datedExceptions(state)) last = latest(last, d) // one-offs, moves
const weeks = weeksBetween(weekStart(today), last) + Math.min(lcm, 104)
return Math.min(weeks, 208) // two caps: two years of repeats, four years in all
}فمع دورتين مدتهما أسبوعان و4 أسابيع، يمتد الفحص 4 أسابيع بعد آخر تاريخ، ومع 3 و4 أسابيع يمتد 12 أسبوعاً. والجداول الفعلية تبقى صغيرة هكذا، لكننا نحدّ فترة التكرار بعامين والفحص كله بأربعة أعوام (فدورات من 3 و5 و7 أسابيع تحتاج وحدها إلى 105 أسابيع)، حتى لا يتعطل الحفظ بسبب مزيج غير معتاد من الدورات؛ وبعد هذين الحدّين لا يعود الفحص شاملاً. وثلاثة قرارات أخرى تجعله موثوقاً في الاستخدام الفعلي:
- يجري الخادم الفحص ضمن معاملة في قاعدة البيانات. تُنفَّذ عملية الحفظ، ثم يُفحص المدى كله بحثاً عن التعارضات. وإذا أضاف الحفظ تعارضاً جديداً، يتراجع الخادم عن العملية بالكامل ويعرض رسالة مثل: «Host Mei Chen double-booked: Standup (Room A, 9 AM – 9:15 AM, Acme — Daily standup W1) & Interview (Room B, 9 AM – 12 PM, Orbit Analytics — Hiring W1), first 6 Oct». ولأن SQLite تعمل بشكل متزامن في عملية واحدة، لا يمكن أن تتداخل عمليتا حفظ.
- التعارضات الجديدة وحدها تمنع الحفظ. يقارن الفحص التعارضات قبل التعديل وبعده. فإذا كانت البيانات القديمة تحتوي تعارضاً أصلاً، تبقى التعديلات التي لا تتعلق به ممكنة، بدلاً من أن يتوقف النظام كله إلى أن يحلّ أحدهم مشكلة شهر مارس.
- المتصفح والخادم يطبّقان القواعد نفسها. وحدة برمجية واحدة يشترك فيها الطرفان، فالتحذير الذي تراه قبل الضغط على «Save» يأتي من الشيفرة نفسها التي يطبّقها الخادم.
متى يكون التخزين هو الخيار الصحيح
لحساب التواريخ تكاليفه أيضاً. فسؤال مثل «كل الحصص التي يحضرها آدم الشهر القادم» يتطلب حساب التواريخ أولاً ثم التصفية، ومع آلاف المنشآت في قاعدة بيانات واحدة ستحتاج إلى فهرس محسوب مسبقاً. والماضي يجب ألا يتغير: فالحضور والرواتب والفواتير تشير إلى حصص حدثت فعلاً، ومكانها سجلات مخزّنة تُكتب وقت حدوثها، لا يُعاد حسابها من قواعد اليوم. وحالياً، تعديل السلسلة مباشرة يعيد كتابة أسابيعها الماضية أيضاً؛ أما «Replace from a date» فيُنهي السلسلة القديمة ويبدأ سلسلة جديدة، وهذا ما يحفظ السجل كما هو.
لذلك فالقاعدة الكاملة هي: احسب المستقبل، وخزّن الماضي. ولا يسجّل Koredyne Schedule الحضور بعد، فلا يحتاج اليوم إلا إلى نصفها الأول.
محرك واحد، وثلاث مجموعات من المصطلحات
لا شيء مما سبق خاص بالأندية الرياضية. فعيادة العلاج الطبيعي لديها أخصائيون وغرف علاج وبرامج، ومركز الأعمال لديه مستضيفون وقاعات اجتماعات وحجوزات للعملاء. النموذج نفسه تماماً، فيبقى المحرك كما هو وتتغير الكلمات فقط:
| نادٍ رياضي | عيادة | قاعات اجتماعات | |
|---|---|---|---|
| الحجز | حصة | جلسة | اجتماع |
| من يقدّمه | مدرب | أخصائي | مستضيف |
| نوعه | نشاط | خدمة | نوع الاجتماع |
| من يحجز | عضو | مراجع | عميل |
| المجموعة المتكررة | باقة | برنامج | سلسلة |
تختار المنشأة نوعها من الإعدادات، فتتغير تبعاً له المصطلحات في كل شاشة وتسميات الخطط ورسائل الخطأ، مثل «Host Sara double-booked». واستبعدنا الكلمات العامة مثل «مورد» و«حدث»، فمن يستخدم الجدول هم موظفو الاستقبال لا المهندسون. أما داخل الشيفرة فنحتفظ بمجموعة أسماء واحدة، ويقتصر اختلاف المصطلحات على طبقة بسيطة عند عرضها للمستخدم.

ويأتي كل نوع مع نسخة تجريبية مكتوبة كبيانات عادية، ويفحص اختبار آلي كل نسخة بحثاً عن التعارضات على مدى الأسابيع الاثني عشر المقبلة.
نسخة تجريبية لكل زائر، جرّب فيها ما تشاء
أردنا رابطاً عاماً يتيح لأي شخص تجربة النظام فعلاً، بما في ذلك حذف كل شيء. ومع نسخة تجريبية مشتركة، كان ما يحذفه زائر واحد سيُفسد التجربة على الجميع.
لذلك يحصل كل زائر على نسخته الخاصة: قاعدة بيانات SQLite في الذاكرة، تُحمَّل فيها البيانات التجريبية، ويُعثر عليها مجدداً من خلال معرّف عشوائي محفوظ في ملف تعريف الارتباط (cookie). وتُحذف النسخ التي بقيت دون استخدام ساعة كاملة، وإذا تجاوز عددها 200 تُحذف أولاً النسخة التي مضى أطول وقت منذ آخر استخدام لها. والنسخة الواحدة بضع مئات من السجلات، فتكلفتها شبه معدومة، وإعادة تشغيل الخادم تمسحها كلها، وهذا في نسخة تجريبية ميزة لا عيب.
handle(req, res) {
let id = readCookie(req, 'sandbox')
let box = id && boxes.get(id)
if (!box) {
id = crypto.randomUUID()
if (boxes.size >= MAX) drop(leastRecentlyUsed())
const db = openDb(':memory:') // a fresh database starts with the demo
box = { db, api: apiMiddleware(db) }
setCookie(res, 'sandbox', id)
}
markUsed(id, box)
return box.api(req, res)
}صغير عن قصد
النظام كله عملية Node واحدة تستخدم node:sqlite المدمجة، وواجهة مبنية باستخدام React، ووحدة برمجية واحدة مشتركة للقواعد. وكل عملية كتابة تُرجع الحالة الكاملة (فجدول المنشأة يتكوّن من بضع مئات من السجلات)، فلا توجد ذاكرة تخزين مؤقت تحتاج إلى إبطال صلاحية بياناتها، ولا تحديثات جزئية تحتاج إلى دمجها مع الحالة السابقة. وعلى حاسوب محمول (Apple M4 مع Node 24)، يستغرق حساب جدول أسبوع للنسخة التجريبية من النادي الرياضي، التي تضم 109 مواعيد للحصص، نحو 1 ملّي ثانية، وحساب سنة كاملة نحو 90 ملّي ثانية (قرابة 11,800 حصة). ويستغرق فحص التعارضات نحو 5 ملّي ثانية، ويُجرى مرتين في كل عملية حفظ (قبلها وبعدها). وتُقارَن الحصص في كل يوم زوجاً زوجاً، وهذا مناسب لبضع عشرات من الحصص يومياً؛ فلا شيء يحتاج إلى تحسين حتى الآن.
وما يأتي لاحقاً يتعلق بالأشخاص أكثر من التواريخ: الأدوار، والحضور، وإغلاق المنشأة لفترة محددة، وإيقاف التناوب مؤقتاً خلال أسبوع الإغلاق.
جرّبه بنفسك
افتح النسخة التجريبية، وبدّل بين نسخة النادي الرياضي والعيادة وقاعات الاجتماعات من الإعدادات، واختبر حدود النظام: اسحب حصة إلى شخص مشغول وسترى أن النظام يرفض هذا التغيير. والشيفرة متاحة على GitHub. وإذا كانت منشأتك تعمل بجدول يتكرر ويتناوب وتطرأ عليه استثناءات، فيسعدنا أن نسمع منك.

