تقرير المبيعات اليومي يصل إلى المدير وفيه طلبات الأمس محسوبة على اليوم، ومهمة النسخ الاحتياطي Backup على سيرفر في أوروبا لم تعمل ليلة واحدة في مارس، وتذكير الموعد وصل إلى العميل قبل موعده بساعة كاملة، والتاريخ الهجري في الفاتورة يختلف يوماً عن التقويم الذي على الحائط، وسجلات Logs سيرفرين لا تستطيع أن تضعها جنباً إلى جنب لأن أحدهما يكتب بتوقيت الرياض والآخر بتوقيت UTC، وفي صباح يوم ما يرفض السيرفر الاتصال بخدمة خارجية لأن شهادتها TLS "ليست صالحة بعد"، والسبب أن ساعته متأخرة عشر دقائق. هذه كلها أخطاء حقيقية يقع فيها كل فريق عاجلاً أو آجلاً، ولا يوجد خلل واحد في الكود يجمعها، وإنما يجمعها أن الوقت في البرمجيات أعقد مما يبدو.
والسبب أن كلمة "الوقت" تعني عدة أشياء مختلفة: لحظة على الخط الزمني يتفق عليها كل العالم، والرقم الذي تعرضه ساعة الحائط في مدينة معينة، وقواعد تقررها الحكومات وتغيرها أحياناً بإشعار أيام قليلة، وتقويمات مختلفة تسمي اليوم نفسه بأسماء مختلفة، وساعة داخل كل سيرفر تنحرف إذا تركتها وحدها. ومن يخلط بين هذه المعاني يكتب كوداً يعمل طوال السنة ثم يتعطل في ليلة واحدة.
وسوف نناقش في هذا المقال ما يلي:
- المفاهيم التي يقوم عليها كل شيء: اللحظة والوقت المحلي، ولماذا توجد المناطق الزمنية أصلاً، وUTC، والفرق بين الفارق Offset والمنطقة الزمنية Time Zone، ودول غيرت ساعتها بإشعار أيام وحذفت إحداها يوماً كاملاً من تقويمها، والتوقيت الصيفي، وUnix Time وسنة 2038، والثانية الكبيسة، وصيغة ISO 8601.
- التقويم الميلادي والهجري: تقويم أم القرى، والرؤية، والتقويم الحسابي، ولماذا يختلف اليوم نفسه بين الأنظمة، مع مخرجات حقيقية من Node.js وPython وJava.
- إعداد السيرفر: المنطقة الزمنية، ومزامنة الساعة بـ chrony، وال Containers، ونسخة tzdata التي تحملها Java وNode.js بعيداً عن النظام، وقواعد البيانات PostgreSQL وMySQL.
- قواعد المطور: احفظ UTC والمنطقة بجانبها، وحول عند الأطراف، واختبر بساعة مجمدة، ثم الحساب بالشهور والأسابيع، وعرض الوقت للمستخدم بعبارات مثل "قبل 5 دقائق"، وحقول إدخال التاريخ.
- المهام المجدولة والتذكيرات: cron وsystemd timers وKubernetes، والمواعيد المستقبلية، والاجتماع المتكرر بين مدينتين، والتذكير بتاريخ هجري.
- السجلات وانحراف الساعة، ثم قائمة تحقق لكل سيرفر جديد وتطبيق جديد ومهمة مجدولة.
اللحظة وساعة الحائط: المفاهيم التي يقوم عليها كل شيء
اللحظة Instant والوقت المحلي Wall-Clock Time
اللحظة Instant هي نقطة واحدة على الخط الزمني، يتفق عليها كل من في العالم مهما كان مكانه، فإذا ضغط مستخدم في الرياض زر الدفع وضغط آخر في لندن الزر نفسه في نفس اللحظة، فهما حدث واحد في الوقت نفسه. أما الوقت المحلي Wall-Clock Time أو Local Time فهو الرقم الذي تعرضه ساعة الحائط في مدينة معينة، وهو يختلف من مدينة إلى أخرى للحظة نفسها. والتشبيه الأقرب هو مباراة كرة قدم تبث على الهواء، فالمباراة تبدأ مرة واحدة، ولكن كل قناة تكتب موعدها بتوقيت بلدها.
والمثال التالي بلغة Python يأخذ لحظة واحدة ويعرضها في خمس مناطق زمنية:
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
instant = datetime(2026, 10, 6, 6, 0, tzinfo=timezone.utc)
print("Unix time:", int(instant.timestamp()))
for zone in ["UTC", "Asia/Riyadh", "Africa/Cairo", "Europe/London", "America/New_York"]:
local = instant.astimezone(ZoneInfo(zone))
print(f"{zone:17} {local.isoformat()} {local.tzname()}")والمخرج سوف يكون كما يلي:
Unix time: 1791266400
UTC 2026-10-06T06:00:00+00:00 UTC
Asia/Riyadh 2026-10-06T09:00:00+03:00 +03
Africa/Cairo 2026-10-06T09:00:00+03:00 EEST
Europe/London 2026-10-06T07:00:00+01:00 BST
America/New_York 2026-10-06T02:00:00-04:00 EDT
في المخرج أعلاه لاحظ التالي:
- اللحظة واحدة في كل الأسطر، والرقم
1791266400هو عدد الثواني منذ بداية 1970 بتوقيت UTC، ولا يتغير بتغير المدينة. - الرياض والقاهرة تعرضان 09:00 اليوم، ولكن القاهرة في توقيتها الصيفي
EEST، وبعد 29 أكتوبر 2026 تعود إلى+02:00فيظهر بينهما فرق ساعة. لذلك لا تقم بمعاملة مدينتين على أنهما منطقة واحدة لأن ساعتيهما متطابقتان اليوم. - اختصار الرياض هو
+03وليس اسماً مثل AST، والسبب أن قاعدة بيانات المناطق الزمنية لا تخترع اختصارات لا يستخدمها أهل البلد، فلا تبن أي منطق على هذه الاختصارات، فالاختصار الواحد قد يعني أكثر من منطقة.
لماذا توجد المناطق الزمنية أصلاً؟
قبل القرن التاسع عشر كانت كل مدينة تضبط ساعتها على الشمس، فالظهر هو اللحظة التي تصل فيها الشمس إلى أعلى نقطة فوق تلك المدينة، والمدينة التي تقع شرقك بمئة كيلومتر ساعتها متقدمة على ساعتك بدقائق. ولم يكن هذا يزعج أحداً حتى جاءت القطارات والتلغراف، فجدول قطار يمر بعشر مدن لا يمكن كتابته بعشر ساعات مختلفة. لذلك اعتمدت شركات السكك الحديدية في بريطانيا توقيت غرينتش في ديسمبر 1847 وصار اسمه "توقيت السكك الحديدية Railway Time"، ثم أصبح التوقيت القانوني لبريطانيا كلها في 1880، وفي 1884 أوصى المؤتمر الدولي لخط الطول International Meridian Conference باعتماد خط غرينتش خط الطول الرئيسي Prime Meridian للعالم، كما في صفحة توقيت غرينتش في المرصد الملكي Royal Museums Greenwich. وفي أمريكا الشمالية اعتمدت شركات القطارات أربع مناطق زمنية في 18 نوفمبر 1883، والصورة التالية تبين خريطتها كما نشرت وقتها، وعلى كل منطقة ساعة تبين الوقت عندها لحظة ظهر غرينتش:

وكثير من الأمثلة في هذا القسم والقسمين التاليين جمعها المطور زاك هولمان Zach Holman في محاضرته UTC is Enough for Everyone, Right?، وقد بدأها بنصيحة صديق له بنى تقويمات من قبل: "just don't ever build a calendar"، ثم بنى تقويماً على أي حال. وقد رجعنا في كل حقيقة نقلناها إلى مصدرها الأصلي، وأضفنا ما يهم القارئ العربي.
وأثر هذا التاريخ ما زال موجوداً في tzdata نفسها، فكل منطقة تبدأ بالتوقيت الشمسي المحلي Local Mean Time واختصاراً LMT لمدينتها، والأداة zdump الموجودة مع tzdata في أغلب التوزيعات تعرض لحظات التغيير لأي منطقة. الأمر التالي يعرض لحظة انتقال الرياض إلى +03:
zdump -v -c 1947,1948 Asia/Riyadh | grep ' 1947 UT'والمخرج سوف يكون كما يلي (من Debian 13 مع tzdata 2026c):
Asia/Riyadh Thu Mar 13 20:53:07 1947 UT = Thu Mar 13 23:59:59 1947 LMT isdst=0 gmtoff=11212
Asia/Riyadh Thu Mar 13 20:53:08 1947 UT = Thu Mar 13 23:53:08 1947 +03 isdst=0 gmtoff=10800لاحظ أن الفارق قبل التغيير كان gmtoff=11212 ثانية، أي +03:06:52، وهو التوقيت الشمسي للرياض، وأن الساعة رجعت 6 دقائق و52 ثانية ليلة 14 مارس 1947. فإذا كان عندك أرشيف قديم بالوقت المحلي، مثل وثائق أو سجلات تاريخية، فالتحويل إلى UTC لما قبل 1947 لا يكون بطرح ثلاث ساعات، وإنما بالمكتبة التي تقرأ هذه القواعد.
ولأن المناطق تقررها الحكومات وليس الحساب، فليست كل الفروق ساعات كاملة. الأمر التالي يعرض اللحظة نفسها 1791266400 من المثال الأول في ست مناطق:
for z in Asia/Riyadh Asia/Tehran Asia/Kabul Asia/Kolkata Asia/Kathmandu Pacific/Chatham; do
printf '%-16s ' "$z"; TZ=$z date -d @1791266400 '+%H:%M %z'
doneوالمخرج سوف يكون كما يلي:
Asia/Riyadh 09:00 +0300
Asia/Tehran 09:30 +0330
Asia/Kabul 10:30 +0430
Asia/Kolkata 11:30 +0530
Asia/Kathmandu 11:45 +0545
Pacific/Chatham 19:45 +1345لاحظ أن جارتنا إيران على +03:30، وأفغانستان على +04:30، والهند على +05:30، ونيبال على +05:45، وجزر Chatham في نيوزيلندا على +13:45 في صيفها. لذلك لا تقم بحفظ الفارق في عمود ساعات صحيحة Integer Hours، ولا تبن قائمة المناطق في الواجهة من -12 إلى +14 بخطوة ساعة، والسبب أن ملايين المستخدمين لن يجدوا منطقتهم فيها. وإذا احتجت إلى الفارق نفسه فاحفظه بالدقائق أو بالثواني، والأفضل كما سيأتي أن تحفظ اسم المنطقة.
لماذا UTC وليس GMT؟
التوقيت العالمي المنسق Coordinated Universal Time واختصاراً UTC هو المرجع الذي تقاس به كل المناطق الزمنية، فالرياض UTC+3 ونيويورك في الصيف UTC-4، وهو مبني على الساعات الذرية Atomic Clocks ولا يعرف التوقيت الصيفي. أما توقيت غرينتش GMT فهو في الاستخدام اليومي اسم ساعة لندن في الشتاء، فإذا كتبت في إعداداتك GMT وأنت تقصد UTC فسوف يأتي من يقرأ "توقيت لندن"، ولندن في الصيف على +01:00 كما رأيت في المخرج أعلاه. لذلك اكتب UTC في كل ما يخص السيرفرات والكود، ودع GMT للنشرات الجوية.
والثانية التي يعد بها UTC معرفة بالساعات الذرية منذ 1967، فهي مدة 9192631770 ذبذبة لذرة السيزيوم 133، كما في تعريف الثانية في المكتب الدولي للأوزان والمقاييس BIPM، أما GMT فأصله توقيت شمسي يقاس من مرصد غرينتش، وكان المعيار الدولي للتوقيت المدني من 1884 حتى 1972 قبل أن يحل UTC محله، كما في صفحة المرصد الملكي التي ذكرناها أعلاه. والصورة التالية تبين ساعة Shepherd عند بوابة المرصد، وهي أول ساعة عرضت توقيت غرينتش للجمهور:

