{"id":"l-js-5-4","courseId":"javascript-fundamentals","lang":"ar","kind":"lesson","summary":"تجمع Pocket في تطبيق متصفّح مكتمل له كائن state واحد، ومنطق نقي، ودالة render واحدة، وأحداث مفوَّضة، وتصفية، وبيانات محفوظة، وتفحصه وفق تعريف واضح لـ “مكتمل”.","html":"<p>لديك كل القطع: دوال مساعدة للمال، والتحقّق من المدخلات، والعرض، والأحداث، والنموذج، والتخزين، والوحدات، والأصناف. يجمعها هذا الدرس في Pocket المكتمل، والأهم أنه يُريك الشكل الذي يُمسك تطبيقًا صغيرًا متماسكًا فيبقى سهل التغيير.</p>\n<h2 id=\"sec-1\" tabindex=\"-1\">الشكل: state واحد، وطريقة واحدة لتغييره</h2>\n<p>لكل تطبيق تفاعلي بيانات تتغيّر. في Pocket هي قائمة المصاريف وفلتر الفئة الذي اختاره المستخدم. ضعها كلها في كائن واحد، هو <strong>الـ state</strong> (الحالة):</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"js\" data-index=\"0\"><div class=\"code-head\"><span class=\"code-title\">src/main.js</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"نسخ الكود\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>نسخ</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">const</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> state</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> =</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> {</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  expenses: </span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">loadExpenses</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(),</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  filter: </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"all\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">,</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">};</span></span></code></pre></div>\n<p>ثم اجعل دالة واحدة هي الطريقة الوحيدة لتغييره. إنها تطبّق التغيير، وتحفظ، وتعيد العرض:</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"js\" data-index=\"1\"><div class=\"code-head\"><span class=\"code-title\">src/main.js</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"نسخ الكود\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>نسخ</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">function</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\"> update</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#953800;--shiki-dark:#FFA657\">changes</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">) {</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  Object.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">assign</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(state, changes);</span></span>\n<span class=\"line\"><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">  saveExpenses</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(state.expenses);</span></span>\n<span class=\"line\"><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">  render</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">();</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">}</span></span></code></pre></div>\n<p><code dir=\"ltr\">Object.assign(state, changes)</code> تنسخ خصائص <code dir=\"ltr\">changes</code> إلى <code dir=\"ltr\">state</code>، لذلك لا تغيّر <code dir=\"ltr\">update({ filter: &quot;food&quot; })</code> إلا الفلتر. وصار كل معالج حدث ينتهي بالطريقة نفسها: احسب القيم الجديدة، ثم استدعِ <code dir=\"ltr\">update</code>. ولن تحتاج أبدًا إلى تذكّر الحفظ أو إعادة الرسم، لأن نسيانهما صار مستحيلًا.</p>\n<figure class=\"diagram\"><svg class=\"diagram-svg\" focusable=\"false\" viewBox=\"0 0 680 220\" role=\"img\" aria-labelledby=\"l-js-5-4-t1\"><defs><marker id=\"arrow-l-js-5-4-1\" viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\" markerWidth=\"7\" markerHeight=\"7\" orient=\"auto-start-reverse\"><path d=\"M0 0 L10 5 L0 10 z\" class=\"d-arrowhead\"/></marker></defs>\n  <title id=\"l-js-5-4-t1\">حدث من المستخدم يذهب إلى معالج حدث، يحسب القيم الجديدة بدوال نقية ويستدعي update. تغيّر update الـ state، وتحفظه في التخزين، وتستدعي render، التي ترسم الصفحة التي يراها المستخدم، جاهزةً للحدث التالي.</title>\n  <rect class=\"d-box-accent\" x=\"20\" y=\"20\" width=\"140\" height=\"56\" rx=\"10\"/>\n  <text class=\"d-label\" x=\"90\" y=\"53\" text-anchor=\"middle\">حدث المستخدم</text>\n  <rect class=\"d-box\" x=\"200\" y=\"20\" width=\"160\" height=\"56\" rx=\"10\"/>\n  <text class=\"d-label\" x=\"280\" y=\"44\" text-anchor=\"middle\">معالج الحدث</text>\n  <text class=\"d-label-muted\" x=\"280\" y=\"64\" text-anchor=\"middle\">يستخدم دوال نقية</text>\n  <rect class=\"d-box-primary\" x=\"400\" y=\"20\" width=\"140\" height=\"56\" rx=\"10\"/>\n  <text class=\"d-code\" x=\"470\" y=\"53\" text-anchor=\"middle\">update()</text>\n  <rect class=\"d-box-success\" x=\"400\" y=\"140\" width=\"140\" height=\"56\" rx=\"10\"/>\n  <text class=\"d-code\" x=\"470\" y=\"173\" text-anchor=\"middle\">render()</text>\n  <rect class=\"d-box\" x=\"580\" y=\"20\" width=\"90\" height=\"56\" rx=\"10\"/>\n  <text class=\"d-label\" x=\"625\" y=\"53\" text-anchor=\"middle\">حفظ</text>\n  <rect class=\"d-box\" x=\"200\" y=\"140\" width=\"160\" height=\"56\" rx=\"10\"/>\n  <text class=\"d-label\" x=\"280\" y=\"173\" text-anchor=\"middle\">الصفحة</text>\n  <path class=\"d-arrow\" d=\"M160 48 L196 48\" marker-end=\"url(#arrow-l-js-5-4-1)\"/>\n  <path class=\"d-arrow\" d=\"M360 48 L396 48\" marker-end=\"url(#arrow-l-js-5-4-1)\"/>\n  <path class=\"d-arrow\" d=\"M540 48 L576 48\" marker-end=\"url(#arrow-l-js-5-4-1)\"/>\n  <path class=\"d-arrow\" d=\"M470 76 L470 136\" marker-end=\"url(#arrow-l-js-5-4-1)\"/>\n  <path class=\"d-arrow\" d=\"M400 168 L364 168\" marker-end=\"url(#arrow-l-js-5-4-1)\"/>\n  <path class=\"d-arrow\" d=\"M200 168 Q90 168 90 80\" marker-end=\"url(#arrow-l-js-5-4-1)\"/>\n</svg><figcaption>الحلقة التي يمرّ بها كل حدث</figcaption></figure>\n<h2 id=\"sec-2\" tabindex=\"-1\">دالة render تشتقّ كل شيء</h2>\n<p>تقرأ <code dir=\"ltr\">render</code> الـ state وترسم الواجهة كلها. وكل ما يمكن حسابه من الـ state يُحسب هنا، لا يُخزَّن: القائمة المرئية تعتمد على الفلتر، والمجموع يعتمد على القائمة المرئية.</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"js\" data-index=\"2\"><div class=\"code-head\"><span class=\"code-title\">src/ui.js</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"نسخ الكود\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>نسخ</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">function</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\"> render</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">() {</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  const</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> visible</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> =</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">    state.filter </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">===</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> \"all\"</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">      ?</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> state.expenses</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">      :</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> state.expenses.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">filter</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">((</span><span style=\"color:#953800;--shiki-dark:#FFA657\">e</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">) </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=></span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> e.category </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">===</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> state.filter);</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  list.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">replaceChildren</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">();</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  if</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> (visible.</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">length</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> ===</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> 0</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">) {</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">    const</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> empty</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> =</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> document.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">createElement</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"li\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">);</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">    empty.className </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> \"empty\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">    empty.textContent </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> \"No expenses yet. Add your first one above.\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">    list.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">append</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(empty);</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  }</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  for</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> (</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">const</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> expense</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> of</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> visible) {</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">    list.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">append</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">renderItem</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(expense));</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  }</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  totalEl.textContent </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> `Total: ${</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">formatMoney</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">(</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">totalCents</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">(</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">visible</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">))</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">}`</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">}</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">function</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\"> renderItem</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#953800;--shiki-dark:#FFA657\">expense</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">) {</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  const</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> li</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> =</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> document.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">createElement</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"li\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">);</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  li.dataset.id </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> expense.id;</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  const</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> text</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> =</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> document.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">createElement</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"span\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">);</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  text.textContent </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> `${</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">expense</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">.</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">label</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">}: ${</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">formatMoney</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">(</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">expense</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">.</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">amount</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">)</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">} (${</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">expense</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">.</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">category</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">})`</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  const</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> button</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> =</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> document.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">createElement</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"button\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">);</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  button.type </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> \"button\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  button.className </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> \"delete\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  button.textContent </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> \"Delete\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  button.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">setAttribute</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"aria-label\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">`Delete ${</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">expense</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">.</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">label</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">}`</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">);</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  li.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">append</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(text, button);</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  return</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> li;</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">}</span></span></code></pre></div>\n<p>لأن <code dir=\"ltr\">render</code> تبدأ دائمًا من الـ state، لا يمكن للفلتر والقائمة والمجموع أن تتناقض أبدًا. وتبديل الفلتر إلى “food” لا يُخفي بعض عناصر <code dir=\"ltr\">&lt;li&gt;</code> ويعيد حساب رقم؛ بل يرسم ببساطة صورة مختلفة للـ state نفسه.</p>\n<p>تفصيلتان تستحقّان النسخ. <strong>الحالة الفارغة</strong> تحوّل القائمة الخالية إلى إرشاد. والـ <code dir=\"ltr\">aria-label</code> على زر الحذف تخبر مستخدمي قارئات الشاشة <em>أيّ</em> مصروف يحذفه الزر، فعشرة أزرار كلها باسم “Delete” لا فائدة منها لمن لا يرى الصف.</p>\n<h2 id=\"sec-3\" tabindex=\"-1\">معالجات الأحداث تترجم الأحداث إلى تحديثات</h2>\n<p>حين يكون الـ state و<code dir=\"ltr\">update</code> و<code dir=\"ltr\">render</code> في مكانها، تصبح كل ميزة بضعة أسطر:</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"js\" data-index=\"3\"><div class=\"code-head\"><span class=\"code-title\">src/main.js</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"نسخ الكود\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>نسخ</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">form.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">addEventListener</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"submit\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, (</span><span style=\"color:#953800;--shiki-dark:#FFA657\">event</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">) </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=></span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> {</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  event.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">preventDefault</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">();</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  const</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> result</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> =</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\"> readExpenseForm</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(form);          </span><span style=\"color:#6E7781;--shiki-dark:#8B949E\">// { values } or { errors }</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  if</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> (result.errors) {</span></span>\n<span class=\"line\"><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">    showErrors</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(form, result.errors);</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">    return</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  }</span></span>\n<span class=\"line\"><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">  update</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">({ expenses: </span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">addExpense</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(state.expenses, { id: </span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">nextId</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(), </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">...</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">result.values }) });</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  form.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">reset</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">();</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  form.elements.label.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">focus</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">();</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">});</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">list.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">addEventListener</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"click\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, (</span><span style=\"color:#953800;--shiki-dark:#FFA657\">event</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">) </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=></span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> {</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  const</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> button</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> =</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> event.target.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">closest</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\".delete\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">);</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  if</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> (</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">!</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">button) </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">return</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  const</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> id</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> =</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> button.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">closest</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"li\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">).dataset.id;</span></span>\n<span class=\"line\"><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">  update</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">({ expenses: state.expenses.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">filter</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">((</span><span style=\"color:#953800;--shiki-dark:#FFA657\">e</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">) </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=></span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> e.id </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">!==</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> id) });</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">});</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">filterSelect.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">addEventListener</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"change\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, () </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=></span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> {</span></span>\n<span class=\"line\"><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">  update</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">({ filter: filterSelect.value });</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">});</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">render</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">();</span></span></code></pre></div>\n<p><code dir=\"ltr\">readExpenseForm</code> و<code dir=\"ltr\">showErrors</code> هما كود النموذج من القسم 4، وقد نُقل إلى دالتين صغيرتين لهما اسمان: الأولى تقرأ وتشذّب وتحوّل وتتحقّق، وتُرجع إمّا قيمًا نظيفة أو كائن أخطاء؛ والثانية تكتب الرسائل وتضبط <code dir=\"ltr\">aria-invalid</code>. ومنح كل منهما اسمًا يحوّل معالج الإرسال إلى ملخّص تستطيع قراءته في خمس ثوانٍ.</p>\n<p>لاحظ ما <em>لا</em> تحتويه معالجات الأحداث: لا بناء للـ DOM، ولا حفظ، ولا مجاميع. إنها تقرأ الحدث، وتستدعي دوال نقية مثل <code dir=\"ltr\">addExpense</code> من القسم 3، وتسلّم النتيجة إلى <code dir=\"ltr\">update</code>. القواعد تعيش في <code dir=\"ltr\">expenses.js</code>، حيث تستطيع اختبارها دون متصفّح؛ أما معالجات الأحداث فهي الغراء الذي يربط الأجزاء.</p>\n<aside class=\"callout callout-mistake\" role=\"note\"><p class=\"callout-title\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><path d=\"m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3\"/><path d=\"M12 9v4\"/><path d=\"M12 17h.01\"/></svg><span><span class=\"callout-kind\">خطأ شائع</span><span class=\"callout-sep\" aria-hidden=\"true\"> · </span>معرّفات تتكرّر بعد إعادة التحميل</span></p>\n<p>العدّاد الذي يبدأ من 1 في كل مرة تُحمَّل فيها الصفحة سيعطي <code dir=\"ltr\">exp-1</code> من جديد بينما ما زال <code dir=\"ltr\">exp-1</code> قديم في التخزين، وعندها يحذف حذفُ أحدهما الاثنين معًا. ابدأ العدّاد بعد أعلى معرّف محفوظ، أو استخدم <code dir=\"ltr\">crypto.randomUUID()</code>، التي توفّرها كل المتصفّحات الحالية في الصفحات الآمنة (https أو localhost)، وتُرجع معرّفًا فريدًا مثل <code dir=\"ltr\">&quot;3b241101-e2bb-4255-8caf-4136c566a962&quot;</code>.</p>\n</aside>\n<h2 id=\"sec-4\" tabindex=\"-1\">تعريف “مكتمل”</h2>\n<p>“إضافة مصروف تعمل” هي نقطة بداية الاختبار، لا نهايته. قبل أن تقول إن Pocket اكتمل، مرّ على هذه القائمة يدويًا:</p>\n<ul>\n<li><strong>الفراغ</strong>: دون أي بيانات، تشرح الصفحة ما يجب فعله.</li>\n<li><strong>المدخلات السيئة</strong>: التسمية الفارغة، والمبلغ <code dir=\"ltr\">abc</code> أو <code dir=\"ltr\">0</code> أو <code dir=\"ltr\">-5</code>، كل منها يعرض رسالة واضحة ولا يضيف شيئًا.</li>\n<li><strong>إعادة التحميل</strong>: كل ما أضفته ما زال موجودًا، والفلتر يبدأ على “All”.</li>\n<li><strong>لوحة المفاتيح وحدها</strong>: تستطيع الانتقال بـ Tab إلى كل حقل وزر، والإرسال بـ Enter، والحذف بـ Enter أو Space.</li>\n<li><strong>البيانات الغريبة</strong>: تسمية مثل <code dir=\"ltr\">&lt;b&gt;hi&lt;/b&gt;</code> تظهر نصًا، والتسمية الطويلة جدًا لا تكسر التخطيط.</li>\n<li><strong>الـ Console</strong>: لا أخطاء ولا تحذيرات أثناء قيامك بكل ما سبق.</li>\n</ul>\n<p>كل بند هنا يأتي من درس في هذه الدورة، وكل منها يلتقط فئة حقيقية من الأخطاء كان المستخدمون سيكتشفونها نيابةً عنك.</p>\n<aside class=\"callout callout-tip\" role=\"note\"><p class=\"callout-title\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5\"/><path d=\"M9 18h6\"/><path d=\"M10 22h4\"/></svg><span><span class=\"callout-kind\">نصيحة</span><span class=\"callout-sep\" aria-hidden=\"true\"> · </span>احفظ نسخة عند كل خطوة ناجحة</span></p>\n<p>ابنِ الميزات واحدة تلو الأخرى، واحفظ نسخة تعمل بعد كل منها: الإضافة، ثم الحذف، ثم التصفية، ثم التخزين. وإن انكسر شيء، فليس عليك إلا التراجع عن خطوة واحدة. وتُريك <a href=\"/ar/courses/git-github\">دورة Git وGitHub</a> كيف تفعل ذلك كما ينبغي باستخدام الـ commits.</p>\n</aside>\n<p>التمرين أدناه يسلّمك Pocket شبه مكتمل، وما زال عليك ربط التصفية والحذف. وبعد ذلك، درس قصير أخير حول وجهتك التالية.</p>\n","toc":[{"id":"sec-1","text":"الشكل: state واحد، وطريقة واحدة لتغييره","level":2},{"id":"sec-2","text":"دالة render تشتقّ كل شيء","level":2},{"id":"sec-3","text":"معالجات الأحداث تترجم الأحداث إلى تحديثات","level":2},{"id":"sec-4","text":"تعريف “مكتمل”","level":2}],"takeaways":["احتفظ بكل البيانات المتغيّرة في مكان واحد، وغيّرها عبر دالة واحدة تحفظ وتعيد العرض، فلا تختلف الصفحة والبيانات والتخزين أبدًا.","اشتقّ كل ما يمكنك اشتقاقه، مثل القائمة المرئية والمجاميع، من الـ state وقت العرض بدلًا من تخزينه منفصلًا.","الدوال النقية تحمل القواعد؛ ومعالجات الأحداث تبقى قصيرة ولا تفعل إلا ترجمة الأحداث إلى تغييرات في الـ state.","يكتمل التطبيق حين يتعامل مع الحالة الفارغة، والمدخلات السيئة، وإعادة التحميل، والاستخدام بلوحة المفاتيح وحدها، لا مع المسار السعيد فقط."],"further":[{"title":"Document.createElement() (MDN)","url":"https://developer.mozilla.org/en-US/docs/Web/API/Document/createElement"},{"title":"HTMLElement: change event (MDN)","url":"https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/change_event"}],"quiz":[{"q":"<p>يعرض Pocket المصاريف مصفّاةً على “food” مع مجموع في الأسفل. من أين يجب أن تأتي القائمة المصفّاة؟</p>","options":[{"text":"من مصفوفة ثانية، <code dir=\"ltr\">foodExpenses</code>، تُحدَّث كلما أُضيف مصروف أو حُذف.","why":"المصفوفتان اللتان يجب إبقاؤهما متزامنتين ستختلفان في النهاية، مثلًا بعد حذف لا يحدّث إلا إحداهما."},{"text":"من قراءة عناصر <code dir=\"ltr\">&lt;li&gt;</code> الموجودة حاليًا على الصفحة.","why":"الصفحة صورة للبيانات. وقراءة البيانات منها تربط منطقك بالوسوم وتنكسر بسهولة."},{"text":"تُحسب داخل <code dir=\"ltr\">render</code> من <code dir=\"ltr\">state.expenses</code> و<code dir=\"ltr\">state.filter</code> في كل مرة.","why":"صحيح. البيانات المشتقّة التي يُعاد حسابها من مصدر الحقيقة الوحيد لا يمكن أن تصبح قديمة أبدًا."}],"answer":2},{"q":"<p>كل معالج حدث في Pocket ينتهي بالخطوات الثلاث نفسها. أيّ ترتيب صحيح؟</p>","options":[{"text":"حدّث الـ state، ثم احفظه، ثم اعرض منه.","why":"صحيح. يتغيّر الـ state أولًا؛ ثم يقرأ الحفظ والعرض كلاهما من الـ state الجديد، فيعكسان البيانات نفسها."},{"text":"اعرض، ثم حدّث الـ state، ثم احفظ.","why":"العرض قبل تغيّر الـ state يرسم البيانات القديمة، فتكون الشاشة دائمًا متأخّرة خطوة."},{"text":"احفظ، ثم حدّث الـ state، ثم اعرض.","why":"الحفظ قبل التحديث يخزّن البيانات القديمة، فيضيع آخر تغيير عند إعادة التحميل."}],"answer":0},{"q":"<p>أيّ مما يلي جزء من تعريف معقول لـ “مكتمل” في Pocket، يتجاوز “إضافة مصروف تعمل”؟</p>","options":[{"text":"كل دالة مكتوبة على شكل method في صنف.","why":"الأصناف أداة لا غاية. والدوال العادية غالبًا هي الخيار الأفضل للمنطق النقي."},{"text":"في التطبيق حركة (animation) واحدة على الأقل.","why":"اللمسات الجمالية لطيفة، لكنها لا تقول شيئًا عن صلاحية التطبيق للاستخدام الحقيقي."},{"text":"الكود لا يستخدم أي تعليمة <code dir=\"ltr\">if</code>.","why":"الشروط ضرورية في البرامج الحقيقية. وتجنّبها ليس مقياسًا للجودة."},{"text":"تعرض القائمة رسالة مفيدة حين لا توجد مصاريف، وتنجو البيانات من إعادة التحميل.","why":"صحيح. الحالات الفارغة واستمرار البيانات جزء مما يختبره المستخدمون، لذلك مكانهما ضمن “مكتمل”."}],"answer":3}],"exercise":{"kind":"web","mode":"dom","starter":{"html":"<h1>Pocket</h1>\n<form id=\"expense-form\" novalidate>\n  <label for=\"label\">What</label>\n  <input id=\"label\" name=\"label\">\n  <label for=\"amount\">Amount ($)</label>\n  <input id=\"amount\" name=\"amount\" type=\"number\" step=\"0.01\">\n  <label for=\"category\">Category</label>\n  <select id=\"category\" name=\"category\">\n    <option value=\"food\">Food</option>\n    <option value=\"travel\">Travel</option>\n    <option value=\"fun\">Fun</option>\n  </select>\n  <p id=\"form-error\" role=\"alert\"></p>\n  <button type=\"submit\">Add expense</button>\n</form>\n\n<label for=\"filter\">Show</label>\n<select id=\"filter\">\n  <option value=\"all\">All</option>\n  <option value=\"food\">Food</option>\n  <option value=\"travel\">Travel</option>\n  <option value=\"fun\">Fun</option>\n</select>\n\n<ul id=\"expense-list\"></ul>\n<p id=\"total\"></p>\n","css":"form { display: grid; gap: 4px; max-width: 280px; margin-bottom: 16px; }\n#form-error { color: #b42318; min-height: 1.5em; margin: 0; }\nli { display: flex; gap: 8px; align-items: center; margin: 4px 0; }\n.empty { color: #6b6880; }\n","js":"// Storage. The playground starts every run with empty storage, so seed three\n// saved expenses (on a real page they would be there from earlier visits).\nconst STORAGE_KEY = \"pocket.expenses.v1\";\nif (localStorage.getItem(STORAGE_KEY) === null) {\n  localStorage.setItem(STORAGE_KEY, JSON.stringify([\n    { id: \"exp-1\", label: \"Coffee\", amount: 450, category: \"food\" },\n    { id: \"exp-2\", label: \"Train\", amount: 1220, category: \"travel\" },\n    { id: \"exp-3\", label: \"Lunch\", amount: 1350, category: \"food\" },\n  ]));\n}\n\nfunction loadExpenses() {\n  try {\n    const data = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? \"[]\");\n    return Array.isArray(data) ? data : [];\n  } catch {\n    return [];\n  }\n}\nfunction saveExpenses(expenses) {\n  localStorage.setItem(STORAGE_KEY, JSON.stringify(expenses));\n}\n\n// Pure helpers\nconst formatMoney = (cents) => `$${(cents / 100).toFixed(2)}`;\nconst totalCents = (expenses) => expenses.reduce((sum, e) => sum + e.amount, 0);\nfunction validateExpense({ label, amount }) {\n  const errors = {};\n  if (label === \"\") errors.label = \"Enter what you spent money on.\";\n  if (!Number.isFinite(amount) || amount <= 0) errors.amount = \"Enter an amount greater than zero.\";\n  return errors;\n}\n\n// State\nconst state = { expenses: loadExpenses(), filter: \"all\" };\nlet idCounter = state.expenses.reduce((max, e) => Math.max(max, Number(e.id.split(\"-\")[1])), 0);\nconst nextId = () => `exp-${++idCounter}`;\n\nfunction update(changes) {\n  Object.assign(state, changes);\n  saveExpenses(state.expenses);\n  render();\n}\n\n// Rendering\nconst form = document.querySelector(\"#expense-form\");\nconst errorEl = document.querySelector(\"#form-error\");\nconst list = document.querySelector(\"#expense-list\");\nconst totalEl = document.querySelector(\"#total\");\nconst filterSelect = document.querySelector(\"#filter\");\n\n// 1. Return the expenses that match state.filter (\"all\" shows everything)\nfunction visibleExpenses() {\n  return state.expenses;\n}\n\nfunction render() {\n  const visible = visibleExpenses();\n  list.replaceChildren();\n  if (visible.length === 0) {\n    const empty = document.createElement(\"li\");\n    empty.className = \"empty\";\n    empty.textContent = \"No expenses yet. Add your first one above.\";\n    list.append(empty);\n  }\n  for (const expense of visible) {\n    const li = document.createElement(\"li\");\n    li.dataset.id = expense.id;\n    const text = document.createElement(\"span\");\n    text.textContent = `${expense.label}: ${formatMoney(expense.amount)} (${expense.category})`;\n    const button = document.createElement(\"button\");\n    button.type = \"button\";\n    button.className = \"delete\";\n    button.textContent = \"Delete\";\n    button.setAttribute(\"aria-label\", `Delete ${expense.label}`);\n    li.append(text, button);\n    list.append(li);\n  }\n  totalEl.textContent = `Total: ${formatMoney(totalCents(visible))}`;\n}\n\n// Events\nform.addEventListener(\"submit\", (event) => {\n  event.preventDefault();\n  const data = new FormData(form);\n  const values = {\n    label: data.get(\"label\").trim(),\n    amount: Math.round(Number(data.get(\"amount\")) * 100),\n    category: data.get(\"category\"),\n  };\n  const errors = validateExpense(values);\n  if (Object.keys(errors).length > 0) {\n    errorEl.textContent = Object.values(errors).join(\" \");\n    return;\n  }\n  errorEl.textContent = \"\";\n  update({ expenses: [...state.expenses, { id: nextId(), ...values }] });\n  form.reset();\n});\n\n// 2. When #filter changes, update the filter\n// 3. One delegated click listener on #expense-list that deletes an expense\n\nrender();\n"},"solution":{"html":"<h1>Pocket</h1>\n<form id=\"expense-form\" novalidate>\n  <label for=\"label\">What</label>\n  <input id=\"label\" name=\"label\">\n  <label for=\"amount\">Amount ($)</label>\n  <input id=\"amount\" name=\"amount\" type=\"number\" step=\"0.01\">\n  <label for=\"category\">Category</label>\n  <select id=\"category\" name=\"category\">\n    <option value=\"food\">Food</option>\n    <option value=\"travel\">Travel</option>\n    <option value=\"fun\">Fun</option>\n  </select>\n  <p id=\"form-error\" role=\"alert\"></p>\n  <button type=\"submit\">Add expense</button>\n</form>\n\n<label for=\"filter\">Show</label>\n<select id=\"filter\">\n  <option value=\"all\">All</option>\n  <option value=\"food\">Food</option>\n  <option value=\"travel\">Travel</option>\n  <option value=\"fun\">Fun</option>\n</select>\n\n<ul id=\"expense-list\"></ul>\n<p id=\"total\"></p>\n","css":"form { display: grid; gap: 4px; max-width: 280px; margin-bottom: 16px; }\n#form-error { color: #b42318; min-height: 1.5em; margin: 0; }\nli { display: flex; gap: 8px; align-items: center; margin: 4px 0; }\n.empty { color: #6b6880; }\n","js":"// Storage. The playground starts every run with empty storage, so seed three\n// saved expenses (on a real page they would be there from earlier visits).\nconst STORAGE_KEY = \"pocket.expenses.v1\";\nif (localStorage.getItem(STORAGE_KEY) === null) {\n  localStorage.setItem(STORAGE_KEY, JSON.stringify([\n    { id: \"exp-1\", label: \"Coffee\", amount: 450, category: \"food\" },\n    { id: \"exp-2\", label: \"Train\", amount: 1220, category: \"travel\" },\n    { id: \"exp-3\", label: \"Lunch\", amount: 1350, category: \"food\" },\n  ]));\n}\n\nfunction loadExpenses() {\n  try {\n    const data = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? \"[]\");\n    return Array.isArray(data) ? data : [];\n  } catch {\n    return [];\n  }\n}\nfunction saveExpenses(expenses) {\n  localStorage.setItem(STORAGE_KEY, JSON.stringify(expenses));\n}\n\n// Pure helpers\nconst formatMoney = (cents) => `$${(cents / 100).toFixed(2)}`;\nconst totalCents = (expenses) => expenses.reduce((sum, e) => sum + e.amount, 0);\nfunction validateExpense({ label, amount }) {\n  const errors = {};\n  if (label === \"\") errors.label = \"Enter what you spent money on.\";\n  if (!Number.isFinite(amount) || amount <= 0) errors.amount = \"Enter an amount greater than zero.\";\n  return errors;\n}\n\n// State\nconst state = { expenses: loadExpenses(), filter: \"all\" };\nlet idCounter = state.expenses.reduce((max, e) => Math.max(max, Number(e.id.split(\"-\")[1])), 0);\nconst nextId = () => `exp-${++idCounter}`;\n\nfunction update(changes) {\n  Object.assign(state, changes);\n  saveExpenses(state.expenses);\n  render();\n}\n\n// Rendering\nconst form = document.querySelector(\"#expense-form\");\nconst errorEl = document.querySelector(\"#form-error\");\nconst list = document.querySelector(\"#expense-list\");\nconst totalEl = document.querySelector(\"#total\");\nconst filterSelect = document.querySelector(\"#filter\");\n\nfunction visibleExpenses() {\n  if (state.filter === \"all\") {\n    return state.expenses;\n  }\n  return state.expenses.filter((e) => e.category === state.filter);\n}\n\nfunction render() {\n  const visible = visibleExpenses();\n  list.replaceChildren();\n  if (visible.length === 0) {\n    const empty = document.createElement(\"li\");\n    empty.className = \"empty\";\n    empty.textContent = \"No expenses yet. Add your first one above.\";\n    list.append(empty);\n  }\n  for (const expense of visible) {\n    const li = document.createElement(\"li\");\n    li.dataset.id = expense.id;\n    const text = document.createElement(\"span\");\n    text.textContent = `${expense.label}: ${formatMoney(expense.amount)} (${expense.category})`;\n    const button = document.createElement(\"button\");\n    button.type = \"button\";\n    button.className = \"delete\";\n    button.textContent = \"Delete\";\n    button.setAttribute(\"aria-label\", `Delete ${expense.label}`);\n    li.append(text, button);\n    list.append(li);\n  }\n  totalEl.textContent = `Total: ${formatMoney(totalCents(visible))}`;\n}\n\n// Events\nform.addEventListener(\"submit\", (event) => {\n  event.preventDefault();\n  const data = new FormData(form);\n  const values = {\n    label: data.get(\"label\").trim(),\n    amount: Math.round(Number(data.get(\"amount\")) * 100),\n    category: data.get(\"category\"),\n  };\n  const errors = validateExpense(values);\n  if (Object.keys(errors).length > 0) {\n    errorEl.textContent = Object.values(errors).join(\" \");\n    return;\n  }\n  errorEl.textContent = \"\";\n  update({ expenses: [...state.expenses, { id: nextId(), ...values }] });\n  form.reset();\n});\n\nfilterSelect.addEventListener(\"change\", () => {\n  update({ filter: filterSelect.value });\n});\n\nlist.addEventListener(\"click\", (event) => {\n  const button = event.target.closest(\".delete\");\n  if (!button) return;\n  const id = button.closest(\"li\").dataset.id;\n  update({ expenses: state.expenses.filter((e) => e.id !== id) });\n});\n\nrender();\n"},"checks":[{"id":"initial","test":"return $$('#expense-list li[data-id]').length === 3 && text('#total') === 'Total: $30.20';\n"},{"id":"filter","test":"await input($('#filter'), 'food');\nconst ids = $$('#expense-list li[data-id]').map((li) => li.dataset.id).join();\nreturn ids === 'exp-1,exp-3' && text('#total') === 'Total: $18.00';\n"},{"id":"delete","test":"await click($('li[data-id=\"exp-3\"] .delete'));\nconst saved = JSON.parse(localStorage.getItem('pocket.expenses.v1'));\nreturn $$('#expense-list li[data-id]').length === 1 && !$('li[data-id=\"exp-3\"]') && saved.length === 2 && !saved.some((e) => e.id === 'exp-3');\n"},{"id":"empty-state","test":"await input($('#filter'), 'fun');\nreturn $$('#expense-list li[data-id]').length === 0 && exists('#expense-list .empty') && text('#total') === 'Total: $0.00';\n"},{"id":"add-and-all","test":"await input($('#filter'), 'all');\nawait input($('#label'), 'Museum');\nawait input($('#amount'), '12');\nawait input($('#category'), 'fun');\nawait click($('button[type=\"submit\"]'));\nconst items = $$('#expense-list li[data-id]');\nconst saved = JSON.parse(localStorage.getItem('pocket.expenses.v1'));\nreturn items.length === 3 && text(items[2]).startsWith('Museum: $12.00') && saved.length === 3 && new Set(saved.map((e) => e.id)).size === 3;\n"}],"title":"أكمل Pocket","prompt":"<p>Pocket على وشك الاكتمال: الـ state و<code dir=\"ltr\">update</code> و<code dir=\"ltr\">render</code> والتخزين ونموذج الإضافة كلها تعمل. اربط القطع الثلاث الأخيرة:</p>\n<ol>\n<li><code dir=\"ltr\">visibleExpenses()</code> تُرجع فقط المصاريف التي تطابق <code dir=\"ltr\">category</code> فيها قيمة <code dir=\"ltr\">state.filter</code>، أو كلها حين يكون الفلتر <code dir=\"ltr\">&quot;all&quot;</code>. و<code dir=\"ltr\">render</code> تستخدمها أصلًا للقائمة وللمجموع.</li>\n<li>حين تتغيّر قائمة الاختيار <code dir=\"ltr\">#filter</code>، حدّث الفلتر.</li>\n<li>أضف مستمع نقرات مفوَّضًا <strong>واحدًا</strong> على <code dir=\"ltr\">#expense-list</code> يحذف المصروف الذي نُقر زر الحذف الخاص به.</li>\n</ol>\n<p>لا تغيّر البيانات إلا عبر <code dir=\"ltr\">update(...)</code>، كي يُحفظ كل تغيير ويُعاد عرضه.</p>\n","hints":["<code dir=\"ltr\">visibleExpenses</code> عبارة عن <code dir=\"ltr\">if</code> واحدة و<code dir=\"ltr\">filter</code> واحدة: <code dir=\"ltr\">state.expenses.filter((e) =&gt; e.category === state.filter)</code>.","<code dir=\"ltr\">filterSelect.addEventListener(&quot;change&quot;, () =&gt; update({ filter: filterSelect.value }));</code>","للحذف: <code dir=\"ltr\">const button = event.target.closest(&quot;.delete&quot;); if (!button) return;</code> ثم اقرأ <code dir=\"ltr\">button.closest(&quot;li&quot;).dataset.id</code> واستدعِ <code dir=\"ltr\">update({ expenses: state.expenses.filter((e) =&gt; e.id !== id) })</code>."],"explanation":"<p>الميزات الثلاث صغيرة لأن البنية تتولّى العمل الثقيل. الفلتر لا يُخزَّن في أي مكان سوى <code dir=\"ltr\">state.filter</code>؛ والقائمة المرئية ومجموعها يُشتقّان منه وقت العرض، فلا يمكن أن يختلفا أبدًا. ومعالجا التصفية والحذف لا يفعلان إلا حساب القيم الجديدة واستدعاء <code dir=\"ltr\">update</code>، التي تطبّقها، وتحفظها في التخزين، وتعيد العرض، فتكون البيانات المحفوظة دائمًا هي ما يراه المستخدم. والتفويض يعني أن مستمع الحذف يظل يعمل عبر كل إعادة عرض ومع المصاريف المضافة حديثًا. ولأن <code dir=\"ltr\">idCounter</code> يبدأ بعد أعلى معرّف محفوظ، لا تعيد المصاريف الجديدة أبدًا استخدام معرّف مخزّن أصلًا.</p>\n","checkLabels":{"initial":"يعرض المصاريف الثلاثة المحفوظة ومجموعها","filter":"اختيار Food يعرض مصاريف الطعام فقط ومجموعها","delete":"الحذف يُزيل المصروف من الصفحة ومن التخزين","empty-state":"الفلتر الذي لا يطابق شيئًا يعرض رسالة الفراغ و$0.00","add-and-all":"إضافة مصروف والفلتر على All تعرضه وتحفظه بمعرّف جديد"}},"words":603,"readMinutes":4}