الفارق Offset ليس منطقة زمنية Time Zone
الفارق Offset هو رقم ثابت مثل +03:00، وهو يصف لحظة واحدة فقط: في هذه اللحظة كانت ساعة الحائط متقدمة ثلاث ساعات عن UTC. أما المنطقة الزمنية Time Zone مثل Asia/Riyadh أو Africa/Cairo أو Europe/London فهي تاريخ كامل من القواعد: ما الفارق في كل فترة، ومتى يبدأ التوقيت الصيفي ومتى ينتهي، ومتى غيرت الحكومة رأيها. وهذه القواعد يجمعها مشروع واحد هو قاعدة بيانات المناطق الزمنية في IANA، المعروفة باسم tzdata أو tz database، وهي الملفات التي تجدها في /usr/share/zoneinfo على Linux، وتستخدمها Java وPython وNode.js وPostgreSQL وكل نظام تقريباً.
وقد يتساءل البعض: لماذا لا أحفظ الفارق +03:00 وأرتاح؟ والإجابة على ذلك أن الدول تغير قواعدها، وأحياناً بإشعار قصير جداً، وملف سجل التغييرات NEWS في tzdata مليء بالأمثلة:
- ساموا 2011: نقلت ساموا Samoa فارقها من
UTC-11إلىUTC+13بقانون خط التاريخ الدولي International Date Line Act 2011، فحذفت يوم الجمعة 30 ديسمبر 2011 كاملاً من تقويمها، فبعد الخميس 29 ديسمبر الساعة 23:59:59 جاء السبت 31 ديسمبر مباشرة، كما في ملف أستراليا والمحيط الهادئ australasia في tzdata، والملف نفسه يذكر أنها فعلت العكس في 1892 فاحتفلت بيوم 4 يوليو مرتين. - كوريا الشمالية 2015 و2018: انتقلت إلى
+08:30في 15 أغسطس 2015، وصدر tzdata بالإصدار 2015f في 10 أغسطس، ثم رجعت إلى+09:00في 5 مايو 2018 بمرسوم نشر في 30 أبريل، وصدر tzdata بالإصدار 2018e في 1 مايو، أي قبل التغيير بأربعة أيام. - ساموا 2021: ألغت التوقيت الصيفي قبل موعده في 26 سبتمبر 2021 بأيام قليلة، وصدر tzdata بالإصدار 2021b في 24 سبتمبر، أي قبل الموعد بيومين.
- إيران 2022: أعلنت في مايو 2022 إلغاء التوقيت الصيفي ابتداءً من السنة الإيرانية 1402، أي من مارس 2023، وصدر tzdata بالإصدار 2022b في 10 أغسطس 2022، فإيران اليوم على
+03:30طوال السنة. - المكسيك 2022: نشر قانون إلغاء التوقيت الصيفي في الجريدة الرسمية يوم 28 أكتوبر 2022، أي قبل موعد إرجاع الساعة بيومين، وصدر tzdata بالإصدار 2022f في اليوم نفسه.
- مصر 2023: عادت إلى التوقيت الصيفي من آخر جمعة في أبريل إلى آخر خميس في أكتوبر، وأعلنت ذلك في 1 مارس 2023، وصدر tzdata بالإصدار 2023a في 22 مارس، أي قبل التغيير بخمسة أسابيع فقط.
- كازاخستان 2024: وحدت البلد على
UTC+5ابتداءً من 1 مارس 2024، وصدر tzdata بالإصدار 2024a في 1 فبراير. - المغرب 2026: كان المغرب منذ 2018 على
+01:00ويرجع إلى+00:00في رمضان، ثم قرر الانتقال إلى UTC دائماً ابتداءً من 20 سبتمبر 2026، كما في ملف أفريقيا في tzdata الذي يذكر المرسوم ورقم الجريدة الرسمية، ووصل القرار في الإصدار 2026c.
والمثال الأخير يبين المشكلة بالأرقام. لنفرض أن تطبيقك حجز موعداً في الدار البيضاء يوم 1 ديسمبر 2026 الساعة 09:00، وحسب الوقت بتوقيت UTC وقت الحجز وحفظه. الكود التالي يحسب الموعد بنسختين من tzdata في Container واحد: نسخة النظام في Debian (2026c)، والحزمة القديمة tzdata==2025.2 من pip:
# A reminder booked for 1 December 2026 at 09:00 in Casablanca, computed with two versions of tzdata.
from datetime import datetime, timezone
from importlib.metadata import version
import zoneinfo
local = datetime(2026, 12, 1, 9, 0)
new_rules = zoneinfo.ZoneInfo("Africa/Casablanca") # /usr/share/zoneinfo (Debian tzdata 2026c)
zoneinfo.reset_tzpath(to=[]) # now only the old pip package tzdata is used
old_rules = zoneinfo.ZoneInfo.no_cache("Africa/Casablanca")
precomputed = local.replace(tzinfo=old_rules).astimezone(timezone.utc)
print("old rules (pip tzdata", version("tzdata") + "):", local.replace(tzinfo=old_rules).isoformat(), "->", precomputed.isoformat())
print("new rules (tzdata 2026c) :", local.replace(tzinfo=new_rules).isoformat(), "->", local.replace(tzinfo=new_rules).astimezone(timezone.utc).isoformat())
print("the precomputed UTC instant, shown with the new rules:", precomputed.astimezone(new_rules).strftime("%H:%M"))والمخرج سوف يكون كما يلي:
old rules (pip tzdata 2025.2): 2026-12-01T09:00:00+01:00 -> 2026-12-01T08:00:00+00:00
new rules (tzdata 2026c) : 2026-12-01T09:00:00+00:00 -> 2026-12-01T09:00:00+00:00
the precomputed UTC instant, shown with the new rules: 08:00أي أن الموعد الذي حسب بتوقيت UTC قبل القرار سوف يصل في الساعة 08:00 بتوقيت الدار البيضاء، قبل موعده بساعة، ولن يكتشف أحد الخطأ حتى يشتكي العميل. وسوف نعود إلى الحل الصحيح في قسم التذكيرات المستقبلية، أما القاعدة هنا فهي: احفظ اسم المنطقة الزمنية وليس الفارق، وحدث tzdata في نظام التشغيل وفي ال Images وفي مكتبات لغتك، والسبب أن كل نسخة قديمة منها تحسب الوقت بقواعد لم تعد صحيحة.
ويجدر الإشارة هنا إلى أن Asia/Riyadh في tzdata على +03 منذ 14 مارس 1947 دون أي توقيت صيفي، كما في ملف آسيا في tzdata، لذلك لن تقابل في السعودية ساعة مفقودة أو مكررة. ولكن هذا لا يحميك، فالسيرفر قد يكون في فرانكفورت، والمستخدم قد يسافر إلى لندن، والعميل قد يكون في القاهرة.
وتستطيع أن ترى هذه القرارات بنفسك في tzdata التي على سيرفرك بالأداة zdump، والأوامر التالية تعرض لحظات التغيير في ساموا وكوريا الشمالية وإيران:
zdump -v -c 2011,2012 Pacific/Apia | grep ' Dec 30 '
zdump -v -c 2015,2019 Asia/Pyongyang | grep -E ' (2015|2018) UT'
zdump -v -c 2022,2027 Asia/Tehran | grep -E ' 20[0-9][0-9] UT'والمخرج سوف يكون كما يلي (من Debian 13 مع tzdata 2026c):
Pacific/Apia Fri Dec 30 09:59:59 2011 UT = Thu Dec 29 23:59:59 2011 -10 isdst=1 gmtoff=-36000
Pacific/Apia Fri Dec 30 10:00:00 2011 UT = Sat Dec 31 00:00:00 2011 +14 isdst=1 gmtoff=50400
Asia/Pyongyang Fri Aug 14 14:59:59 2015 UT = Fri Aug 14 23:59:59 2015 KST isdst=0 gmtoff=32400
Asia/Pyongyang Fri Aug 14 15:00:00 2015 UT = Fri Aug 14 23:30:00 2015 KST isdst=0 gmtoff=30600
Asia/Pyongyang Fri May 4 14:59:59 2018 UT = Fri May 4 23:29:59 2018 KST isdst=0 gmtoff=30600
Asia/Pyongyang Fri May 4 15:00:00 2018 UT = Sat May 5 00:00:00 2018 KST isdst=0 gmtoff=32400
Asia/Tehran Mon Mar 21 20:29:59 2022 UT = Mon Mar 21 23:59:59 2022 +0330 isdst=0 gmtoff=12600
Asia/Tehran Mon Mar 21 20:30:00 2022 UT = Tue Mar 22 01:00:00 2022 +0430 isdst=1 gmtoff=16200
Asia/Tehran Wed Sep 21 19:29:59 2022 UT = Wed Sep 21 23:59:59 2022 +0430 isdst=1 gmtoff=16200
Asia/Tehran Wed Sep 21 19:30:00 2022 UT = Wed Sep 21 23:00:00 2022 +0330 isdst=0 gmtoff=12600
في المخرج أعلاه لاحظ التالي:
- في ساموا بين ثانيتين متتاليتين بتوقيت UTC انتقلت ساعة الحائط من الخميس إلى السبت، فالتاريخ المحلي
2011-12-30لا يوجد أصلاً فيPacific/Apia، والكود الذي يمشي يوماً بيوم بالتاريخ المحلي ويفترض أن لكل يوم 24 ساعة، أو أن كل تاريخ موجود في كل منطقة، سوف يخطئ في مثل هذه الحالة، أما UTC فلم يفقد أي ثانية. - كوريا الشمالية غيرت فارقها مرتين وبقي الاختصار
KSTكما هو، لذلك لا تحكم على تغير الفارق من الاختصار، وإنما منgmtoffأو من المكتبة. - آخر تغيير في إيران كان في سبتمبر 2022، ولا توجد بعده أي أسطر حتى 2027، أي أن tzdata الحديثة تعرف أن إيران ألغت التوقيت الصيفي، أما السيرفر الذي يحمل tzdata أقدم من 2022b فما زال يقدم ساعة إيران كل ربيع.
التوقيت الصيفي: ساعة تختفي وساعة تتكرر
التوقيت الصيفي Daylight Saving Time واختصاراً DST هو أن تقدم الدولة ساعتها ساعة في الربيع وترجعها في الخريف. وفي ألمانيا مثلاً تقفز الساعة ليلة 29 مارس 2026 من 02:00 إلى 03:00، فالساعة بين 02:00 و03:00 لا تأتي ذلك اليوم، وهذه هي الفجوة Gap، وفي ليلة 25 أكتوبر 2026 ترجع الساعة من 03:00 إلى 02:00، فتأتي الساعة بين 02:00 و03:00 مرتين، وهذا هو التداخل Overlap. والكود التالي يمشي في الليلتين كل نصف ساعة بتوقيت UTC ويعرض ما تقوله ساعة الحائط في برلين:
from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo
utc = timezone.utc
berlin = ZoneInfo("Europe/Berlin")
def night(start):
for i in range(5):
t = start + timedelta(minutes=30 * i)
print(t.strftime("%H:%M UTC"), "=", t.astimezone(berlin).strftime("%H:%M %Z"))
print("29 March 2026 (spring forward)")
night(datetime(2026, 3, 28, 23, 30, tzinfo=utc))
print("25 October 2026 (fall back)")
night(datetime(2026, 10, 24, 23, 30, tzinfo=utc))
print("02:30 Berlin on 29 March =", datetime(2026, 3, 29, 2, 30, tzinfo=berlin).astimezone(utc).isoformat())
for fold in (0, 1):
t = datetime(2026, 10, 25, 2, 30, fold=fold, tzinfo=berlin)
print(f"02:30 Berlin on 25 October, fold={fold} =", t.astimezone(utc).isoformat(), t.tzname())والمخرج سوف يكون كما يلي:
29 March 2026 (spring forward)
23:30 UTC = 00:30 CET
00:00 UTC = 01:00 CET
00:30 UTC = 01:30 CET
01:00 UTC = 03:00 CEST
01:30 UTC = 03:30 CEST
25 October 2026 (fall back)
23:30 UTC = 01:30 CEST
00:00 UTC = 02:00 CEST
00:30 UTC = 02:30 CEST
01:00 UTC = 02:00 CET
01:30 UTC = 02:30 CET
02:30 Berlin on 29 March = 2026-03-29T01:30:00+00:00
02:30 Berlin on 25 October, fold=0 = 2026-10-25T00:30:00+00:00 CEST
02:30 Berlin on 25 October, fold=1 = 2026-10-25T01:30:00+00:00 CET
في المخرج أعلاه لاحظ التالي:
- في مارس قفزت ساعة الحائط من
01:30 CETإلى03:00 CESTبين سطرين، فالساعة 02:30 غير موجودة أصلاً في برلين ذلك اليوم، وعندما طلبناها حولتها Python إلى01:30 UTCأي 03:30 بتوقيت برلين. - في أكتوبر ظهرت
02:00و02:30مرتين، والمتغيرfoldهو الذي يحدد أي المرتين تقصد:fold=0للأولى بتوقيتCESTوfold=1للثانية بتوقيتCET، وبينهما ساعة كاملة. - أي مهمة مجدولة "كل يوم الساعة 02:30 بتوقيت برلين" عليها أن تقرر ماذا تفعل في الليلتين، وهذا ما سوف نراه في قسم المهام المجدولة.
ولاحظ أن الساعة المفقودة ليست واحدة في كل البلدان، ففي لندن تقفز الساعة من 01:00 إلى 02:00 لأن التغيير يحدث عند 01:00 بتوقيت UTC، وفي القاهرة تقفز من منتصف الليل إلى 01:00 ليلة آخر جمعة في أبريل. لذلك لا تقم بكتابة قواعد التوقيت الصيفي بيدك في الكود، وإنما دع tzdata تقررها.
Unix Time ومشكلة سنة 2038
الطريقة الأبسط لحفظ لحظة هي رقم واحد: عدد الثواني منذ 1 يناير 1970 الساعة 00:00 بتوقيت UTC، ويسمى Unix Time أو Epoch Time، وهو الرقم 1791266400 الذي رأيته في المثال الأول، وتستخدمه حقول exp وiat في توكنات JWT كما في شرح OAuth وOpenID Connect. وهذا الرقم لا يعرف المناطق الزمنية أصلاً، لذلك هو مناسب للمقارنة والترتيب وحساب المدة بين لحظتين.
والمشكلة أن أنظمة كثيرة حفظت هذا الرقم في عدد صحيح بحجم 32 بت بإشارة، وأكبر قيمة له هي 2147483647، وهي توافق 19 يناير 2038 الساعة 03:14:07 بتوقيت UTC، وبعدها يلتف الرقم إلى قيمة سالبة تعود إلى سنة 1901، وهذه هي مشكلة سنة 2038 Year 2038 Problem. والأنظمة 64 بت الحديثة لا تعاني منها في النواة والمكتبات، وقد أضافت glibc 2.34 دعم time_t بحجم 64 بت للمعماريات 32 بت، وأكملت Debian التحول نفسه لمعمارياتها 32 بت. ولكن المشكلة تبقى في البيانات والأنواع التي اخترتها أنت، والمثال الأقرب هو النوع TIMESTAMP في MySQL الذي سوف تراه يرفض أول ثانية بعد الحد في قسم قواعد البيانات. فإذا كانت عندك تواريخ انتهاء عقود أو قروض أو شهادات تتجاوز 2038، فتأكد من نوع العمود من اليوم.
الثانية الكبيسة Leap Second ونهايتها
الأرض لا تدور بسرعة ثابتة تماماً، والساعات الذرية ثابتة، لذلك كانت الهيئات الدولية تضيف ثانية إلى UTC بين حين وآخر حتى لا يبتعد عن دوران الأرض، وتسمى الثانية الكبيسة Leap Second، وتظهر في الساعة على شكل 23:59:60. وآخر ثانية كبيسة أضيفت في 31 ديسمبر 2016، والفرق بين UTC والتوقيت الذري الدولي TAI اليوم 37 ثانية، وقد أعلنت IERS في النشرة C الصادرة في يوليو 2026 أنه لن تضاف ثانية كبيسة في نهاية ديسمبر 2026. ثم قرر المؤتمر العام للأوزان والمقاييس CGPM في القرار الرابع لسنة 2022 زيادة الحد المسموح للفرق بين UTC ودوران الأرض في سنة 2035 أو قبلها، وهذا يعني عملياً التوقف عن إضافة الثواني الكبيسة.
وUnix Time يتجاهل الثانية الكبيسة، فكل يوم فيه 86400 ثانية بالضبط، والنظام يعالج الثانية الإضافية بتكرار ثانية أو بإبطاء الساعة قليلاً. والدرس الأهم للمطور ليس الثانية الكبيسة نفسها، وإنما أن ساعة النظام Wall Clock قد ترجع إلى الخلف، سواءً بسبب ثانية كبيسة أو بسبب تصحيح من NTP أو لأن أحداً ضبطها يدوياً، لذلك لا تقم بحساب المدة بين لحظتين بطرح قراءتين من ساعة النظام، وإنما استخدم الساعة الرتيبة Monotonic Clock التي لا ترجع أبداً، مثل time.monotonic() في Python وSystem.nanoTime() في Java وperformance.now() في JavaScript.
rand.Int63n() التي توقف البرنامج إذا استلمت رقماً سالباً. وفي الذروة فشل نحو 0.2% من استعلامات DNS، والدرس: المدة تحسب بالساعة الرتيبة، وأي رقم يأتي من الساعة قد يكون سالباً.والطريقة الثانية لمعالجة الثانية الكبيسة، أي إبطاء الساعة قليلاً، لها اسم هو توزيع الثانية Leap Smear، فبدلاً من ثانية مكررة توزع Google الثانية الإضافية على 24 ساعة من ظهر اليوم السابق إلى ظهر اليوم التالي بتوقيت UTC، فتصبح كل ثانية أطول بنحو 11.6 ميكروثانية، كما في صفحة Leap Smear عند Google. وسيرفرات الوقت العامة time.google.com تقدم هذا الوقت الموزع، لذلك تنص صفحة Google Public NTP على ألا تضعها في إعداد chrony مع سيرفرات لا توزع الثانية، والسبب أن المصدرين يختلفان بما يصل إلى نصف ثانية يوم الثانية الكبيسة، فلا يعرف السيرفر أيهما يصدق.
كيف تكتب الوقت: ISO 8601 وRFC 3339
التاريخ 06/10/2026 يقرؤه الأمريكي 10 يونيو ويقرؤه غيره 6 أكتوبر، ولا يعرف أحد بأي توقيت هو. والحل هو صيغة المعيار ISO 8601، وبالتحديد الصورة المختصرة منه التي يعرفها RFC 3339 لبروتوكولات الإنترنت: السنة ثم الشهر ثم اليوم، ثم الحرف T والوقت، ثم الفارق أو الحرف Z الذي يعني UTC:
2026-10-06T06:00:00Z
2026-10-06T09:00:00+03:00
2026-10-06T09:00:00+03:00[Asia/Riyadh]في الأسطر أعلاه لاحظ التالي:
- السطران الأول والثاني لحظة واحدة، والأول أفضل للتخزين والسجلات والتواصل بين الأنظمة لأنه لا يحتاج إلى أي حساب.
- السطر الثاني يحفظ الفارق ولكنه لا يحفظ المنطقة، فلا يعرف من يقرؤه هل صاحبه في الرياض أم في القاهرة.
- السطر الثالث بصيغة RFC 9557 الصادر في 2024، وهو يضيف اسم المنطقة بين قوسين بعد الفارق، وهي الصيغة نفسها التي تطبعها Temporal في JavaScript و
ZonedDateTimeفي Java، وهو مفيد عندما تريد إرسال موعد مستقبلي مع منطقته. - الترتيب من الأكبر إلى الأصغر يجعل الترتيب الأبجدي للنصوص هو نفسه الترتيب الزمني، وهذه ميزة تقدرها عندما تفرز أسماء ملفات النسخ الاحتياطي مثل
backup-2026-10-06T0300Z.tar.gz.
أما في الواجهات والمستندات التي يقرؤها الناس، فانتبه للساعة 12 مع "ص" أو "م"، فهل 12:00 ص منتصف الليل أم الظهر؟ وصفحة الأسئلة الشائعة في المعهد الأمريكي للمعايير NIST تقول صراحة إن 12 a.m. و12 p.m. غامضتان ولا ينبغي استخدامهما، وتنصح بنظام 24 ساعة، أو بكلمتي "الظهر" و"منتصف الليل"، أو بكتابة 11:59 ليلاً بدلاً من 12 في المواعيد القانونية. لذلك في آخر موعد للتقديم Deadline أو نهاية عرض أو اشتراك اكتب "حتى 23:59 بتوقيت الرياض يوم 31 ديسمبر"، ولا تكتب "حتى 12 منتصف الليل"، والسبب أن نصف القراء سوف يفهمونها بداية اليوم ونصفهم نهايته.
اللغة والتنسيق Locale ليست المنطقة الزمنية
الإعداد المحلي Locale مثل ar-SA يحدد طريقة العرض: اللغة، وشكل الأرقام (٠١٢٣ أو 0123)، والتقويم الافتراضي، وترتيب اليوم والشهر. أما المنطقة الزمنية فتحدد أي ساعة حائط تعرض. والاثنان مستقلان تماماً، فالمستخدم السعودي المسافر إلى لندن ما زال يريد الواجهة بالعربية، ولكن تذكير اجتماعه يجب أن يأتي بتوقيت لندن. لذلك احفظ لكل مستخدم حقلين منفصلين: اللغة ar-SA والمنطقة Asia/Riyadh، ولا تستنتج أحدهما من الآخر.
التقويم الميلادي والتقويم الهجري
التقويم الميلادي Gregorian Calendar شمسي، وقواعده حسابية ثابتة، فأي برنامج في العالم يحسب اليوم نفسه بالطريقة نفسها. أما التقويم الهجري Hijri Calendar فقمري، والشهر فيه 29 أو 30 يوماً بحسب القمر، وهنا يبدأ الاختلاف، فهناك أكثر من طريقة لتحديد بداية الشهر.
تقويم أم القرى، والرؤية، والتقويم الحسابي
- تقويم أم القرى Umm al-Qura: هو كما يقول موقع تقويم أم القرى الرسمي التقويم الرسمي للمملكة العربية السعودية، وهو تقويم هجري قمري محسوب فلكياً، يعتمد إحداثيات المسجد الحرام مرجعاً لحساباته، وتشرف عليه مدينة الملك عبدالعزيز للعلوم والتقنية KACST. ولأنه محسوب، فهو جدول معروف مسبقاً لسنوات طويلة، وتحمله المكتبات البرمجية جدولاً جاهزاً.
- الرؤية Moon Sighting: المناسبات الدينية مثل بداية رمضان وشوال وذي الحجة تحدد برؤية الهلال، ففي 16 فبراير 2026 مثلاً دعت المحكمة العليا إلى تحري رؤية هلال رمضان مساء الثلاثاء 29 شعبان 1447 "حسب تقويم أم القرى". والموقع الرسمي نفسه يذكر أن تواريخ المناسبات قد تختلف يوماً واحداً بحسب الرؤية الفعلية، ولا يستطيع أي برنامج أن يعرف نتيجة الرؤية قبل إعلانها.
- التقويم الحسابي Tabular Islamic Calendar: قاعدة رياضية ثابتة تتبادل فيها الشهور 30 و29 يوماً في دورة من 30 سنة، دون أي حساب للقمر. وهو يعمل لأي سنة، ولكنه يبتعد عن أم القرى يوماً أو يومين، وله صيغتان معروفتان في المكتبات:
islamic-civilوislamic-tbla.
ويجدر الإشارة هنا إلى تقويم ثالث يهم من يكتب أنظمة الرواتب، فقد قرر مجلس الوزراء في القرار رقم 551 بتاريخ 25/12/1437 صرف رواتب موظفي الدولة في اليوم الخامس من كل برج هجري شمسي، وأول صرف كان في 27 أكتوبر 2016، كما في صفحة الرواتب في وزارة المالية. أي أن موعد الراتب لا يحسب بالتقويم الهجري القمري ولا بأول الشهر الميلادي، فلا تقم بتخمينه، وخذه من الجهة الرسمية.

التاريخ الهجري في الكود: Node.js وPython وJava
أغلب لغات البرمجة لا تحسب الهجري بنفسها، وإنما تعتمد على مكتبة ICU وبيانات CLDR التي تعرف التقويمات بأسماء ثابتة، أهمها islamic-umalqura وislamic-civil وislamic-tbla. وفي JavaScript تختار التقويم بالامتداد -u-ca- في اسم اللغة عبر Intl.DateTimeFormat. المثال التالي يعرض يوم 6 أكتوبر 2026 بالتقويمات الثلاثة:
const day = new Date('2026-10-06T06:00:00Z');
console.log('node', process.version, '| icu', process.versions.icu, '| tz', process.versions.tz);
for (const ca of ['islamic-umalqura', 'islamic-civil', 'islamic-tbla']) {
const f = new Intl.DateTimeFormat(`ar-SA-u-ca-${ca}-nu-latn`, { timeZone: 'Asia/Riyadh', dateStyle: 'long' });
console.log(ca.padEnd(17), f.format(day));
}
console.log('ar-SA default ', new Intl.DateTimeFormat('ar-SA', { timeZone: 'Asia/Riyadh', dateStyle: 'long' }).format(day),
'|', new Intl.DateTimeFormat('ar-SA').resolvedOptions().calendar);وعند تشغيله في Container من node:22-alpine سوف يكون المخرج كما يلي:
node v22.23.3 | icu 78.3 | tz 2026c
islamic-umalqura 25 ربيع الآخر 1448 هـ
islamic-civil 23 ربيع الآخر 1448 هـ
islamic-tbla 24 ربيع الآخر 1448 هـ
ar-SA default ٦ أكتوبر ٢٠٢٦ | gregoryوهذا مخرج الملف نفسه دون أي تعديل في Container من node:18-alpine:
node v18.20.8 | icu 74.2 | tz 2024a
islamic-umalqura 25 ربيع الآخر 1448 هـ
islamic-civil 23 ربيع الآخر 1448 هـ
islamic-tbla 24 ربيع الآخر 1448 هـ
ar-SA default ٢٥ ربيع الآخر ١٤٤٨ هـ | islamic-umalquraفي المخرجين أعلاه لاحظ التالي:
- اليوم نفسه له ثلاثة تواريخ هجرية: 25 في أم القرى، و23 في الحسابي civil، و24 في الحسابي tbla، أي أن الفرق وصل إلى يومين، فالذي يقول "التاريخ الهجري خطأ بيوم" غالباً يستخدم تقويماً غير الذي يظنه.
- السطر الأخير هو الأخطر:
ar-SAدون تحديد التقويم عرض الميلادي في Node 22 والهجري في Node 18، لأن التقويم الافتراضي يأتي من بيانات CLDR المضمنة، وتغير بين الإصداريين. فالكود نفسه يعرض تاريخين مختلفين بعد ترقية Node أو المتصفح، لذلك حدد التقويم والأرقام صراحة دائماً، كما ذكرنا في قائمة تحقق المطور قبل نشر تطبيقه. - Node 18 يحمل tzdata بالإصدار
2024a، أي أنه لا يعرف قرار المغرب الذي رأيته أعلاه، وهذا سبب آخر لترقية بيئة التشغيل وليس نظام التشغيل فقط.
وفي Python المكتبة المعروفة لتقويم أم القرى كانت hijri-converter، وقد أصبحت متوقفة Deprecated وانتقلت باسم جديد هو hijridate بالواجهة نفسها، أما convertdate فتحسب الهجري بالطريقة الحسابية:
from datetime import date
from importlib.metadata import version
from hijridate import Gregorian, Hijri
from convertdate import islamic
print("hijridate", version("hijridate"), "| convertdate", version("convertdate"))
h = Gregorian(2026, 10, 6).to_hijri()
print("Umm al-Qura (hijridate):", h, h.month_name("ar"))
print("tabular (convertdate) :", islamic.from_gregorian(2026, 10, 6))
print("1 Ramadan 1448 =", Hijri(1448, 9, 1).to_gregorian())
try:
Hijri(1501, 1, 1).to_gregorian()
except OverflowError as e:
print("OverflowError:", e)والمخرج سوف يكون كما يلي:
hijridate 2.6.0 | convertdate 2.5.1
Umm al-Qura (hijridate): 1448-04-25 ربيع الثاني
tabular (convertdate) : (1448, 4, 23)
1 Ramadan 1448 = 2027-02-08
OverflowError: year must be in 1343-1500, got '1501'لاحظ أن hijridate وافقت ICU في أم القرى (25)، وconvertdate وافقت islamic-civil (23)، ولاحظ أيضاً أن اسم الشهر "ربيع الثاني" هنا و"ربيع الآخر" في ICU، فلا تقارن أسماء الشهور نصاً بين مكتبتين. والسطر الأخير مهم: جدول أم القرى في hijridate يغطي السنوات من 1343 إلى 1500 هجرية فقط، والمكتبة ترفض ما بعدها برسالة خطأ واضحة، وهذا أفضل من أن تعطيك تاريخاً حسابياً بصمت.
وفي Java يوجد التقويم الهجري في المكتبة القياسية عبر HijrahChronology، والتوثيق ينص على أنه يستخدم صيغة أم القرى:
import java.time.LocalDate;
import java.time.chrono.HijrahChronology;
import java.time.chrono.HijrahDate;
import java.time.temporal.ChronoField;
public class Hijri {
public static void main(String[] args) {
System.out.println("java " + Runtime.version());
HijrahDate h = HijrahDate.from(LocalDate.of(2026, 10, 6));
System.out.println(h + " | " + h.getChronology().getCalendarType());
System.out.println("years: " + HijrahChronology.INSTANCE.range(ChronoField.YEAR));
System.out.println("1 Ramadan 1448 = " + LocalDate.from(HijrahDate.of(1448, 9, 1)));
}
}وعند تشغيله بالأمر java Hijri.java في Container من eclipse-temurin:21-jdk-alpine سوف يكون المخرج كما يلي:
java 21.0.12.1+1-LTS
Hijrah-umalqura AH 1448-04-25 | islamic-umalqura
years: 1300 - 1600
1 Ramadan 1448 = 2027-02-08والنتيجة نفسها في اللغات الثلاث: 25 ربيع الآخر 1448، و1 رمضان 1448 يوافق 8 فبراير 2027 بحسب أم القرى. ولاحظ أن مدى Java من 1300 إلى 1600 هجرية، وأن ICU يستخدم جدول أم القرى للمدى نفسه تقريباً ثم يرجع إلى الحساب المدني خارجه، أي أن التاريخ البعيد جداً قد يتحول بصمت إلى تقويم آخر.
قواعد التعامل مع التاريخ الهجري
- احفظ اللحظة بالميلادي وUTC، واعرض الهجري: التاريخ الهجري طريقة عرض View لليوم وليس قيمة تخزنها، فإذا حفظت
1448-04-25نصاً فلن تعرف بعد سنة بأي تقويم حسب، ولن تستطيع مقارنته أو فرزه بسهولة. - اختر التقويم صراحة:
islamic-umalquraإذا كان جمهورك في السعودية، ولا تترك الاختيار للإعداد الافتراضي كما رأيت في Node 18. - اعرض التاريخين عندما يحتاجهما القارئ: في الفواتير والعقود والخطابات الرسمية اكتب "الثلاثاء 25 ربيع الآخر 1448 الموافق 6 أكتوبر 2026"، حتى يتحقق القارئ بنفسه.
- لا تحسب المواعيد النظامية بنفسك: المهل القانونية ومواعيد الإجازات الرسمية والمناسبات الدينية تأتي من الجهة الرسمية، والحساب الفلكي في الكود قد يخالف الإعلان بيوم، فخذ التاريخ المعلن واحفظه بالميلادي.
السيرفر: ساعة واحدة صحيحة بتوقيت UTC
اضبط منطقة السيرفر على UTC
كما ذكرنا في دليل تأمين خادم VPS من أول دخول، ضبط المنطقة الزمنية للسيرفر على UTC يجعل السجلات قابلة للمقارنة بين كل الأنظمة، ويخرج السيرفر نفسه من مشكلة التوقيت الصيفي كاملة. وقد يتساءل البعض: وماذا لو كان كل المستخدمين في الرياض؟ والإجابة على ذلك أن السيرفر ليس مستخدماً، فالمستخدم يرى الوقت بتوقيته في الواجهة، والسيرفر يبقى على UTC حتى يوم تضيف سيرفراً في بلد آخر أو تنقل خدمتك إلى مزود في أوروبا. والأمر هو:
sudo timedatectl set-timezone Etc/UTC
timedatectlوالمخرج سوف يكون كما يلي (من Debian 13 مع systemd 257):
Local time: Mon 2026-10-05 22:20:45 UTC
Universal time: Mon 2026-10-05 22:20:45 UTC
RTC time: n/a
Time zone: Etc/UTC (UTC, +0000)
System clock synchronized: yes
NTP service: inactive
RTC in local TZ: noفي المخرج أعلاه لاحظ التالي:
Local timeوUniversal timeمتطابقان، وهذا ما تريده.RTC in local TZ: noيعني أن ساعة اللوحة الأم Hardware Clock أو RTC تحفظ الوقت بتوقيت UTC، وهذا هو الصحيح على Linux. أما إذا ظهرتyes، وهو ما يحدث غالباً في جهاز فيه Windows وLinux معاً، فأرجعها بالأمرsudo timedatectl set-local-rtc 0، والسبب أن الساعة المحلية في RTC تتغير مرتين في السنة في البلدان التي فيها توقيت صيفي.NTP service: inactiveهنا لأن الأمر نفذ داخل Container، وال Container لا يضبط ساعة النواة كما سيأتي، أما على السيرفر الحقيقي فيجب أن ترىactiveوSystem clock synchronized: yes.
وللمقارنة، هذا ما يعرضه الأمر نفسه بعد timedatectl set-timezone Asia/Riyadh، ولاحظ أن الساعة الحقيقية لم تتغير، وإنما تغيرت طريقة عرضها فقط:
Local time: Mon 2026-10-05 22:22:18 +03
Universal time: Mon 2026-10-05 19:22:18 UTC
RTC time: n/a
Time zone: Asia/Riyadh (+03, +0300)مزامنة الساعة: chrony وsystemd-timesyncd
كل ساعة في أي جهاز تنحرف Clock Drift ببطء، ثوان قليلة في اليوم أو أكثر في الأجهزة الافتراضية بعد إيقافها مؤقتاً أو نقلها، لذلك يحتاج كل سيرفر إلى خدمة تزامن ساعته مع سيرفرات الوقت عبر بروتوكول NTP. وفي Ubuntu وDebian تأتي غالباً systemd-timesyncd، وهي عميل بسيط يكفي لأغلب السيرفرات، أما chrony فهو الخيار الافتراضي في RHEL والتوزيعات المشابهة، وهو أدق، ويعالج الانحراف الكبير والشبكات غير المستقرة، ويدعم NTS لتوقيع رسائل الوقت. وأي منهما يكفي، المهم ألا تشغل الاثنين معاً على السيرفر نفسه.
ومصدر الوقت الافتراضي غالباً هو مشروع pool.ntp.org، وهو مجموعة كبيرة من السيرفرات التطوعية تتغير عناوينها كل ساعة. وفي chrony يكفي سطر مثل:
# /etc/chrony/chrony.conf
pool pool.ntp.org iburst maxsources 4
makestep 1 3
rtcsyncفي الإعداد أعلاه لاحظ التالي:
iburstيرسل عدة طلبات سريعة عند التشغيل حتى تتزامن الساعة خلال ثوان بدلاً من دقائق.makestep 1 3يسمح لـ chrony أن يقفز بالساعة مباشرة Step إذا كان الفرق أكبر من ثانية، ولكن في أول ثلاث تحديثات فقط، وبعدها يصحح الساعة ببطء Slew بتسريعها أو إبطائها قليلاً، والسبب أن القفز المفاجئ للساعة أثناء عمل التطبيقات هو بالضبط ما يجعل المدة المحسوبة سالبة كما في حادثة Cloudflare.rtcsyncينسخ الوقت الصحيح إلى ساعة اللوحة الأم بشكل دوري.
وللتحقق استخدم الأمرين chronyc tracking وchronyc sources، كما في توثيق chronyc. في السيرفر السليم يجب أن يظهر في tracking السطر Leap status : Normal وفارق System time صغير بالميكروثانية أو الملي ثانية، وأن تجد في sources مصدراً واحداً على الأقل بالعلامة ^* وهو المصدر المختار. ولكي تعرف شكل الخطأ، هذا مخرج حقيقي من Container لم يستطع الوصول إلى سيرفرات الوقت بشكل مستقر لأن الشبكة كانت تسقط أغلب حزم UDP على المنفذ 123:
Reference ID : 00000000 ()
Stratum : 0
Ref time (UTC) : Thu Jan 01 00:00:00 1970
System time : 0.000000000 seconds fast of NTP time
Last offset : +0.000000000 seconds
RMS offset : 0.000000000 seconds
Frequency : 0.000 ppm slow
Residual freq : +0.000 ppm
Skew : 0.000 ppm
Root delay : 1.000000000 seconds
Root dispersion : 1.000000000 seconds
Update interval : 0.0 seconds
Leap status : Not synchronised
MS Name/IP address Stratum Poll Reach LastRx Last sample
===============================================================================
^? 162.159.200.123 0 6 0 - +0ns[ +0ns] +/- 0ns
^? 216.239.35.0 1 6 27 41 -151ms[ -151ms] +/- 103ms
^? 212.138.170.134 1 6 24 42 -164ms[ -164ms] +/- 65msفي المخرج أعلاه لاحظ التالي:
Leap status : Not synchronisedوReference ID : 00000000يعنيان أن chrony لم يختر أي مصدر بعد، وأن ساعة السيرفر تعمل وحدها.- العلامة
^?أمام كل المصادر تعني أنها غير صالحة للاستخدام حالياً، والعمودReachرقم بالنظام الثماني يمثل آخر ثمانية طلبات، والقيمة377تعني أن الثمانية وصلت، أما0و24و27فتعني أن أغلب الردود ضاعت. - الحل هنا ليس في chrony، وإنما في الشبكة: تأكد أن جدار الحماية Firewall يسمح بالخروج على UDP 123، وأن مزود الخدمة لا يحجبه، أو استخدم سيرفر وقت داخلياً في شبكتك.
ال Containers: ساعة واحدة ومنطقة لكل Container
ال Container ليس جهازاً افتراضياً، وإنما عملية تعمل على نواة Kernel السيرفر نفسها، لذلك كل ال Containers تقرأ ساعة النواة نفسها، ولا يستطيع أي منها أن يغيرها، ولكن لكل Container منطقته الزمنية الخاصة التي يأخذها من الملف /etc/localtime أو من متغير البيئة TZ. والأوامر التالية تبين ذلك على Debian وAlpine:
docker run --rm debian:13-slim sh -c 'date; ls -l /etc/localtime'
docker run --rm -e TZ=Asia/Riyadh debian:13-slim date
docker run --rm debian:13-slim date -s "2030-01-01"والمخرج سوف يكون كما يلي:
Mon Oct 5 21:25:47 UTC 2026
lrwxrwxrwx 1 root root 27 Sep 18 00:00 /etc/localtime -> /usr/share/zoneinfo/Etc/UTC
Tue Oct 6 00:25:50 +03 2026
date: cannot set date: Operation not permitted
Tue Jan 1 00:00:00 UTC 2030لاحظ أن ال Image يأتي على UTC، وأن TZ=Asia/Riyadh غير طريقة العرض فقط، وأن محاولة تغيير الساعة من داخل ال Container فشلت بالرسالة Operation not permitted، والسطر الأخير هو الوقت الذي طلبناه يطبع فقط ولم يطبق. لذلك مزامنة الساعة مسؤولية السيرفر، وليست مسؤولية ال Container.
والآن نكرر التجربة على Alpine، وهنا يظهر الفخ:
docker run --rm -e TZ=Asia/Riyadh alpine:3.22 sh -c 'date; ls /usr/share/zoneinfo'
docker run --rm -e TZ=Asia/Riyadh alpine:3.22 sh -c 'apk add -q --no-cache tzdata && date'والمخرج سوف يكون كما يلي:
Mon Oct 5 21:25:53 UTC 2026
ls: /usr/share/zoneinfo: No such file or directory
Tue Oct 6 00:26:20 +03 2026في الأمر الأول طلبنا توقيت الرياض فأعطانا Alpine توقيت UTC دون أي رسالة خطأ، والسبب أن ال Image لا يحتوي على ملفات tzdata أصلاً، فلم يجد تعريف Asia/Riyadh ورجع إلى UTC بصمت. وبعد تثبيت الحزمة tzdata ظهر التوقيت الصحيح. لذلك إذا احتجت إلى منطقة غير UTC في Image صغير، فأضف السطر التالي إلى ال Dockerfile:
RUN apk add --no-cache tzdataوفي Debian وUbuntu تأتي الحزمة tzdata غالباً مع ال Image، ولكن تذكر أنها نسخة مجمدة من يوم بناء ال Image، فإعادة بناء ال Images دورياً تحدث قواعد المناطق الزمنية كما تحدث الثغرات الأمنية. وهناك طريقة ثالثة على سيرفر Linux هي ربط ملف السيرفر داخل ال Container للقراءة فقط بالخيار -v /etc/localtime:/etc/localtime:ro، وهي تعمل، ولكن الأوضح أن تكتب TZ في ملف docker-compose.yml حتى يراها كل من يقرأ الإعداد. وأفضل من الاثنين أن تترك ال Containers على UTC كما ذكرنا في قائمة تحقق المطور، وأن يحول التطبيق الوقت عند العرض.
وهناك فخ آخر لا يحله تحديث حزمة النظام، وهو أن بعض بيئات التشغيل تحمل نسختها الخاصة من tzdata ولا تقرأ ملفات /usr/share/zoneinfo أصلاً. فقد رأيت في قسم التقويم الهجري أن Node.js يحمل tzdata داخل ICU، وJava تحمل نسختها في الملف tzdb.dat داخل ال JDK، وتحدث مع تحديثات Java نفسها أو بأداة TZUpdater كما في توثيق TZUpdater من Oracle. البرنامج التالي يطبع نسخة tzdata التي تستخدمها Java، ثم يسألها عن موعد الدار البيضاء الذي رأيته في قسم المناطق الزمنية:
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.zone.ZoneRulesProvider;
public class Casablanca {
public static void main(String[] args) {
System.out.println("java " + Runtime.version() + " | tzdata " + ZoneRulesProvider.getVersions("Africa/Casablanca").lastKey());
System.out.println(LocalDateTime.of(2026, 12, 1, 9, 0).atZone(ZoneId.of("Africa/Casablanca")));
}
}ونشغله في Container من eclipse-temurin:21-jdk-alpine بعد أن نعرض نسخة حزمة tzdata في النظام نفسه:
apk info -v 2>/dev/null | grep tzdata
java Casablanca.javaوالمخرج سوف يكون كما يلي:
tzdata-2026d-r0
java 21.0.12.1+1-LTS | tzdata 2026b
2026-12-01T09:00+01:00[Africa/Casablanca]في المخرج أعلاه لاحظ التالي:
- نظام التشغيل في ال Image يحمل tzdata بالإصدار
2026dالذي يعرف قرار المغرب، ولكن Java في ال Container نفسه تستخدم نسختها2026b، وهي أقدم من 2026c التي وصل فيها القرار، لذلك حسبت الموعد بفارق+01:00وهو خطأ، والصحيح+00:00كما رأيت في مثال Python. - ال Image بني في 25 سبتمبر 2026، أي بعد صدور 2026c بأكثر من شهرين، والسبب أن Java تأخذ tzdata مع إصداراتها الدورية، فتحديث النظام وإعادة بناء ال Image لا يكفيان، وإنما يجب أن تتابع إصدارات JDK نفسها، أو تستخدم TZUpdater إذا كان القرار عاجلاً.
- وفي Python تقرأ
zoneinfoملفات النظام أولاً، ثم ترجع إلى الحزمةtzdataمن PyPI إذا لم تجدها، كما في توثيق zoneinfo، والتوثيق نفسه يذكر أن Windows لا يحمل قاعدة IANA، لذلك أضفtzdataإلى متطلبات المشروع إذا كان يعمل على Windows، وحدثها كما تحدث أي مكتبة.
لذلك اعرف لكل بيئة تشغيل عندك من أين تأخذ قواعد المناطق: process.versions.tz في Node.js، وZoneRulesProvider.getVersions في Java، ونسخة الحزمة tzdata في النظام، واجعل التحقق منها جزءاً من مراجعة الترقيات.
قواعد البيانات: PostgreSQL وMySQL
في PostgreSQL نوعان للوقت: timestamp (واسمه الكامل timestamp without time zone) الذي يحفظ الأرقام كما كتبتها دون أي معنى زمني، وtimestamptz الذي يحول القيمة إلى UTC عند الحفظ ويعرضها بتوقيت الجلسة TimeZone عند القراءة، كما ينص توثيق PostgreSQL. ولاحظ أن الاسم مضلل، فـ timestamptz لا يحفظ المنطقة الزمنية نفسها، وإنما يحفظ لحظة. والتجربة التالية على PostgreSQL 17 تحفظ الساعة 09:00 بتوقيت الرياض في العمودين، ثم تقرؤها من جلسات بمناطق مختلفة:
CREATE TABLE events (id int, plain timestamp, aware timestamptz);
SET TimeZone = 'Asia/Riyadh';
INSERT INTO events VALUES (1, '2026-10-06 09:00', '2026-10-06 09:00');
SELECT id, plain, aware FROM events;
SET TimeZone = 'UTC';
SELECT id, plain, aware FROM events;
SET TimeZone = 'Europe/London';
SELECT id, plain, aware FROM events;
SELECT aware AT TIME ZONE 'Asia/Riyadh' AS riyadh_wall_clock FROM events;والمخرج سوف يكون كما يلي:
id | plain | aware
----+---------------------+------------------------
1 | 2026-10-06 09:00:00 | 2026-10-06 09:00:00+03
(1 row)
id | plain | aware
----+---------------------+------------------------
1 | 2026-10-06 09:00:00 | 2026-10-06 06:00:00+00
(1 row)
id | plain | aware
----+---------------------+------------------------
1 | 2026-10-06 09:00:00 | 2026-10-06 07:00:00+01
(1 row)
riyadh_wall_clock
---------------------
2026-10-06 09:00:00
(1 row)في المخرج أعلاه لاحظ التالي:
- العمود
awareلحظة واحدة تعرض بتوقيت كل جلسة: 09:00 في الرياض و06:00 في UTC و07:00 في لندن، وهذا هو السلوك الذي تريده. - العمود
plainبقي 09:00 في الجلسات الثلاث، ولا أحد يعرف بعد سنة هل كانت 09:00 بتوقيت الرياض أم بتوقيت السيرفر، لذلك استخدمtimestamptzلكل لحظة حدثت أو ستحدث. AT TIME ZONEتحول اللحظة إلى ساعة حائط في منطقة محددة، وهي الطريقة الصحيحة لحساب "اليوم" بتوقيت العمل كما سيأتي.- والأفضل أن يرسل التطبيق القيم بصيغة RFC 3339 مع الفارق أو
Z، حتى لا تعتمد النتيجة على إعدادTimeZoneفي الجلسة.
وهذا هو أصل خطأ "التقرير اليومي ناقص ثلاث ساعات". ثلاثة طلبات، أحدها في الساعة 22:30 بتوقيت UTC يوم 5 أكتوبر، أي 01:30 بعد منتصف الليل بتوقيت الرياض يوم 6 أكتوبر:
SET TimeZone = 'UTC';
CREATE TABLE orders (id int, created_at timestamptz);
INSERT INTO orders VALUES
(1, '2026-10-05 20:30:00+00'),
(2, '2026-10-05 22:30:00+00'),
(3, '2026-10-06 08:15:00+00');
-- the report as the server sees it (UTC days)
SELECT created_at::date AS day, count(*) FROM orders GROUP BY 1 ORDER BY 1;
-- the report as the business in Riyadh sees it
SELECT (created_at AT TIME ZONE 'Asia/Riyadh')::date AS day, count(*) FROM orders GROUP BY 1 ORDER BY 1;والمخرج سوف يكون كما يلي:
day | count
------------+-------
2026-10-05 | 2
2026-10-06 | 1
(2 rows)
day | count
------------+-------
2026-10-05 | 1
2026-10-06 | 2
(2 rows)الاستعلام الأول قسم الأيام بتوقيت UTC فحسب الطلب الثاني على يوم 5 أكتوبر، والثاني قسمها بتوقيت الرياض فحسبه على يوم 6، والفرق بينهما هو كل الطلبات بين منتصف الليل والثالثة فجراً. لذلك كل تقرير "يومي" يجب أن يحدد بتوقيت من يحسب اليوم.
وفي MySQL الوضع مشابه بأسماء مختلفة: DATETIME يحفظ القيمة كما هي دون أي تحويل، وTIMESTAMP يحولها من منطقة الجلسة إلى UTC عند الحفظ ويعيدها عند القراءة، ولكن TIMESTAMP عدد ثوان بحجم 32 بت، فينتهي مداه في 2038. والتجربة التالية على MySQL 8.4:
CREATE TABLE events (id INT, plain DATETIME, stamp TIMESTAMP NULL);
SET time_zone = '+03:00';
INSERT INTO events VALUES (1, '2026-10-06 09:00:00', '2026-10-06 09:00:00');
SELECT * FROM events;
SET time_zone = '+00:00';
SELECT * FROM events;
SET time_zone = 'Asia/Riyadh';
INSERT INTO events VALUES (2, '2038-01-19 06:14:07', '2038-01-19 06:14:07');
INSERT INTO events VALUES (3, '2038-01-19 06:14:08', '2038-01-19 06:14:08');والمخرج سوف يكون كما يلي:
+------+---------------------+---------------------+
| id | plain | stamp |
+------+---------------------+---------------------+
| 1 | 2026-10-06 09:00:00 | 2026-10-06 09:00:00 |
+------+---------------------+---------------------+
+------+---------------------+---------------------+
| id | plain | stamp |
+------+---------------------+---------------------+
| 1 | 2026-10-06 09:00:00 | 2026-10-06 06:00:00 |
+------+---------------------+---------------------+
ERROR 1292 (22007) at line 9: Incorrect datetime value: '2038-01-19 06:14:08' for column 'stamp' at row 1لاحظ أن stamp تحول مع الجلسة وplain لم يتحول، ولاحظ أن الساعة 06:14:07 بتوقيت الرياض، وهي 03:14:07 بتوقيت UTC، قبلت، أما الثانية التي بعدها فرفضت بالخطأ 1292. لذلك في MySQL احفظ اللحظات في DATETIME بتوقيت UTC دائماً، واجعل التطبيق يضبط جلسته على +00:00، أو استخدم TIMESTAMP وأنت تعرف أن مداه ينتهي في 2038. ولاحظ أيضاً أن أسماء المناطق مثل 'Asia/Riyadh' تعمل في MySQL فقط إذا حملت جداول المناطق الزمنية في قاعدة البيانات، وهي محملة في ال Image الرسمي mysql:8.4 كما يبين نجاح الإدخال الثاني أعلاه، أما الفارق الثابت '+03:00' فيعمل دائماً.
في الكود: احفظ UTC وحول عند الأطراف
القاعدة الأساسية واحدة، وكل ما بعدها تفاصيل: داخل النظام كله، في قاعدة البيانات وال API والطوابير Queues والسجلات، يتحرك الوقت لحظة بتوقيت UTC، والتحويل إلى ساعة حائط يحدث في طرفين فقط: عند الإدخال من المستخدم، وعند العرض له. ومعه تحفظ منطقة المستخدم باسمها في IANA في حقل منفصل.

وفيما يلي القواعد بالتفصيل، كل قاعدة مع سببها:
- احفظ اللحظات بتوقيت UTC:
timestamptzفي PostgreSQL، وDATETIMEبتوقيت UTC في MySQL، وUnix Time بحجم 64 بت إذا كان رقماً، والسبب أن المقارنة والترتيب وحساب المدة تصبح عمليات بسيطة لا تحتاج إلى قواعد. - احفظ منطقة المستخدم باسمها:
Asia/Riyadhوليس+03:00وليسAST، والسبب أن الاسم يحمل القواعد الماضية والمستقبلية، والفارق يصح للحظة واحدة فقط، والاختصار غامض. - ال API يرسل ويستقبل RFC 3339 مع
Zأو فارق صريح: ارفض القيمة التي تصل دون فارق، مثل2026-10-06T09:00:00، أو اعرف بالضبط بأي منطقة تفسرها، والسبب أن كل مكتبة تفسرها بطريقة مختلفة، فبعضها يعتبرها UTC وبعضها يعتبرها توقيت الجهاز. - لا تقرأ ساعة الجهاز ولا منطقته في منطق العمل: الدوال مثل
datetime.now()دون منطقة في Python وnew Date().getHours()في JavaScript تعتمد على منطقة الجهاز الذي يعمل عليه الكود، فتعطي نتيجة على جهاز المطور ونتيجة أخرى في ال Container. وفي Python أصبحت الدالةdatetime.utcnow()متوقفة Deprecated منذ الإصدار 3.12 كما في توثيق datetime، لأنها تعيد وقتاً دون منطقة، واستخدم بدلهاdatetime.now(timezone.utc). - استخدم مكتبات تفهم المناطق، ولا تحسب الفارق بيدك: في Java الحزمة
java.timeبأنواعهاInstantوZonedDateTimeوLocalDateTime، وفي Python الوحدةzoneinfoمنذ الإصدار 3.9، وفي Go الحزمةtimeمعtime.LoadLocation، وإذا كان البرنامج يعمل في Image لا يحتوي على tzdata فأضفtime/tzdataلتضمين القواعد في الملف التنفيذي. - JavaScript وTemporal: الكائن
Dateالقديم لا يعرف إلا UTC ومنطقة الجهاز، والبديل الحديث هو Temporal بأنواع مثلTemporal.InstantوTemporal.ZonedDateTimeوTemporal.PlainDate، وقد وصل إلى المرحلة الرابعة Stage 4 في TC39 في مارس 2026 ودخل قائمة المقترحات المكتملة لإصدار ES2027. وهو متوفر في Chrome وEdge من الإصدار 144، وFirefox من 139، وNode.js من الإصدار 26، ولكنه لم يصل إلى Safari المستقر حتى كتابة هذا المقال، لذلك في الواجهات التي يفتحها مستخدمو iPhone استخدم Polyfill أو مكتبة مثل Luxon أو date-fns-tz. - التاريخ دون وقت قيمة مختلفة: تاريخ الميلاد أو تاريخ الإجازة ليس لحظة، فلا تحفظه
2026-10-06T00:00:00Z، والسبب أن المستخدم في نيويورك سوف يراه 5 أكتوبر. احفظهdateفي قاعدة البيانات وLocalDateفي Java وTemporal.PlainDateفي JavaScript. - اختبر بساعة مجمدة: لا تجعل الكود يقرأ الساعة مباشرة، وإنما مرر له ساعة يمكن استبدالها في الاختبار، مثل
java.time.Clockفي Java، أو دالةnowتمررها إلى الكلاس في Python وJavaScript، أو الساعات المزيفة Fake Timers في أدوات الاختبار. ثم اكتب اختبارات لليلتي التوقيت الصيفي في منطقة فيها DST، ولنهاية الشهر، وللتاريخ 29 فبراير، ولنهاية السنة بتوقيت الرياض وهي ما زالت 31 ديسمبر بتوقيت UTC.
وأشهر الأخطاء في كود الوقت:
| الخطأ | ماذا يحدث | الصحيح |
|---|---|---|
حفظ 09:00 دون منطقة | لا أحد يعرف بأي توقيت هي بعد شهر | لحظة UTC، أو وقت محلي مع اسم المنطقة |
حفظ +03:00 بدل Asia/Riyadh | يخطئ في الدول التي تغير ساعتها أو قواعدها | اسم المنطقة من IANA |
| إضافة 86400 ثانية لتحصل على "غداً" | في ليلة التوقيت الصيفي اليوم 23 أو 25 ساعة | إضافة يوم في منطقة المستخدم بدالة المكتبة |
| حساب المدة بطرح قراءتين من ساعة النظام | مدة سالبة إذا رجعت الساعة | الساعة الرتيبة Monotonic Clock |
ar-SA دون تقويم أو أرقام | يتغير العرض بعد ترقية المتصفح أو Node | ar-SA-u-ca-gregory-nu-latn أو islamic-umalqura صراحة |
تحليل التاريخ 06/10/2026 | يونيو أو أكتوبر بحسب المكتبة | RFC 3339 فقط بين الأنظمة |
| الفارق في عمود ساعات صحيحة | إيران +03:30 ونيبال +05:45 لا تدخل فيه | اسم المنطقة، أو الفارق بالدقائق |
| إضافة شهر إلى نتيجة إضافة شهر | 31 يناير يصبح 28 مارس بعد شهرين | احسب كل مرة من تاريخ البداية |
YYYY بدل yyyy في نمط التاريخ | أيام آخر ديسمبر تظهر بالسنة القادمة | yyyy للسنة، وسنة الأسبوع مع رقم الأسبوع فقط |
اليوم والشهر والأسبوع: حساب يبدو بسيطاً
رأيت أن اليوم ليس 86400 ثانية دائماً، فهو 23 أو 25 ساعة في ليلتي التوقيت الصيفي، وقد لا يأتي أصلاً كما في ساموا. والشهر أصعب من اليوم، لأن طوله 28 أو 29 أو 30 أو 31 يوماً، فماذا يعني "بعد شهر" من 31 يناير؟ ولا يوجد جواب واحد صحيح، وإنما قرار تتخذه أنت، والمهم أن تعرف ما تقرره مكتبتك. البرنامج التالي بلغة Java يجرب إضافة الشهور، ثم يطبع ثلاثة أيام حول نهاية السنة بنمطين للسنة:
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.temporal.IsoFields;
import java.util.Locale;
public class Calendar {
public static void main(String[] args) {
LocalDate jan31 = LocalDate.of(2026, 1, 31);
System.out.println("31 Jan 2026 plusMonths(1) = " + jan31.plusMonths(1));
System.out.println("31 Jan 2026 plusMonths(1).plusMonths(1) = " + jan31.plusMonths(1).plusMonths(1));
System.out.println("31 Jan 2026 plusMonths(2) = " + jan31.plusMonths(2));
DateTimeFormatter wrong = DateTimeFormatter.ofPattern("YYYY-MM-dd", Locale.US);
DateTimeFormatter right = DateTimeFormatter.ofPattern("yyyy-MM-dd", Locale.US);
for (LocalDate d : new LocalDate[] { LocalDate.of(2026, 12, 26), LocalDate.of(2026, 12, 27), LocalDate.of(2027, 1, 1) }) {
System.out.println(right.format(d) + " | YYYY gives " + wrong.format(d)
+ " | ISO week " + d.get(IsoFields.WEEK_BASED_YEAR) + "-W" + d.get(IsoFields.WEEK_OF_WEEK_BASED_YEAR));
}
}
}وعند تشغيله بالأمر java Calendar.java في Container من eclipse-temurin:21-jdk-alpine سوف يكون المخرج كما يلي:
31 Jan 2026 plusMonths(1) = 2026-02-28
31 Jan 2026 plusMonths(1).plusMonths(1) = 2026-03-28
31 Jan 2026 plusMonths(2) = 2026-03-31
2026-12-26 | YYYY gives 2026-12-26 | ISO week 2026-W52
2026-12-27 | YYYY gives 2027-12-27 | ISO week 2026-W52
2027-01-01 | YYYY gives 2027-01-01 | ISO week 2026-W53في المخرج أعلاه لاحظ التالي:
plusMonths(1)من 31 يناير أعطى 28 فبراير، لأن LocalDate ينزل إلى آخر يوم صحيح في الشهر، وهذا معقول. ولكن إضافة شهر ثم شهر أعطت 28 مارس، وإضافة شهرين مرة واحدة أعطت 31 مارس، أي أن النتيجة تعتمد على طريقة الحساب. لذلك في الاشتراكات الشهرية والأقساط احفظ تاريخ البداية واحسب كل موعد منه مباشرة، مثلstart.plusMonths(n)، ولا تقم بإضافة شهر إلى الموعد السابق، والسبب أن الاشتراك الذي بدأ يوم 31 سوف ينزل إلى 28 في فبراير ثم يبقى على 28 إلى الأبد.- الحرف
Yفي أنماط DateTimeFormatter هو سنة الأسبوع Week-Based Year وليس سنة التقويم، والسنة هيy. فيوم 27 ديسمبر 2026 خرج بالنمطYYYYعلى أنه2027-12-27، أي خطأ بسنة كاملة، والسبب أن الأسبوع فيLocale.USيبدأ يوم الأحد، والأسبوع الذي يبدأ يوم 27 ديسمبر فيه 1 يناير، فيعتبر الأسبوع الأول من 2027. وهذا الخطأ لا يظهر في الاختبارات طوال السنة، ثم يظهر في الأيام الأخيرة من ديسمبر فقط. - الأسبوع في ISO 8601 يبدأ يوم الاثنين، والأسبوع الأول هو الذي فيه أول خميس في السنة، لذلك يوم 1 يناير 2027 ينتمي إلى الأسبوع
2026-W53، أي أن سنة 2026 فيها 53 أسبوعاً. فإذا كانت تقاريرك أسبوعية برقم الأسبوع، فاستخدم سنة الأسبوع مع رقم الأسبوع معاً، والسنة العادية مع الشهر واليوم، ولا تخلط بينهما.
وفي JavaScript الكائن Date لا ينزل إلى آخر الشهر، وإنما يزيد الأيام الفائضة على الشهر التالي، وفوق ذلك أول يوم في الأسبوع وأيام العطلة Weekend تختلف بين الدول، وهي بيانات موجودة في CLDR وتقرؤها عبر Intl.Locale. المثال التالي يبين الأمرين:
// The first day of the week and the weekend per locale (CLDR data in ICU), and adding a month with Date.
for (const tag of ['ar-SA', 'ar-AE', 'ar-EG', 'en-GB', 'en-US']) {
const loc = new Intl.Locale(tag);
const w = loc.getWeekInfo?.() ?? loc.weekInfo; // Node 22 still has the older weekInfo getter
console.log(tag.padEnd(5), 'firstDay', w.firstDay, '| weekend', JSON.stringify(w.weekend));
}
const d = new Date(Date.UTC(2026, 0, 31)); // 31 January 2026
d.setUTCMonth(d.getUTCMonth() + 1);
console.log('31 Jan 2026 + 1 month with Date =', d.toISOString().slice(0, 10));وعند تشغيله في Container من node:22-alpine (Node.js v22.23.3) سوف يكون المخرج كما يلي:
ar-SA firstDay 7 | weekend [5,6]
ar-AE firstDay 1 | weekend [6,7]
ar-EG firstDay 6 | weekend [5,6]
en-GB firstDay 1 | weekend [6,7]
en-US firstDay 7 | weekend [6,7]
31 Jan 2026 + 1 month with Date = 2026-03-03في المخرج أعلاه لاحظ التالي:
- الأيام مرقمة من 1 للاثنين إلى 7 للأحد، فالأسبوع في
ar-SAيبدأ يوم الأحد والعطلة الجمعة والسبت، وفيar-AEيبدأ يوم الاثنين والعطلة السبت والأحد، وفيar-EGيبدأ يوم السبت. لذلك لا تقم بكتابة "أيام العمل من الاثنين إلى الجمعة" ثابتة في الكود عندما تحسب مهلة اتفاقية الخدمة SLA أو موعد التسليم، والسبب أن عميلك في الرياض وعميلك في دبي لا يعملان الأيام نفسها، واحفظ أيام العمل والإجازات لكل عميل أو فرع كإعداد. - السطر في الكود الذي يجرب
getWeekInfo()ثمweekInfoليس زينة، فالدالةgetWeekInfo()هي الاسم الحالي في المعيار، وNode 22 ما زال يوفر الخاصية القديمةweekInfoفقط، ولو كتبنا الدالة وحدها لتوقف البرنامج بالخطأgetWeekInfo is not a function. - 31 يناير مع شهر أصبح 3 مارس في
Date، و28 فبراير في Java، فالمكتبتان أجابتا عن السؤال نفسه بجوابين مختلفين. والبديل الحديث Temporal الذي ذكرناه أعلاه ينزل إلى آخر الشهر مثل Java افتراضياً، ويقبل في الدالة add الخيارoverflow: 'reject'إذا أردت خطأ صريحاً بدلاً من أي تعديل.
عرض الوقت للمستخدم: "قبل 5 دقائق" والعنصر time وحقول الإدخال
أغلب الواجهات تعرض الوقت القريب نسبياً، مثل "قبل 5 دقائق" و"أمس"، وفي العربية هذا أصعب منه في الإنجليزية بسبب المفرد والمثنى والجمع، فلا تقم بتركيب النص بيدك مثل "قبل " + n + " دقائق"، وإنما استخدم Intl.RelativeTimeFormat الذي يعرف قواعد اللغة:
// Relative time in Arabic: the library picks the singular, dual and plural forms.
const rtf = new Intl.RelativeTimeFormat('ar', { numeric: 'auto' });
for (const [n, unit] of [[-1, 'minute'], [-2, 'minute'], [-5, 'minute'], [-11, 'minute'], [-1, 'day'], [-2, 'day'], [-14, 'month']]) {
console.log(String(n).padStart(3), unit.padEnd(6), rtf.format(n, unit));
}والمخرج في Node.js 22 سوف يكون كما يلي:
-1 minute قبل دقيقة واحدة
-2 minute قبل دقيقتين
-5 minute قبل 5 دقائق
-11 minute قبل 11 دقيقة
-1 day أمس
-2 day أول أمس
-14 month قبل 14 شهرًالاحظ "دقيقتين" و"5 دقائق" و"11 دقيقة" و"أول أمس"، وكلها جاءت من بيانات CLDR دون أي شرط في الكود. ولكن الوقت النسبي له مشكلتان: النص يبقى كما هو إذا ترك المستخدم الصفحة مفتوحة ساعة، فيحتاج إلى تحديث دوري في الصفحة، و"قبل 14 شهراً" لا تخبر القارئ متى حدث الشيء بالضبط. والحل أن تضع النص النسبي داخل العنصر <time>، مع الوقت الكامل في title ليظهر عند الوقوف عليه، واللحظة بصيغة RFC 3339 في datetime لتقرأها البرامج ومحركات البحث وقارئات الشاشة:
<time datetime="2026-10-06T06:00:00Z" title="الثلاثاء 6 أكتوبر 2026، 09:00 بتوقيت الرياض">قبل 5 دقائق</time>وفي الاتجاه المعاكس، أي عند إدخال التاريخ، لاحظ أمرين في حقول HTML:
<input type="date">يعرض التاريخ بتنسيق لغة المتصفح، ولكن قيمته التي تصل إلى السيرفر دائماً بالصيغةyyyy-mm-dd، فلا تقم بتحليلها بحسب لغة المستخدم.<input type="datetime-local">يعطيك وقتاً محلياً مثل2026-12-01T09:00دون أي منطقة زمنية، والتوثيق يذكر أنه يقبل حتى الساعة التي لا توجد في ليلة التوقيت الصيفي. لذلك أرسل معه اسم منطقة المستخدم منIntl.DateTimeFormat().resolvedOptions().timeZoneأو من إعداد حسابه، وحوله في السيرفر بمكتبة تعرف المناطق، وقرر ماذا تفعل إذا وقع في الفجوة Gap أو التداخل Overlap كما رأيت في قسم التوقيت الصيفي.
المهام المجدولة والتذكيرات
هنا يظهر كل ما سبق في وقت واحد، فالمهمة المجدولة هي "ساعة حائط" تنتظر رقماً معيناً، والسؤال الأول دائماً: ساعة حائط أي منطقة؟
cron: يعمل بمنطقة النظام
البرنامج cron يقرأ الجدول بالمنطقة الزمنية للنظام، فإذا كان السيرفر على UTC كما نصحنا، فالسطر 0 4 * * * يعني الساعة 04:00 بتوقيت UTC، أي 07:00 بتوقيت الرياض. ولأن الرياض لا يوجد فيها توقيت صيفي، فهذا الحساب يبقى صحيحاً طوال السنة، وهذا هو الحل الأبسط والأوضح لمعظم مهام السيرفر: اكتب الجدول بتوقيت UTC، واكتب فوقه تعليقاً بالتوقيت المحلي:
# 07:00 Asia/Riyadh = 04:00 UTC (Riyadh has no DST)
0 4 * * * /usr/local/bin/daily-reportأما إذا كانت المهمة مرتبطة بساعة حائط في منطقة فيها توقيت صيفي، مثل تقرير لفرع القاهرة الساعة 08:00 بتوقيتها، فالسطر الثابت بتوقيت UTC سوف يتحرك ساعة مرتين في السنة. وهنا تختلف نسخ cron:
- cronie، وهو cron في Fedora وRHEL وما يشبهها، يدعم المتغير
CRON_TZفي أول الجدول كما في صفحة crontab(5)، فيقرأ أوقات الجدول بتلك المنطقة. - cron في Debian وUbuntu لا يدعم منطقة خاصة لكل جدول، وصفحة crontab(5) في Debian تقول صراحة إن كتابة
TZفي الجدول تغير بيئة الأوامر فقط، وليس وقت تنفيذها، فإذا كتبتCRON_TZ=Africa/Cairoفلن يحدث أي شيء ولن تظهر أي رسالة.
أما ليلة التوقيت الصيفي على سيرفر ليس على UTC، فتنص صفحة cron(8) في Debian على معاملة خاصة إذا تغيرت الساعة بأقل من 3 ساعات: إذا تقدمت الساعة فالمهام التي وقعت في الوقت المفقود تنفذ بعد التغيير بقليل، وإذا رجعت فالمهام التي وقعت في الوقت المكرر لا تعاد، وcronie فيه سلوك قريب. ولكن هذه المعاملة تخص المهام ذات الوقت المحدد فقط، أما المهام التي فيها * في الساعة أو الدقيقة مثل 30 * * * *، فتتبع ساعة الحائط كما هي، فتفقد تشغيلاً في الربيع وقد تتكرر في الخريف. وغير cron من أدوات الجدولة، مثل الجدولة داخل التطبيقات ومكتبات الطوابير، لها سلوك مختلف في كل أداة. لذلك الحل الأنسب هو أن يبقى السيرفر على UTC فلا توجد ليلة توقيت صيفي أصلاً، وأن تستخدم أداة تفهم المناطق صراحة عندما تحتاج إلى ساعة حائط محلية.
systemd timers: المنطقة داخل الجدول
مؤقتات systemd تقبل اسم المنطقة الزمنية في آخر OnCalendar منذ systemd 235، كما في صفحة systemd.time(7)، فتكتب الوقت بالمنطقة التي تقصدها ويبقى السيرفر على UTC. المثال التالي ينشئ تقريراً يومياً الساعة 07:00 بتوقيت الرياض، وأولاً ملف الخدمة:
# /etc/systemd/system/daily-report.service
[Unit]
Description=Daily sales report
[Service]
Type=oneshot
ExecStart=/bin/echo "report generated"ثم ملف المؤقت:
# /etc/systemd/system/daily-report.timer
[Unit]
Description=Daily sales report at 07:00 Riyadh time
[Timer]
OnCalendar=*-*-* 07:00:00 Asia/Riyadh
Persistent=true
[Install]
WantedBy=timers.targetثم نفعله ونعرض موعده القادم:
sudo systemctl daemon-reload
sudo systemctl enable --now daily-report.timer
systemctl list-timers daily-report.timerوالمخرج سوف يكون كما يلي:
NEXT LEFT LAST PASSED UNIT ACTIVATES
Tue 2026-10-06 04:00:00 UTC 5h 35min - - daily-report.timer daily-report.service
1 timers listed.
Pass --all to see loaded but inactive timers, too.في الإعداد أعلاه لاحظ التالي:
- السيرفر على UTC، ولذلك عرض
list-timersالموعد04:00:00 UTC، وهو 07:00 بتوقيت الرياض كما كتبناه. Persistent=trueيعني أن السيرفر إذا كان مطفأً وقت الموعد، فسوف تنفذ المهمة مباشرة عند تشغيله، كما في صفحة systemd.timer(5)، وهذا ما لا يفعله cron.- مخرجات المهمة تذهب إلى السجل، وتقرؤها بالأمر
journalctl -u daily-report.service.
والأجمل أن systemd يعطيك طريقة لترى سلوك الجدول في ليلة التوقيت الصيفي قبل أن تأتي، بالأمر systemd-analyze calendar مع الخيار --base-time. لنسأله عن مهمة الساعة 02:30 بتوقيت برلين حول الليلتين:
systemd-analyze calendar --iterations=3 "*-*-* 02:30:00 Europe/Berlin" --base-time="2026-03-27 00:00:00 UTC"
systemd-analyze calendar --iterations=3 "*-*-* 02:30:00 Europe/Berlin" --base-time="2026-10-24 00:00:00 UTC"والمخرج سوف يكون كما يلي:
Normalized form: *-*-* 02:30:00 Europe/Berlin
Next elapse: Fri 2026-03-27 01:30:00 UTC
From now: 6 months 10 days ago
Iteration #2: Sat 2026-03-28 01:30:00 UTC
From now: 6 months 9 days ago
Iteration #3: Mon 2026-03-30 00:30:00 UTC
From now: 6 months 7 days ago
Normalized form: *-*-* 02:30:00 Europe/Berlin
Next elapse: Sat 2026-10-24 00:30:00 UTC
From now: 2 weeks 4 days left
Iteration #2: Sun 2026-10-25 00:30:00 UTC
From now: 2 weeks 5 days left
Iteration #3: Mon 2026-10-26 01:30:00 UTC
From now: 2 weeks 6 days leftلاحظ أن الجدول الأول قفز من 28 مارس إلى 30 مارس، أي أن systemd لم ينفذ المهمة يوم 29 مارس أصلاً لأن الساعة 02:30 لم تأت في برلين، وأن الجدول الثاني نفذها يوم 25 أكتوبر مرة واحدة عند 00:30 UTC، أي عند أول 02:30. فإذا كانت المهمة مهمة لا يجوز أن تفوت، مثل النسخ الاحتياطي، فلا تضعها في الساعة بين 02:00 و03:00 في منطقة فيها توقيت صيفي، أو اكتبها بتوقيت UTC.
Kubernetes CronJob
في Kubernetes أصبح الحقل spec.timeZone في CronJob مستقراً Stable منذ الإصدار 1.27، كما في توثيق CronJob، فتكتب المنطقة باسمها في IANA:
apiVersion: batch/v1
kind: CronJob
metadata:
name: daily-report
spec:
schedule: "0 7 * * *"
timeZone: "Asia/Riyadh"
concurrencyPolicy: Forbid
jobTemplate:
spec:
template:
spec:
restartPolicy: OnFailure
containers:
- name: report
image: busybox:1.37
command: ["sh", "-c", "echo report generated"]ولاحظ أن التوثيق نفسه يحذر من كتابة CRON_TZ أو TZ داخل schedule، فهذا لم يكن مدعوماً رسمياً في أي وقت، والإصدارات الحديثة ترفض المورد بخطأ تحقق Validation Error. وconcurrencyPolicy: Forbid يمنع تشغيل نسخة جديدة إذا كانت السابقة ما زالت تعمل، وهو مهم لأي مهمة قد تطول.
التذكيرات والمواعيد المستقبلية
لنفرض أن عميلاً حجز اليوم موعداً بعد شهرين "الساعة 9:00 بتوقيت الرياض"، والسؤال: ماذا تحفظ؟ الحل الذي يبدو منطقياً هو أن تحول الموعد الآن إلى لحظة UTC وتحفظها، كما تفعل مع أي حدث. وهذا صحيح للأحداث التي وقعت، ولكنه خطأ للمواعيد المستقبلية، والسبب أن التحويل يستخدم قواعد اليوم، والموعد سوف يقع بعد شهرين بقواعد ذلك اليوم، وقد رأيت في مثال الدار البيضاء أن القواعد تغيرت بين الحجز والموعد، فوصل التذكير قبل موعده بساعة.

والحل الصحيح أن تحفظ للموعد المستقبلي ثلاثة أشياء: الوقت المحلي كما قاله العميل 2026-12-01 09:00، واسم المنطقة Africa/Casablanca، واللحظة المحسوبة بتوقيت UTC كقيمة مساعدة للفهرسة والبحث فقط. ثم أعد حساب اللحظة كلما حدثت tzdata، وقبل الإرسال مباشرة. أما الأحداث التي وقعت، مثل وقت الدفع ووقت تسجيل الدخول، فتحفظ لحظة UTC فقط، لأن الماضي لا تغيره القرارات الجديدة.
والمواعيد المتكررة مثل "اجتماع كل ثلاثاء الساعة 9:00" قاعدة وليست قائمة لحظات، وتحفظ بقاعدة تكرار RRULE مع منطقتها الزمنية، وهي الطريقة التي يعمل بها iCalendar، وقد شرحناها مع TZID وVTIMEZONE في دليل التقويم وجهات الاتصال CalDAV وCardDAV، فلا تحسب كل التكرارات مسبقاً بتوقيت UTC للسبب نفسه.
وعندما يحين وقت التذكير، فإرساله يمر غالباً بطابور Queue أو مهمة مؤجلة Delayed Job، وهنا قاعدتان: المهمة يجب أن تكون قابلة للتكرار دون أثر Idempotent، أي أن تنفيذها مرتين لا يرسل رسالتين، لأن أي مهمة مجدولة قد تنفذ مرتين بعد إعادة تشغيل أو في ليلة توقيت صيفي، وأبسط طريقة هي مفتاح فريد مثل reminder:42:2026-12-01 تتحقق منه قبل الإرسال. والثانية أن الموعد الذي يسجل في الطابور يكون لحظة UTC محسوبة قبل الإرسال بوقت قصير، وليس عند الحجز. وتفاصيل إرسال الرسائل من التطبيق عبر طابور مع إعادة المحاولة شرحناها في دليل البريد التفاعلي Transactional Email.
الاجتماع المتكرر بين مدينتين: توقيت من يتبع؟
لنفرض أن لفريقك اجتماعاً أسبوعياً مع مكتب لندن كل ثلاثاء الساعة 12:00 بتوقيت لندن، وأن الاجتماع يوم 27 أكتوبر ألغي. والتوقيت الصيفي في لندن ينتهي ليلة 25 أكتوبر 2026، والرياض لا تغير ساعتها، والسؤال: متى يدخل موظف الرياض الاجتماع؟ البرنامج التالي يحسب السلسلة بقاعدة RRULE مع منطقتها بالمكتبة python-dateutil، ثم يحسبها بالطريقة التي تبدو منطقية: لحظة UTC واحدة نضيف إليها 7 أيام كل مرة:
# A weekly meeting every Tuesday at 12:00 London time, seen from Riyadh, across the end of British Summer Time.
from datetime import datetime, timedelta, timezone
from importlib.metadata import version
from zoneinfo import ZoneInfo
from dateutil.rrule import rrulestr
london, riyadh = ZoneInfo("Europe/London"), ZoneInfo("Asia/Riyadh")
print("python-dateutil", version("python-dateutil"))
rule = rrulestr("""DTSTART;TZID=Europe/London:20261013T120000
RRULE:FREQ=WEEKLY;BYDAY=TU;COUNT=4
EXDATE;TZID=Europe/London:20261027T120000""", tzinfos={"Europe/London": london})
print("rule in Europe/London:")
for t in rule:
print(" ", t.strftime("%a %d %b %H:%M %Z"), "| Riyadh", t.astimezone(riyadh).strftime("%H:%M"))
first = datetime(2026, 10, 13, 12, 0, tzinfo=london).astimezone(timezone.utc)
print("UTC instants computed once, plus 7 days each:")
for i in range(4):
t = first + timedelta(weeks=i)
print(" ", t.strftime("%a %d %b %H:%M UTC"), "| London", t.astimezone(london).strftime("%H:%M %Z"))وعند تشغيله في Container من python:3.13-slim بعد pip install python-dateutil سوف يكون المخرج كما يلي:
python-dateutil 2.9.0.post0
rule in Europe/London:
Tue 13 Oct 12:00 BST | Riyadh 14:00
Tue 20 Oct 12:00 BST | Riyadh 14:00
Tue 03 Nov 12:00 GMT | Riyadh 15:00
UTC instants computed once, plus 7 days each:
Tue 13 Oct 11:00 UTC | London 12:00 BST
Tue 20 Oct 11:00 UTC | London 12:00 BST
Tue 27 Oct 11:00 UTC | London 11:00 GMT
Tue 03 Nov 11:00 UTC | London 11:00 GMT
في المخرج أعلاه لاحظ التالي:
- القاعدة مع
TZID=Europe/Londonأبقت الاجتماع 12:00 في لندن قبل التغيير وبعده، أما موظف الرياض فرآه 14:00 ثم 15:00، وهذا صحيح وليس خطأ، لأن الاجتماع "ملك" منطقة واحدة. فكل سلسلة متكررة يجب أن تكون لها منطقة مرجعية Reference Time Zone واحدة، هي غالباً منطقة من أنشأها، والباقون يرون التحويل. وإذا أراد الفريقان أن يبقى الاجتماع ثابتاً عند كليهما، فهذا مستحيل في الأسابيع التي يختلف فيها التوقيت الصيفي بينهما، وعليهم أن يتفقوا أي ساعة تتحرك. - الطريقة الثانية أبقت
11:00 UTCثابتة، فظهر الاجتماع في لندن 11:00 بعد 25 أكتوبر، أي قبل موعده بساعة، وهذا السبب نفسه الذي ذكرناه في المواعيد المستقبلية: لا تقم بحساب التكرارات مسبقاً بتوقيت UTC. - القاعدة فيها
COUNT=4وخرجت ثلاثة اجتماعات فقط، والسبب أن RFC 5545 يولد التكرارات منRRULEأولاً ثم يحذف ما فيEXDATE، فالاجتماع الملغى محسوب ضمن العدد. أما تفاصيل الاستثناءات ونقل اجتماع واحد بـRECURRENCE-IDفقد شرحناها في دليل التقويم وجهات الاتصال.
وقد يتساءل البعض: هل أحفظ القاعدة فقط وأولد التكرارات عند كل طلب، أم أحفظ كل اجتماع في صف مستقل؟ والإجابة على ذلك أن القاعدة وحدها لا تنتهي، فالسلسلة دون COUNT أو UNTIL تتكرر إلى الأبد، ولكن الاستعلام عن "اجتماعات الأسبوع القادم" أو ربط ملاحظات وحضور باجتماع معين أسهل بكثير عندما يكون لكل اجتماع صف ورقم في قاعدة البيانات. والحل العملي الذي نراه أبسط هو أن تبقى القاعدة مع منطقتها هي المصدر، وأن تولد منها صفوفاً لمدة محدودة، مثل سنة قادمة، ثم تعيد التوليد عندما تتغير القاعدة أو يتغير إصدار tzdata، والسبب أن الحساب البسيط الذي تستطيع أن تقرأه في جدول أفضل من حساب ذكي لا يفهمه أحد في ليلة التوقيت الصيفي.
التذكير بتاريخ هجري
التذكير "كل سنة في 1 رمضان" لا يمكن كتابته في cron ولا في RRULE، لأن 1 رمضان يتقدم نحو 11 يوماً كل سنة ميلادية. والحل أن تحسب التاريخ الميلادي لكل سنة بمكتبة أم القرى، وتحفظ النتيجة موعداً عادياً بالوقت المحلي والمنطقة:
# The Gregorian date of 1 Ramadan for the next five Hijri years, by the Umm al-Qura calendar.
from hijridate import Hijri
for year in range(1448, 1453):
print(f"1 Ramadan {year} = {Hijri(year, 9, 1).to_gregorian()}")والمخرج سوف يكون كما يلي:
1 Ramadan 1448 = 2027-02-08
1 Ramadan 1449 = 2028-01-28
1 Ramadan 1450 = 2029-01-16
1 Ramadan 1451 = 2030-01-05
1 Ramadan 1452 = 2030-12-26لاحظ أن سنة 2030 فيها رمضانان، في يناير وفي ديسمبر، فالكود الذي يفترض "رمضان واحد في كل سنة ميلادية" سوف يخطئ. ولأن بداية رمضان الفعلية تحدد بالرؤية، فاعتبر التاريخ المحسوب موعداً متوقعاً، واكتب في نص التذكير "بحسب تقويم أم القرى"، وإذا كان التذكير حساساً، مثل إيقاف خدمة أو تغيير ساعات العمل، فاجعله يعتمد على التاريخ المعلن رسمياً بعد الإعلان، وعدل الموعد المحفوظ إذا اختلف.
السجلات، والساعة التي تنحرف
السجلات التي لا تستطيع ترتيبها لا تفيدك وقت المشكلة، لذلك اكتب كل السجلات بتوقيت UTC وبصيغة RFC 3339 مع الفارق أو Z، مثل 2026-10-05T22:24:02+00:00 الذي يعرضه journalctl -o short-iso، وفي Docker يضيف الأمر docker logs --timestamps وقتاً بتوقيت UTC لكل سطر كما في توثيق docker logs. ولا تكتب السجلات بالتوقيت المحلي ولو كان كل الفريق في الرياض، والسبب أن أدوات تجميع السجلات والمراقبة تقارن أسطراً من مصادر مختلفة، وسطر دون فارق تفسره كل أداة بطريقتها.
ولكن الصيغة الصحيحة لا تكفي إذا كانت ساعات السيرفرات نفسها مختلفة، فإذا كانت ساعة سيرفر قاعدة البيانات متقدمة دقيقتين على سيرفر التطبيق، فسوف ترى في السجلات أن الاستعلام انتهى قبل أن يرسله التطبيق. والانحراف لا يفسد السجلات فقط:

- شهادات TLS: لكل شهادة تاريخ بداية ونهاية، فالسيرفر المتأخر يرى الشهادة الجديدة "غير صالحة بعد"، والمتقدم يرى الشهادة "منتهية"، فتفشل الاتصالات بخدمات سليمة تماماً.
- التحقق الثنائي TOTP: الرمز في تطبيق المصادقة يتغير كل 30 ثانية كما يوصي RFC 6238، والسيرفر يقبل عادة خطوة واحدة قبل أو بعد، فإذا انحرفت ساعته أكثر من ذلك رفض رموزاً صحيحة، ويظن المستخدم أن هاتفه هو المشكلة.
- التوكنات: حقول
expوiatوnbfفي JWT تقارن بساعة السيرفر، لذلك يسمح الكود بفارق صغير Clock Skew كما شرحنا في شرح OAuth وOpenID Connect، ولكن الفارق الصغير لا يغطي سيرفراً منحرفاً عشر دقائق. - Kerberos وActive Directory: الحد الافتراضي للفرق بين الساعات في MIT Kerberos خمس دقائق كما في توثيق krb5.conf، وبعده يفشل تسجيل الدخول برسالة لا تذكر الساعة غالباً.
لذلك راقب مزامنة الساعة كما تراقب المساحة والذاكرة: تنبيه إذا أصبح System clock synchronized بقيمة no، أو إذا زاد الفرق في chronyc tracking عن حد تحدده، مثل 100 ملي ثانية.
قائمة التحقق
لكل سيرفر جديد
- المنطقة الزمنية
Etc/UTCبالأمرtimedatectl set-timezone Etc/UTC، وRTC in local TZ: no. - خدمة مزامنة واحدة تعمل، chrony أو systemd-timesyncd، و
System clock synchronized: yes، وجدار الحماية يسمح بالخروج على UDP 123. - الحزمة
tzdataمحدثة مع تحديثات النظام، وال Images يعاد بناؤها دورياً. - نسخة tzdata داخل Java وNode.js معروفة ومحدثة مع إصدارات بيئة التشغيل، وليس مع النظام فقط.
- تنبيه في المراقبة إذا فقدت الساعة المزامنة.
لكل تطبيق جديد
- اللحظات محفوظة بتوقيت UTC في
timestamptzأوDATETIMEبتوقيت UTC، ومنطقة المستخدم محفوظة باسمها في IANA، واللغة في حقل منفصل. - ال API يرسل ويستقبل RFC 3339 مع
Zأو فارق صريح، والتحويل إلى التوقيت المحلي في الواجهة فقط. - المواعيد المستقبلية محفوظة بالوقت المحلي والمنطقة، والمتكررة بقاعدة RRULE مع منطقتها.
- لكل سلسلة متكررة منطقة مرجعية واحدة، والتكرارات المولدة في قاعدة البيانات لمدة محدودة يعاد توليدها عند تغير القاعدة أو tzdata.
- التقويم والأرقام محددان صراحة في كل عرض للتاريخ، والهجري بـ
islamic-umalquraعندما يكون الجمهور في السعودية، والتاريخان معاً في المستندات الرسمية. - المدة تحسب بالساعة الرتيبة، والاختبارات تستخدم ساعة مجمدة وتغطي ليلتي التوقيت الصيفي.
- الشهور تضاف من تاريخ البداية وليس من الموعد السابق، والسنة في أنماط التنسيق
yyyyوليسYYYY، والاختبارات تغطي آخر أسبوع في ديسمبر. - أيام العمل والعطلة إعداد لكل عميل أو فرع، وليست ثابتة في الكود.
- الوقت النسبي بـ
Intl.RelativeTimeFormatداخل العنصرtimeمعdatetimeوtitle، وحقلdatetime-localيرسل معه اسم المنطقة. - التقارير اليومية تحدد اليوم بتوقيت العمل، بـ
AT TIME ZONEأو ما يقابلها. - السجلات بتوقيت UTC وبصيغة RFC 3339.
لكل مهمة مجدولة
- بأي منطقة كتب الجدول؟ اكتبها في تعليق فوق السطر، أو في
OnCalendarأوtimeZoneصراحة. - إذا كانت المنطقة فيها توقيت صيفي، فلا تضع المهمة بين 01:00 و03:00 بالتوقيت المحلي، أو اكتبها بتوقيت UTC.
- المهمة قابلة للتكرار دون أثر، ولا تعمل نسختان منها معاً.
Persistent=trueفي systemd إذا كان يجب أن تعمل بعد انقطاع السيرفر.- تنبيه إذا لم تعمل المهمة في موعدها، وليس فقط إذا فشلت.
الخلاصة
وصلنا لنهاية الموضوع، وأهم ما فيه:
- اللحظة شيء والوقت المحلي شيء آخر، والمنطقة الزمنية تاريخ من القواعد تغيره الحكومات أحياناً بإشعار أيام، فساموا حذفت يوماً كاملاً، وكوريا الشمالية غيرت فارقها مرتين، والفروق ليست ساعات كاملة دائماً، لذلك احفظ اسم المنطقة من IANA وليس الفارق، وحدث tzdata في النظام وال Images والمكتبات، وفي Java وNode.js اللتين تحملان نسختهما الخاصة.
- السيرفر على UTC، وساعته متزامنة بـ chrony أو systemd-timesyncd، وال Containers تشترك في ساعة النواة ولكل منها منطقة، وAlpine دون
tzdataيرجع إلى UTC بصمت. - داخل النظام لحظات بتوقيت UTC وصيغة RFC 3339، والتحويل عند الأطراف فقط، و
timestamptzفي PostgreSQL، وانتبه لحد 2038 فيTIMESTAMPفي MySQL. - الشهر والأسبوع ليسا عدداً ثابتاً من الأيام، فاحسب الشهور من تاريخ البداية، واكتب
yyyyوليسYYYY، ولا تثبت أيام العطلة في الكود، واعرض الوقت النسبي بـIntl.RelativeTimeFormatمع الوقت الكامل في العنصرtime. - تقويم أم القرى هو التقويم الرسمي في السعودية، والتقويم الحسابي يختلف عنه يوماً أو يومين، والرؤية تحدد المناسبات، فاختر التقويم صراحة واعرض الهجري ولا تخزنه، ولا تحسب المواعيد النظامية بنفسك.
- المهام المجدولة تحتاج منطقة صريحة، وليلة التوقيت الصيفي تخفي ساعة وتكرر أخرى، وsystemd timers وKubernetes يقبلان المنطقة في الجدول، والمواعيد المستقبلية تحفظ بوقتها المحلي ومنطقتها وتحول إلى UTC قبل التنفيذ، والاجتماع المتكرر له منطقة مرجعية واحدة يتحرك بالنسبة لها عند الآخرين.
- الساعة المنحرفة تعطل TLS والتحقق الثنائي والتوكنات وKerberos قبل أن تعطل السجلات، فراقب مزامنتها.
دليلالتقويم وجهات الاتصال على سيرفرك مع CalDAV وCardDAVكيف تبقى مواعيدك وجهات اتصالك متطابقة بين الهاتف والحاسوب على سيرفرك، وما الذي يعمل مع Outlook وما الذي لا يعمل.
دليلتأمين سيرفر VPS على Ubuntu من أول دخولقبل أن تثبت أي تطبيق على سيرفرك الجديد، هذه الخطوات تغلق أبرز الثغرات وأولها الدخول بمفتاح SSH بدل كلمة المرور.
دليلقبل أن تنشر تطبيقك على خادمك: قائمة تحقق للمطورالأخطاء التي لا تظهر إلا بعد نشر تطبيقك في Docker خلف Reverse Proxy، وكيف تعالج كل واحد منها قبل أن يصل إلى المستخدمين.
دليلTransactional Email: كيف تصل رسائل تطبيقك في وقتهالماذا تفصل رسائل تطبيقك مثل استعادة كلمة المرور عن النشرات البريدية، وكيف ترسلها بحيث تصل في وقتها ولا تذهب إلى Spam.
دليلتثبيت Docker Engine وCompose على Ubuntuكيف تثبت Docker على Ubuntu من المستودع الرسمي، وتضبطه من البداية حتى لا يمتلئ القرص أو تنكشف منافذك للإنترنت.سجل التحديثات
- أكتوبر 2026: كتابة المقال، واختبار الأمثلة على Python 3.13 وNode.js 22 و18 وJava 21 وPostgreSQL 17 وMySQL 8.4 وDebian 13 مع systemd 257 وchrony 4.6 وAlpine 3.22، مع tzdata 2026c.
- أكتوبر 2026: إضافة قسم عن سبب وجود المناطق الزمنية وتوقيت السكك الحديدية مع خريطة 1883 وصورة ساعة غرينتش من Wikimedia Commons، والفروق التي ليست ساعات كاملة، وأمثلة ساموا وكوريا الشمالية وإيران بمخرجات zdump، وتوزيع الثانية الكبيسة Leap Smear، وغموض الساعة 12 ص و12 م، ونسخة tzdata داخل Java، والحساب بالشهور والأسابيع، وعرض الوقت النسبي وحقول الإدخال، والاجتماع المتكرر بين مدينتين، مع مخططين جديدين. وقد استفدنا في اختيار هذه النقاط من محاضرة زاك هولمان Zach Holman، وتحققنا من كل حقيقة من مصدرها الأصلي.