iL

SMART ADMIN PORTAL

iLearn CRM

📊 Dashboard

Synced
--:--
Dufferin County, ON
--°
Loading...
👥
0
Total Contacts
View →
📧
0
Subscribers
View →
🌟
0
Providers
View →
🎯
0
Leads
View →
📋
0
Open Tasks
Manage →

📈 Contact Growth

Last 6 months

🔥 Activity

📦 Inventory Overview

View all →
Loading…

👥 Types

📱 Social Status

⚡ Quick Actions

👥 Team Presence

Online < 2 min Away < 10 min Offline
📅 Calendar
Today Task Due Booking
📋 Upcoming — Next 7 Days

✅ Tasks & Assignments

📓 Quick Notes

📸 Click here then Ctrl+V to paste a screenshot — or take a screenshot first and click Paste Screen

💰 Revenue & Expenses

Revenue
$0
Expenses
$0
Net
$0

📊 Pipeline Forecast

$0
Weighted forecast multiplies each stage's value by its typical close probability.

🏆 Top Performers

Last 7 days
to
Name Email PhoneAddressType Date Added Status ProvidersActions

🏢 Suppliers

Manage vendors, contractors and service providers.

Company Contact Category Phone Email Status Terms Added Actions

📦 Inventory & Assets

Track equipment, supplies, and physical assets with full check-out history.

Asset Category Qty / Value Location Assigned To Status Warranty Updated Actions

🎯 Lead Generation

Track prospects, manage the pipeline and convert leads into clients.

Loading…

MORE FOLDERS

Loading…
🔄 Auto-refresh on
Inbox
⏳ Loading emails…
📬
Select an email to read
or click Compose to write a new one
Name Email Phone Date Status Actions
0 subscribers

🔗 Social Media Connections

Connect your social accounts to broadcast posts directly from the admin portal. Click Connect Account to begin the secure authorization workflow for each platform.

🔒 Security & Authorization: iLearn Admin uses OAuth 2.0 for each platform. Your login credentials are never stored. Add your live API keys in Settings → Social API Keys to enable real posting. This interface demonstrates the complete broadcast workflow.

✏️ Compose & Broadcast

Live Preview
f
iLearn HCC
Facebook · Just now
🤖 Google reCAPTCHA v2

Get your keys at google.com/recaptcha/admin. Add both your domain and www.ilearnhcc.com. These keys protect the booking and contact forms on the main website.

Your post will appear here…
📸
@ilearnhcc
Instagram · Just now
Your post will appear here…
in
iLearn Child Care
LinkedIn · Just now
Your post will appear here…

📋 Post History

ContentPlatformsDateStatusProvidersActions
📣
0
Total
📤
0
Sent
📝
0
Drafts
NameSubjectRecipientsDateStatusActions

📢 Website Ticker Manager

Control the scrolling announcement bar on the main website. Toggle items on/off, edit text, reorder with ▲▼. Click Save & Publish to push changes live immediately.

40s
Live Preview (active items only)

🗺️ Provider Map Manager

ℹ️ Add new providers via Contacts → set type "Provider"

Manage the provider network map on the main website. Add locations, toggle availability, remove inactive providers. Click Save & Publish to update the live map. Only show general areas to protect provider privacy.

Providers — 0
Title + Preview Category Date Status Actions
Author Testimonial Rating Status Actions

❓ FAQ Manager

Add, edit, reorder, or remove FAQs. Changes push live to the website when you publish.

FAQ Items

💡 Tips

• Use the ▲▼ buttons to reorder FAQs — order is reflected on the website.
• Toggle the switch to hide/show individual FAQs without deleting them.
• Click Publish to Website after any changes to push them live.
• Icon field accepts any emoji (e.g. 🦋 💰 🛡️ 📍 🕐 🚀)

🔍 Audit Trail

📋 Activity Log shows admin actions only. 💬 Team Notifications shows website form submissions and chat alerts.

📋 Activity Log

Time User Role Action DetailsIP / LocationSession

💳 Parent Payments & Attendance

Track enrollment payments, attendance, and send automated receipts.

🔗 Payment Links & Reminders

Included in all payment reminder emails

📧 Receipt Settings

Total Collected
$0.00
This Month
$0.00
Families Enrolled
0
Receipts Sent
0
DateParent / ChildAmountTypeMethodPeriodReceiptActions

💵 Payroll & T4 Management

Track provider pay periods, upload T4 slips, and manage payroll records.

YTD Payroll
$0.00
Active Providers
0
Pay Periods
0
T4 Slips
0

📅 Pay Period Records

ProviderPay PeriodStartEnd Gross PayDeductionsNet PayStatusActions

🧾 T4 Slips

Upload T4 slips for each provider. Files are stored securely and can be sent directly to providers via email.

📄 Forms Manager

Upload new versions of the Provider Application and Registration Form. Uploaded forms are linked on the main website and included in email submissions automatically.

📄 Provider Application Form

Current Form
Provider_Application.pdf (default)
👁 Preview
✓ Linked on the "Become a Provider" section
✓ Included as attachment in provider welcome emails
✓ Download link appears on main website automatically

📋 Child Registration Form

Current Form
Registration_Form.pdf (default)
👁 Preview
✓ Linked on the "Enroll Your Child" contact section
✓ Included as attachment in parent welcome emails
✓ Download link appears on main website automatically

📧 Email Delivery Settings

When M365 is connected, forms are automatically attached to welcome emails. Configure what each form submission sends below.

Provider Application Email Includes:
Parent Registration Email Includes:

📁 Upload History

No uploads yet. Uploaded forms replace the default PDFs on the website.

✅ Tasks & Reminders

Track, assign, and get alerted on all team tasks
📋
0
Total
🕐
0
Open
⚠️
0
Overdue
0
Completed
TaskAssigned ToPriority Due DateCategoryStatusAlertsActions

🧾 Invoices & Expenses

0
0
0
$0
Invoice # Supplier Date Due Category Amount Status Actions

📣 Auto Dialer & Notifications

Send bulk email and SMS notifications to parents, providers, or all contacts based on pre-built scenarios.

⚙️ SMS / Twilio configuration has moved to Settings → SMS Configuration.

📋 Scenarios

🚸 Child Absent Alert

0 recipients selected
📋 Sent Log & Archive

📊 Reports & Analytics

Comprehensive financial and operational summaries — exportable and emailable.

💰 Revenue vs Expenses

🥧 Expense Breakdown

📈 Payment Trend

Revenue
$0
Expenses
$0
Net
$0
Payments In
$0
Refunds
$0

💚 Revenue Breakdown

🔴 Expense Breakdown

💳 Parent Payment Transactions

DateParentAmountTypeReceipt #Status

⚠️ Outstanding / Follow-up Required

No outstanding items tracked yet.

📁 File Repository

Shared file storage — policies, training, reports, templates and more.

📭
No files here yet. Click ⬆ Upload File to add your first file.

❓ Help & User Guide

Complete reference for the iLearn Admin Portal and public website.

📋 Contents

🕐
Availability Settings
Meeting duration, buffer, available hours & days have moved to Settings → 📅 Calendar so there's a single source of truth.

🚫 Blocked Dates

📅 Month View
Today

Blocked dates appear as unavailable on the main website booking calendar.

📅 Scheduled Meetings v5.08 · NEW

TitleAttendeeDateTimeDurationStatusActions

📋 Booking Requests

NameEmailDateTimeTopicStatusActions
👤
0
Total Users
🛡
0
Admins
👤
0
Staff
NameEmailRoleEmployee #ResponsibilitiesStatusCreatedLast LoginActions

🔒 Role Permissions

🛡 Admin — Full Access
All sections • User management • Settings • Data export • API keys • Calendar config
👤 Staff — Operational
Contacts • Subscribers • Blog • Testimonials • Ticker • Campaigns • Calendar view
👁 Viewer — Read Only
Dashboard only • No editing • No export • No user or settings access

📄 Purchase Orders

Create, track and print purchase orders for suppliers.

📋 Purchase Orders

PO# Date Due Date Supplier Items Subtotal Total Status Actions

📋 Portal Information & Release Notes

Version
Prod 20260421-1.3
Released
April 2026
Stack
HTML · JS · PHP · JSON
📌 v5.67 — Tab Bars Sticky on Mobile + Scroll Offsets Account for Breadcrumb (91px+)
  • 🎯 Social Media Hub tabs (and other in-page tab bars) were getting clipped at the top on mobile. Safia's screenshot showed "Connected Accounts" tab with its top half cut off behind the breadcrumb. Live diagnosis confirmed the geometry: topbar is 54px (in flow), #v440BreadcrumbHost is position:fixed; top:62px; height:37px (bottom at y=91 on mobile), and in-page tab bars like .social-tabs are position:static by default. At any small scrollY, the static tab bar slides behind the fixed breadcrumb and gets half-obscured. v5.65's scroll-padding-top: 72px was also too small — actual fixed overlap area on mobile is 54 + 37 = 91px, so anchor scrolls were landing targets 19px behind the breadcrumb.
  • 📌 Tab bars now sticky at top:91px on mobile. Any page-level tab bar (.social-tabs, .ctab-bar, .settings-tab-bar, any [id*="tabBar"]) becomes position:sticky; top:91px; z-index:5 on screens ≤768px — rides along right below the breadcrumb with a subtle backdrop-filter: blur(4px) so content scrolling below it stays readable through the edge. Desktop keeps tab bars static exactly as before (rule scoped inside @media (max-width:768px)). Affected pages include: Social Media Hub (Connected Accounts / Compose & Broadcast tabs), Contacts Edit modal (9-tab bar: Basic / Parents / Spouse / Children / Emergency / Providers / Notes / Banking / History), Settings (8 tab bar: Portal / Account / Agency / Data / Email / Integrations / Business / etc.). All tab bars also scroll horizontally with scroll-snap for many-tab cases.
  • 📐 Scroll offsets bumped from 72px to 110px to clear the full header. html, body { scroll-padding-top: 110px } — programmatic scrolls (anchor jumps, form-focus scrolls, scrollIntoView calls) now land targets 110px from the top = 54px topbar + 37px breadcrumb + 19px visual buffer. scroll-margin-top: 110px applied to every landmark element: card headers, section titles, form labels, tab bar items, stats bars. Covers the scenario where a user taps directly into a form field buried inside a long form — the label above the input stays visible instead of getting pushed behind the breadcrumb.
  • 📏 First-card margin + page padding adjusted. First .card in each .page gets margin-top: 14px (was 8px from v5.65). .page top padding bumped from 6px to 10px. At scrollY=0, the first content element now sits a clean 14-24px below the breadcrumb's bottom edge — no more "first line of text almost touches the breadcrumb" feel.
  • 🔒 Sticky stats bars repositioned to top:91px on mobile too. The pre-existing [id$="Stats"]:not(:empty) rule was top:10px (designed for the v4.64-era layout). On mobile with the 91px breadcrumb overlap, that caused stats bars to appear 81px behind the breadcrumb. Now stats bars stick at 91px, flush with the breadcrumb's bottom edge — exactly where the user expects to find the running totals while scrolling long tables.
📌 Scope / files touched
  • ilearnhcc-admin-v2.html — new <style id="v567-css"> block appended after v5.65. All rules scoped inside @media (max-width: 768px). Supplementary — doesn't remove or edit prior v5.61/v5.63/v5.64/v5.65/v5.66 rules. Zero desktop impact. v5.67 release-notes card; title/sidebar/readme version bumps.
  • ilearn-admin.js_PORTAL_BUILD → v5.67.
  • ilearn-db.phpPORTAL_BUILD → v5.67.
⚠️ Validation path
  1. Upload 11 files → purge Cloudflare → hard-reload. Title bar reads v5.67 · Apr 2026.
  2. Social Media Hub: on phone, scroll down inside the page. The "Connected Accounts" / "Compose & Broadcast" tab bar should now stay visible at the top of the page (sticky below the breadcrumb) as content scrolls under it. Tap a tab — you stay on that tab, content below refreshes. No more clipped tabs.
  3. Edit Contact modal: open any contact for editing. The 9-tab bar (Basic / Parents / etc.) stays visible while scrolling through the form fields. Full tab text visible — no half-cut letters.
  4. Settings: the horizontal tab bar (Portal / Account / etc.) stays anchored at the top on mobile while the card content below scrolls.
  5. Stats bars: open Leads / Inventory / any page with a stats summary bar. Scroll the table; stats bar stays flush below the breadcrumb without partial clipping.
  6. First-card spacing: navigate to any page — at scrollY=0, the first card's header is a clean ~14px below the breadcrumb's bottom edge. No "almost touching" feel.
  7. Desktop unchanged: resize to ≥1024px — tab bars revert to position:static, scroll offsets are 0, first-card margins return to defaults. No regressions.
ℹ️ Rollback: re-deploy iLearnHCC_v5_66.zip.
🌙 v5.66 — Dark Mode "Randomly Turned On" Fix (Cross-Device Preference Leak)
  • 🐛 Dark mode was flipping on unexpectedly on desktop — root cause identified. Live diagnosis on production: current state il_theme='light', systemPrefersDark=true (OS in dark mode), no recent theme-related audit entries. The smoking gun: v5.23's wrapSetTheme phone guard (line 10563–10576) wrote 'dark' to localStorage.il_theme whenever setTheme('dark') was called on a phone-width viewport — even though it kept the phone UI on light mode and toasted "Light mode stays on for mobile." The comment explained the intent: "save the preference so desktop still honours it." But that design choice created a cross-device leak: an accidental Shift+D (the portal's toggle-dark shortcut, wired in at line 8786) while the user was briefly on phone/narrow-window view, OR a mis-click on the command palette's "🌙 Toggle dark mode" entry (line 8670), silently wrote 'dark'. The user saw nothing change on their phone, assumed dark was blocked. Then on DESKTOP: restoreIfDesktop() read il_theme='dark' from storage and flipped desktop to dark — from the user's perspective "dark mode just randomly turned on." No audit log entry either, because setTheme() didn't use logAct.
  • 🔒 Fix 1 — wrapSetTheme no longer persists 'dark' on phone. It's now a true no-op: if setTheme('dark') fires while window.innerWidth ≤ 768, nothing is written to localStorage, data-theme stays 'light', and the toast now says "☀️ Dark mode is only available on desktop" — clearer intent. Desktop dark-mode toggles work exactly as before (Shift+D / command palette / any other entry point). The phone UI stays stable. And most importantly: a phone-triggered dark call can no longer "travel" to desktop.
  • 🧹 Fix 2 — one-time remediation clears leaked 'dark' state. Any user whose device already hit this bug pre-v5.66 has il_theme='dark' silently persisted and may see dark mode turn on again on their next desktop visit. On the first desktop page load after v5.66 deploys, the new v566Remediate() runs: if il_theme='dark' AND il_v566_dark_remediated flag is NOT set, it resets to 'light', sets the flag, and toasts "☀️ Reset to light mode — v5.66 fixed a dark-mode persistence bug. Toggle Shift+D if you want dark back." Users who actually wanted dark can re-enable with one keystroke (their toggle will write both il_theme='dark' AND the already-present remediated flag — future toggles are trusted and never reset). Runs once per device. Ignored on phone (we don't touch phone state — phone stays forced light regardless).
  • 🛡 Preserved behaviour. Desktop dark mode still works: Shift+D, command palette → "Toggle dark mode", and any code that calls toggleTheme() or setTheme('dark') on desktop all function identically to v5.65. The preference persists across desktop sessions. Phone still forces light mode at any viewport ≤768px. The keyboard-shortcut-in-form-field guard (line 8779 — if (inField) return) remains in place, preventing Shift+D from firing while typing in inputs.
  • 🔎 Forensic note. If this bug recurs in the future on a new device or after a cache clear, the Safia-identified signature is: (1) current localStorage contains il_theme='dark', (2) no Anthropic audit log entry in the last 24h mentions "dark" or "theme", (3) the user doesn't remember enabling dark mode. The v5.66 guard + remediation should fully prevent this scenario, but the marker il_v566_dark_remediated in localStorage is a tell — if missing, the user somehow cleared their localStorage and the remediation will re-run next load.
📌 Scope / files touched
  • ilearnhcc-admin-v2.htmlwrapSetTheme (inside v5.23 mobile-polish block at line 10559): removed localStorage.setItem('il_theme','dark'), toast text updated, guard flag renamed to _v566PhoneGuard (keeps _v523PhoneGuard as alias for back-compat). New v566Remediate() IIFE runs once on desktop to clear leaked dark state. v5.66 release-notes card; title/sidebar/readme version bumps.
  • ilearn-admin.js_PORTAL_BUILD → v5.66.
  • ilearn-db.phpPORTAL_BUILD → v5.66.
⚠️ Validation path
  1. Upload 11 files → purge Cloudflare → hard-reload on desktop. Title reads v5.67 · Apr 2026.
  2. Remediation on first load: if you're currently infected (il_theme='dark' in localStorage), the reset fires + toast appears. If already on light (current state per live diag), silent pass-through and marker gets set.
  3. Verify marker: DevTools → Application → Local Storage → confirm il_v566_dark_remediated='1' exists.
  4. Desktop dark still works: Shift+D → UI flips to dark, toast "🌙 Dark mode on", il_theme='dark' in storage. Reload → stays dark. Shift+D again → flips back to light.
  5. Phone guard behaves: resize browser to ≤768px, Shift+D → UI stays light, toast "☀️ Dark mode is only available on desktop", il_theme value in localStorage UNCHANGED. Resize back to ≥1024px → UI still matches whatever desktop was set to before the phone-width foray.
  6. Repeat reloads: the remediation does NOT re-fire even if you manually set il_theme='dark' in DevTools — the marker gates it. If you want to re-test, delete the marker key and reload.
ℹ️ Rollback: re-deploy iLearnHCC_v5_65.zip. Note: the leaked il_theme='dark' in storage will re-activate on rollback unless you also clear the key manually.
📱 v5.65 — Compact Mobile Buttons + Top-of-Page Scroll-Clipping Fix
  • 🔘 All mobile buttons shrunk to fit more content per screen. Safia's screenshots showed Quick Actions buttons ("+ Add Contact", "📢 Edit Ticker", "📷 Broadcast Post", "🗺️ Manage Providers", "📦 Load Sample Data", "⬇️ Export All Data", "💾 Save Database File") stacking as 44-50px tall full-width pills — eating half the viewport before any real content appeared. List-action buttons ("+ Add", "↓ CSV") at the top of Contacts/Subscribers/Campaigns same issue. Fix: .btn padding drops from 8px 18px7px 12px, font-size .82rem.76rem, border-radius 50px → 40px to match the shorter height. Full-width variants (Quick Actions, Settings action buttons) get padding: 8px 12px; font-size: .78rem — noticeably more compact but still comfortably tap-friendly. Gap between stacked Quick Actions buttons reduced. On iPhone SE (≤420px) buttons shrink further to 6px 10px / .72rem. Result: 7-button Quick Actions widget goes from ~350px tall to ~220px tall — two to three more rows of actual page content visible above the fold.
  • 📐 Top-of-page content no longer clipped under topbar/breadcrumb. Safia's screenshot of the Parent Payments page showed "Payment Link URL" rendering as "ayment Link URL" — the P was clipped at the top edge. Root cause: when content scrolls, first-visible headings end up partially behind the sticky topbar + breadcrumb area. Standard sticky UX but visually distracting and made users think data was cut off. Three-layer fix: (a) scroll-padding-top: 72px on html, body — any programmatic scroll (anchor jumps, form-focus scrolls, nav events) lands the target 72px from the top instead of flush with viewport top 0. (b) scroll-margin-top: 72px on every .card-hd, .card-bd h4, .page h3, .fg label, and stats bar — same effect per element, covers cases where the user taps a form field directly. (c) .content top padding reduced from 24px (desktop) to 12px (mobile), .page gets a fresh 6px top pad, and the first .card in each page gets margin-top: 8px — together these ensure the FIRST card's header isn't butted up against the breadcrumb's bottom edge. No more letters-cut-off feeling. 72px = 54px topbar + 16px visual gap + 2px safety.
  • 📏 Tighter card + header spacing for density. Card margin-bottom 14px → 10px, card-hd padding 12x14px → 10x12px, card-hd h3 font .95rem → .92rem, card-bd padding 14px → 12px, dash-grid gap 14px → 10px. Each reduction is small — combined they add up to roughly one additional row of content visible per scroll position.
  • 🖱 Scroll-behaviour: smooth everywhere. Added scroll-behavior: smooth on html, body at the mobile breakpoint so nav events glide to their target instead of snapping (most jarring when combined with the 72px offset). Works with existing nav('page') navigation and anchor tapping.
  • 📊 Select/dropdown polish. Sort dropdowns ("Newest first", "Oldest first", etc.) get padding: 6px 10px; font-size: .82rem; line-height: 1.3 on mobile — visibly shorter while staying readable and tap-friendly (select arrow + native touch area preserved).
📌 Scope / files touched
  • ilearnhcc-admin-v2.html — new <style id="v565-css"> block appended after v5.63's mobile block. All rules scoped inside @media (max-width: 768px) (+ ≤420px sub-query). Supplementary — doesn't remove or edit prior v5.61/v5.63/v5.64 rules. Zero desktop impact. v5.65 release-notes card; title/sidebar/readme version bumps.
  • ilearn-admin.js_PORTAL_BUILD → v5.65.
  • ilearn-db.phpPORTAL_BUILD → v5.65.
⚠️ Validation path
  1. Upload 11 files → purge Cloudflare → hard-reload. Title bar reads v5.67 · Apr 2026.
  2. Quick Actions widget on mobile dashboard: buttons should now be noticeably shorter. 8 action buttons should fit where 6 used to fit before scrolling.
  3. Contacts/Subscribers/Campaigns: the "+ Add" / "↓ CSV" / sort-dropdown row at the top of these lists is now compact. More room for the actual records list below.
  4. Parent Payments page: scroll down to the "Payment Link URL" section. The label is fully visible — no clipping at the top edge. Tap directly into the URL input — the label above stays visible (doesn't disappear behind the breadcrumb).
  5. Form labels in modals: tap any input inside a modal. The label above it stays clearly visible — doesn't get cut by the modal's top edge or the sticky topbar.
  6. Desktop unchanged: resize to ≥1024px — all buttons return to their original 8x18px padding, .82rem font, 50px radius. Card spacing reverts to desktop defaults. Zero regression.
ℹ️ Rollback: re-deploy iLearnHCC_v5_64.zip.
🧹 v5.64 — "Cleaned up N duplicate leads" Toast Fix (Persistence + Nuisance Gate)
  • 🔁 "Cleaned up 8 duplicate leads" toast was firing on every page reload. Live diagnosis on production confirmed the root cause: the v5.15 dedupe sweep uses an in-memory sentinel (window._leadDedupeSweepRan) that resets on every page load. Every time the portal reloaded, the 4-second post-DOMContentLoaded sweep re-ran and re-fired the toast. But that alone wouldn't cause it — there's a second, deeper bug: the sweep's "removed" action wasn't actually persisting to the server. The sweep called sd('leads', keepers) + pushKeyToServer('il_leads', keepers), but two things conspired against it: (a) autoLoadFromServer's 3-second heartbeat pulled the server's (still-duplicated) state back into localStorage within seconds, reverting the local delete; (b) pushKeyToServer MERGES rather than DELETES — so IDs omitted from the pushed keeper list were treated as "not updated" rather than "deleted," and the server happily kept them. Live audit log showed "removed 8 duplicate record(s)" logged twice in the same minute, yet all 11 dupes (7× Anthony Hosein, 2× sue sue, 2× brookprovider ali) were still present — hard proof that the delete wasn't landing.
  • Fixed at both layers — tombstones + nuisance gate. (Layer 1 — proper persistence): for each dropped record, the sweep now writes a tombstoneAdd('leads', id) AND calls pushDeleteToServer('il_leads', id). Tombstones instruct the server's merge logic to treat the id as intentionally-deleted; subsequent autoLoadFromServer pulls honor the tombstone and the record stays gone. After all tombstones + deletes are queued, publishToServer(true) flushes immediately. (Layer 2 — nuisance gate): even in the unlikely case that the server fails to honor a tombstone for any reason (misconfigured merge, stale replica, etc.), the toast no longer re-fires. New localStorage key il_v564_last_dedupe_toast stores {timestamp}|{count:ids-sorted}. If the same signature is already present within the last hour, the sweep still runs silently (audit log entry + console warn still fire for forensic visibility), but the user-facing toast is suppressed. Genuinely new dedupes still toast normally.
  • 📊 Why the dupes existed in the first place. Live data showed 7 "Anthony Hosein" leads all from the "Website Booking" source — Safia's legitimate testing of the booking flow created a fresh lead record each time (different IDs, same name+email+stage). The website's confirmBooking() calls pushLeadToServer(_lead) with a fresh Date.now()+1 id on every submission. This is correct behaviour for production (different bookings ARE different leads) but noisy during testing. The dedupe sweep was added in v5.15 specifically to auto-heal the accumulated test duplicates — it just wasn't persisting its fix, so the same 11 dupes re-triggered the toast every page load. After v5.64, they get tombstoned on first sweep and stay gone.
  • 🔍 Preserved behaviour. Audit log still records every dedupe action (🧹 Lead dedupe: removed N duplicate record(s) — {names}) so the forensic trail is intact. Console warn still fires. Only change user-facing is: the toast now fires at most once per hour per unique dedupe signature, and the server actually stays deduplicated across reloads.
📌 Scope / files touched
  • ilearn-admin.js — lead dedupe sweep (around line 37377-37400): replaced pushKeyToServer('il_leads', keepers) with per-record tombstoneAdd('leads', id) + pushDeleteToServer('il_leads', id) + trailing publishToServer(true). Added localStorage-backed toast gate (il_v564_last_dedupe_toast). _PORTAL_BUILD → v5.64.
  • ilearn-db.phpPORTAL_BUILD → v5.64. No handler changes.
  • ilearnhcc-admin-v2.html — v5.64 release-notes card; title/sidebar/readme version bumps.
⚠️ Validation path
  1. Upload 11 files → purge Cloudflare → hard-reload. Title reads v5.67 · Apr 2026.
  2. First load after upload: you SHOULD see the "🧹 Cleaned up 11 duplicate leads" toast fire one final time — this is the fixed sweep actually persisting the delete. Check localStorage → il_v564_last_dedupe_toast should now have a value. Go to Leads → the Anthony Hosein / sue sue / brookprovider ali rows that were previously duplicated should now show only one record each.
  3. Reload the portal. Sweep runs silently. No toast this time. No dedupe activity in the audit log (there shouldn't be any dupes to remove). Lead count stays reduced.
  4. Reload again after 5 minutes. Still no toast, still no dupes. That's the persistence fix working.
  5. Create a test scenario: manually duplicate a lead (open DevTools → var l = gd('leads'); l.push({...l[0], id: Date.now()}); sd('leads', l);) then reload. Toast should fire once naming the new dupe. Subsequent reloads silent.
ℹ️ Rollback: re-deploy iLearnHCC_v5_63.zip.
📱 v5.63 — Tables Show 3 Columns on Mobile + Tiny Action Icons + Modal Polish
  • 📋 Contacts + every major table now shows 3 useful columns on mobile instead of 2. v5.62 hid too much — showing only Name + Actions stripped the user of contextual info (was this the parent or the provider? what's their email?). v5.63 keeps a secondary identifier column visible so the user can recognize rows at a glance: Contacts → Name + Email + Actions; Suppliers → Company + Contact + Actions; Bookings → Name + Date + Time + Actions; Subscribers → Name + Email + Actions; Campaigns → Name + Status + Actions; Leads → Name + Stage + Actions. Remaining 14 tables (blog, testimonials, inventory, audit, meetings, users, tasks, parent payments, POs, purchase requests, parent invoices, enrollments, payroll, dialer history) use the "keep columns 1-3 + last" pattern — more columns visible than v5.62 while still fitting a 390px viewport. Cell padding tightened to 7px×4px, font 0.7rem, line-height 1.3 so stacked data fits.
  • 🎯 Action icons shrunk to 26×26 squares (24×24 on iPhone SE). All buttons inside table Action cells get width:26px; height:26px; padding:0 with emoji-only rendering — no text labels — so three or four actions fit in roughly 90–120px. Buttons are still 44×44 touch-targets on pointer-coarse devices per v5.28's rule, meeting Apple's iOS HIG — the visual square is 26×26 but tap area is unchanged. Gap between buttons reduced from 5px to 2px. Applies to all .acts .act + td .btn + td button selectors — affects every table's action column consistently.
  • ✏️ Edit Contact modal + all other edit modals now mobile-polished. Before v5.63, the Edit Contact modal had max-width:1060px; width:98vw and a display:flex photo row with 88px avatar + child photos + two upload buttons side-by-side — completely broken on a 390px phone. Fixes: (a) Every modal width locked to calc(100vw - 16px) with 8px margin, max-height calc(100vh - 16px) so it always fits. (b) All form grids (.fgrid, .fgrid.g2/g3/g4) collapse to 1 column. (c) Inputs get 16px font-size (anti-iOS-zoom), 8×10px padding, 8px border-radius. (d) Photo row stacks vertically; avatar shrinks 88px → 64px. (e) Tab bar (9 tabs in Edit Contact: Basic/Parents/Spouse/Children/Emergency/Providers/Notes/Banking/History) scrolls horizontally with scroll-snap-type: x proximity so one tab is always centered after a swipe. (f) Modal footer buttons wrap to full-width with flex:1 1 auto; min-width:90px — Cancel + Save + Delete no longer overflow. (g) Modal body padding reduced from 22px to 12px. (h) Close button (.mcl) shrunk to 28×28. Applies to every .modal via universal CSS — not just contacts. So editing Leads, Bookings, Suppliers, Campaigns, Users, Providers all inherit the same polished mobile layout.
  • 📐 Why this matters compared to v5.62. Safia's feedback summary: v5.62's "two columns only" mobile tables felt too sparse to be useful — she'd have to open every row's edit modal just to recognize who it was. v5.63 restores a usable three-column layout by targeting exactly which column to keep on each table, not applying a generic formula. Action buttons went from ~32×32 with visible padding to 26×26 (the sweet spot for emoji-only icons — tight but readable). Every modal now fits within a phone viewport with no horizontal scroll. Together: users can now recognize rows, tap them open, read + edit without any horizontal scrolling or squinting.
📌 Scope / files touched
  • ilearnhcc-admin-v2.html — replaced v5.62's <style id="v562-css"> block with a new <style id="v563-css"> block, five sections: (1) table base + column visibility, (2) tiny action icons, (3) modal polish (header, body, tab bar, photo row, footer), (4) topbar overflow fix (unchanged from v5.62), (5) page-level polish. All rules scoped inside @media (max-width: 768px) with a ≤420px sub-query for small-screen extra compaction. Zero desktop impact.
  • ilearn-admin.js_PORTAL_BUILD → v5.63. No logic changes.
  • ilearn-db.phpPORTAL_BUILD → v5.63. No handler changes.
⚠️ Validation path
  1. Upload 11 files → purge Cloudflare → hard-reload. Title bar reads v5.67 · Apr 2026.
  2. Contacts mobile: open Contacts on phone. Three columns visible: Name, Email, Actions. Action column shows 5 small icon-only buttons (✏️ 🗑 📧 📤 📂) each 26×26px with 2px gap. Tap any row → Edit Contact modal opens full-screen with all 9 tabs scrolling horizontally at the top, Basic Info tab showing stacked photo/name/email/phone fields in 1 column.
  3. All other tables: Suppliers/Bookings/Subscribers/Campaigns/Leads/Blog/Testimonials/Inventory/Audit/Meetings/Users/Tasks all show 3 useful columns + compact action icons.
  4. Modals: Open Add Lead / Reschedule Booking / Schedule Meeting / Agency Settings / Any other modal on mobile. Width fits viewport with ~8px margin. Grids collapsed to 1 column. Input font 16px (no iOS zoom). Footer buttons stack full-width if more than two.
  5. Topbar gear: no overlap at 390px. Labels "Alerts/Email/Chat" hidden, icons visible, badges render normally.
  6. Desktop unchanged: resize Chrome window to ≥1024px → all 10 columns visible, full-size modals with 1060px max-width, action buttons at original size.
ℹ️ Rollback: re-deploy iLearnHCC_v5_62.zip.
📱 v5.62 — Mobile Tables Condensed (not scrolled) + Topbar Gear Overlap Fix
  • 📋 Tables now CONDENSE on mobile instead of scrolling horizontally. v5.61's approach — make every wide table horizontally scrollable with a sticky first column — was rejected after real-world mobile testing. Safia's feedback: scrolling was clunky; users want to see all relevant info in a glance without swiping sideways. v5.62 reverses that approach completely. Per-table column-hiding rules now show only the essential columns (usually Name/Title + Actions) on screens ≤768px, with non-essential columns (Email, Phone, Address, Date Added, Status, Providers, etc.) hidden via CSS. Column count drops from 10 on desktop to 2-3 on mobile, fitting comfortably within a 390px viewport. All hidden data remains fully accessible by tapping a row to open its edit modal — it's presentation only, not data removal. Applied to: contacts (#cTbody), suppliers (#supTbody), bookings (#bookingTbody), subscribers (#sTbody), campaigns (#campTbody), leads (#leadTbody), blog (#blogTbody), testimonials (#testTbody), inventory (#invTbody), audit trail (#auditTbody), meetings (#meetingsTbody), users (#userTbody), tasks (#tskTbody), parent payments (#pymtTbody), POs (#poTbody), purchase requests (#prTbody), parent invoices (#pinvTbody), enrollments (#enrollTbody), payroll (#rptPayTbody), dialer history (#histTbody). The specific "keep what" decision is hand-tuned per table for the first six (where I had explicit column knowledge); the rest use a generic "hide everything between 3rd column and Actions column" pattern that gets the right visual result.
  • ⚙️ Settings gear icon no longer overlaps the topbar on narrow screens. Safia's screenshot confirmed the bug: on a 390px phone, .tb-r (topbar right) contained four full-size pill buttons (#_settingsGear, #_notifPill with "Alerts" label, #topbarEmailBtn with "Email" label, #topbarChatBtn with "Chat" label) plus the user greeting — total width exceeded the viewport, pushing the rightmost button (the gear) out of its container and visually overlapping the edge with a green/red glow effect. Fix in three layers: (a) .tb-r now has max-width: calc(100vw - 110px), overflow: hidden, and flex-wrap: nowrap so nothing can escape the container; (b) all four topbar pill buttons get reduced padding + font-size on mobile; (c) the text labels "Alerts", "Email", "Chat" are hidden on mobile (display:none on #_notifPillLabel, #topbarEmailLabel, #topbarChatLabel) leaving just the emoji icons. Badges (red 17 unread count etc.) still render normally. On ≤420px (iPhone SE), padding shrinks further and badge dimensions scale down from 17×17 to 14×14 px. Desktop unchanged.
  • 🎯 Row tap to drill in is unchanged. Hidden columns don't remove data — tapping any row still opens its edit modal (for contacts: editContact()) where all fields are visible and editable. Makes the mobile table a "find the right record" view, with the full data surface living in the modal. This matches the phone app patterns users already expect from Gmail, Salesforce mobile, etc.
  • 🔤 Compact typography for mobile tables. Cell padding reduced from 10-14px to 6-5px, font-size from default to 0.72rem, action buttons from 0.75rem to 0.65rem font with 3×6px padding — fits more rows vertically without feeling cramped. word-break: break-word applied so long names/emails wrap rather than overflow. Cell max-width constraints removed so content flows naturally.
📌 Scope / files touched
  • ilearnhcc-admin-v2.html — new <style id="v562-css"> block at end of file, scoped inside @media (max-width: 768px) (+ a ≤420px sub-query). Reverses v5.61's display:block; overflow-x:auto table rules and replaces with display:table; min-width:0. ~20 per-tbody column-hiding rules. Topbar .tb-r constraints + pill-button compaction rules. Desktop styles completely unchanged. v5.62 release-notes card; title/sidebar/readme version bumps.
  • ilearn-admin.js_PORTAL_BUILD → v5.62. No logic changes.
  • ilearn-db.phpPORTAL_BUILD → v5.62. No handler changes.
⚠️ Validation path
  1. Upload all 11 files → purge Cloudflare → hard-reload.
  2. Title bar reads v5.67 · Apr 2026; db_ping returns build: 'v5.62'.
  3. Topbar gear: on phone, the ⚙️ gear sits cleanly inside the topbar, no overlap with the viewport edge or the notification bell. Compare with the screenshot in the v5.62 card — the gear should now be fully contained.
  4. Contacts mobile: open Contacts on a phone. Table shows 2 columns: "Name" and "Actions". Tap any row → edit modal opens with all 10 fields visible. Row height is compact (~36-40 px vs. the ~60-70 px of v5.61). Horizontal scroll is gone — everything fits in the viewport.
  5. Same pattern: repeat on Suppliers, Leads (if in table view, not kanban), Bookings, Campaigns, Subscribers, Blog, Testimonials, Inventory, Audit Trail, Meetings, Users, Tasks, Parent Payments, POs. Each table should show only 2-3 columns on mobile and tap-row should still open the full record.
  6. Desktop unchanged: open the same pages on a desktop or ≥1024px browser window — all 10 columns visible exactly as before. No regression.
ℹ️ Rollback: re-deploy iLearnHCC_v5_61.zip (last validated deployed build).
📱 v5.61 — Teams Capability Probe + Silent-Strip Detection + Mobile Polish Round 3 + Biometrics Mobile Surface
  • 🎥 "Test Teams Capability" probe button added to Settings → Microsoft 365. One-click diagnostic that reproduces the exact live investigation used to root-cause Safia's missing-Teams-link issue. Runs three checks: (1) token scope inspection (OnlineMeetings.ReadWrite present?), (2) license scan via /me/licenseDetails (Teams service plan assigned? status Success?), (3) a real event-creation probe against /me/events with isOnlineMeeting:true — plus automatic cleanup of the throwaway event so it never shows up in the admin's calendar. Renders a plain-English verdict with actionable guidance: "No Teams license — assign Microsoft 365 Business Basic", "OnlineMeetings scope missing — reconnect M365", "Teams silently stripped despite license — check tenant policy", or "✅ Teams works". Expandable details section shows the full JSON for deeper debugging. Probe runs with existing OAuth token — no extra credentials needed.
  • ⚠️ Silent Teams-strip detection in createM365CalendarEvent. Previously when Graph returned HTTP 201 with isOnlineMeeting:false / onlineMeetingProvider:"unknown" (the exact behaviour of an Exchange-only license responding to a Teams meeting request), the code treated it as success and moved on silently — admin never knew Teams failed. v5.61 compares the request intent (wantsTeams) against the response facts (isOnlineMeeting, presence of onlineMeeting block) and flags the mismatch as teamsSilentlyStripped. When detected: (a) stamps booking.teamsDiagnostic = 'silently_stripped_no_license', (b) emits an activity-log entry "⚠️ Teams meeting requested but Graph silently stripped it — {mailbox} likely lacks a Teams license", (c) emits a detailed m365Log warning with a snapshot of the Graph response facts and a pointer to the Teams probe for root-cause detail. Every silent strip is now forensically traceable.
  • ⚠️ Bookings table surfaces the silent-strip warning as a pill. A booking approved with 🎥 but whose Teams request got silently stripped now shows an amber "⚠️ Teams unavailable" pill next to the topic cell (same slot where the 🎥 Teams Join pill appears on successful bookings). The pill is clickable — tapping it navigates to Settings → Microsoft 365 and scrolls to the Teams probe panel. Zero-friction path from "something's wrong with this booking" to "here's the diagnostic."
  • 📱 Mobile polish round 3 — tables, modals, and headers now fit any phone viewport. Audit on production found: 10 bare tables with min-widths 700-980px overflowing horizontally, 22 modals with inline max-widths 440-720px escaping the 390px viewport, dashboard page measuring 1618px wide at element level. New <style id="v561-css"> block applies four global fixes inside @media (max-width: 768px): (a) every non-.no-mobile-scroll table gets display:block; overflow-x:auto; -webkit-overflow-scrolling:touch — tables scroll horizontally inside their card instead of blowing out the page. First column (usually the record name) is position:sticky; left:0 so the primary key stays visible as the admin scrolls sideways. (b) All modals get max-width:calc(100vw - 20px); width:calc(100vw - 20px) with !important to override inline styles — no more modal close buttons escaping off-screen. (c) All .page > * children capped at max-width:100%; box-sizing:border-box with page-level overflow-x:hidden — no more 1618px-wide phantom elements dragging the viewport sideways. (d) Card headers use flex-wrap:wrap with row-gap:8px so multi-part headers (title + badge + button) stack cleanly on narrow screens instead of clipping. (e) All text/email/number/date inputs forced to font-size:16px to suppress the iOS zoom-on-focus behaviour that was disorienting users on the Contacts and Bookings forms.
  • 🔑 Biometric Login surfaced at the TOP of Settings on mobile. The existing v5.25 Biometric Login card (WebAuthn / Passkeys / Face ID / Touch ID / Windows Hello / fingerprint) is buried in the account subsection — easy to miss on a phone where the user has to scroll through 30+ settings cards. v5.61 injects a prominent violet→fuchsia quick-access tile at the top of the Settings page whenever the viewport is ≤768px wide. Tile detects WebAuthn support: when available, shows "🔒 Enable Biometric Login" with a big tap-to-manage button that opens the existing openBiometricSettings() dialog (which handles enroll / list / revoke). When WebAuthn isn't available in the browser, shows a friendly "⚠️ Biometric login unavailable on this device" tile instead of a broken-looking button. Desktop view is unchanged — the tile is display:none outside the mobile media query, so desktop users still find biometrics in the existing Settings → Account → Biometric Login card.
📌 Scope / files touched
  • ilearn-admin.jscreateM365CalendarEvent detects silent Teams strip, stamps booking with teamsDiagnostic marker, writes explicit audit/m365 log entries; renderBookings renders the "⚠️ Teams unavailable" warning pill for silently-stripped bookings; _PORTAL_BUILD → v5.61.
  • ilearnhcc-admin-v2.html — Test Teams Capability panel inserted into M365 settings card; new <style id="v561-css"> mobile polish block (tables, modals, pages, inputs); new <script id="v561-js"> containing testTeamsCapability() handler + mobile biometrics tile injector (wraps nav('settings')); v5.61 release-notes card; title/sidebar/readme version bumps.
  • ilearn-db.phpPORTAL_BUILD → v5.61. No handler changes required (all fixes are client-side).
⚠️ Validation path
  1. Upload all 11 files → purge Cloudflare → hard-reload.
  2. Title bar reads v5.67 · Apr 2026; db_ping returns build: 'v5.61'.
  3. Teams probe: Settings → Microsoft 365 → click "🎥 Run Probe". Current mailbox (info@ilearnhcc.com, Exchange Essentials only) should show "❌ No Teams license on this mailbox" with the remediation steps inline. After assigning a Teams-capable license + reconnecting M365, re-run — should show "✅ Teams meeting creation WORKS".
  4. Silent-strip detection: with the current no-Teams-license state, approve a booking via the 🎥 button. Check activity log — should see "⚠️ Teams meeting requested but Graph silently stripped it — info@ilearnhcc.com likely lacks a Teams license". Bookings table should show the amber "⚠️ Teams unavailable" pill on that booking; tapping it should scroll to the Teams probe.
  5. Mobile tables: on a phone (or Chrome DevTools → Toggle Device Toolbar → iPhone 14), open Contacts / Campaigns / Subscribers / Social. Tables should scroll horizontally inside their card — first column (Name/Subject/etc.) stays visible as sticky while you scroll right. No page-level horizontal overflow. No content escaping the viewport.
  6. Mobile modals: open any modal (Add Lead, Schedule Meeting, Reschedule Booking) on mobile. Modal fits viewport width with ~10px margin each side. Close button always visible. Can scroll through long forms vertically.
  7. Mobile Biometrics: on mobile, go to Settings. A purple-gradient "🔒 Enable Biometric Login" tile should appear at the TOP (above all other settings cards). Desktop: no visible tile (injected but display:none). On a browser without WebAuthn (e.g. older Chrome), tile shows the amber "unavailable on this device" state.
  8. iOS zoom suppression: on iPhone, tap into any text/email/search input. Viewport should NOT zoom in — previously did because inputs inherited font-size:14px.
ℹ️ Rollback: re-deploy iLearnHCC_v5_60.zip (last validated deployed build).
Known: the current info@ilearnhcc.com mailbox is on EXCHANGE_S_ESSENTIALS (no Teams plan) — v5.61 correctly diagnoses this and shows the warning, but doesn't fix the underlying license gap. That requires assigning a Teams-capable license in the Microsoft 365 Admin Center. Until then, every 🎥-approved booking will show the "⚠️ Teams unavailable" pill.
🛟 v5.60 — Duplicate Confirmation Email Fix + Teams Link Embedded in Calendar Invite
  • 📧 Duplicate "Your iLearn booking is confirmed" emails — root-caused and fixed. Live production email_log showed two identical confirmation emails for booking id 1776797994732 (Anthony Hosein, Apr 28 14:00) at 03:00:55 and 03:01:09 — exactly 14.3 seconds apart, matching the 15-second fallback window. Root cause: the idempotency guard booking._confirmationEmailSent was being wiped by autoLoadFromServer between the onComplete email send and the fallback timer fire. _sendBookingConfirmationEmail sent the email via createM365CalendarEvent's onComplete callback (~5 s after Accept), stamped the local flag, but publishToServer wasn't re-fired. autoLoadFromServer's 3-second heartbeat then pulled fresh server data and overwrote the local booking record — wiping the flag. When the 15-second fallback fired at t=15 s, the flag was gone, so it happily sent a second confirmation. Fix: new localStorage-backed sent-IDs set (il_v560_confirm_sent_ids) that server sync never touches. _sendBookingConfirmationEmail now (a) marks-before-send (so a mid-send browser crash still suppresses retry), (b) gates on the localStorage set as authoritative, (c) backward-compat backfills the set when it sees the legacy flag. The 15-s fallback timer also checks the localStorage set first. Legacy booking._confirmationEmailSent kept for compat and also now triggers publishToServer(true) immediately after stamping, so other admin instances see the flag via sync.
  • 🔒 "Re-accept" dup prevented. Second dup path: admin clicks the ✅ magic-link in the admin notification email (PHP booking_token_act path sends "✅ Your booking is confirmed — iLearn Home Child Care" and sets status to Accepted, but does not create a calendar event or Teams meeting). Admin then opens the portal, sees the booking as Accepted, and — understandably confused about the missing calendar invite — clicks ✓ or 🎥 again. The JS setBookingStatus re-ran the entire Graph + email flow, firing a second confirmation ("Your iLearn booking is confirmed — date time"). v5.60 adds a pre-flight check: if status is already Accepted AND the sent-IDs set (or legacy flag) shows we've emailed already, show the toast "ℹ️ Booking already accepted — confirmation email already sent." and skip the re-run. Decline of an already-accepted booking is still allowed (legitimate state change).
  • 🎥 Teams join URL now explicitly embedded in the calendar invite body. Previously the Graph POST set isOnlineMeeting: true + onlineMeetingProvider: 'teamsForBusiness' and relied on Outlook's native Teams block being preserved through the ICS stream to the attendee. Some mail clients (Gmail, Apple Mail, third-party calendar apps) strip the native Teams block, leaving attendees with a calendar event that says nothing about how to join — which matches Safia's report "not seeing the calendar invite with a Teams meeting link added." v5.60 adds a second PATCH right after the initial event POST: once Graph returns the onlineMeeting.joinUrl, the event body is patched with an explicit HTML block containing a prominent "Click here to join the meeting" button and the raw join URL in plain text. Now regardless of which calendar client the attendee uses, the URL is recoverable from the event body text. Fire-and-forget PATCH — failure is logged but doesn't affect the overall flow since the native block is still present from the initial POST.
  • 📇 eventId now always saved on Graph success. Previously booking.eventId was only persisted inside the if (wantsTeams && onlineMeeting.joinUrl) branch — so every plain ✓-approved booking (no Teams) had a null eventId, which silently broke reschedule-after-accept for those bookings (updateCalendarEvent at HTML line 20020 skips early when !booking.eventId). v5.60 moves the eventId save out of the Teams-only conditional so every successful Graph event creation stamps it, regardless of Teams. Rescheduling a non-Teams booking now correctly PATCHes the calendar event.
  • 📝 Improved activity-log verbosity for Graph outcomes. Logged action for a Teams-requested booking now distinguishes between "Calendar event created (with Teams link)" (Teams URL was returned by Graph) vs "Calendar event created (Teams requested — link pending)" (Teams fallback/retry path). Helps forensically trace which flow ran for any given booking when looking at audit history.
📌 Scope / files touched
  • ilearn-admin.js — new _v560WasConfirmationEmailSent / _v560MarkConfirmationEmailSent helpers (localStorage il_v560_confirm_sent_ids set); _sendBookingConfirmationEmail marks-before-send and gates on the new set; setBookingStatus adds re-accept guard with friendly toast; createM365CalendarEvent reworked: event body now carries {{TEAMS_BLOCK}} placeholder → substituted via second PATCH when Teams URL returns → resolves to empty string for non-Teams bookings; eventId save moved outside Teams conditional; audit-log text distinguishes Teams outcomes; _PORTAL_BUILD → v5.60.
  • ilearn-db.phpPORTAL_BUILD → v5.60. No handler changes required (all fixes are client-side).
  • ilearnhcc-admin-v2.html — v5.60 release-notes card; title/sidebar/readme version bumps.
⚠️ Validation path
  1. Upload all 11 files → confirm .htaccess has the leading dot → purge Cloudflare → hard-reload.
  2. Title bar reads v5.67 · Apr 2026; db_ping returns build: 'v5.60'.
  3. Dup fix: book a test meeting on the public website → click ✓ to accept in admin → wait 20 s. Email history should show exactly one "Your iLearn booking is confirmed" entry (not two). Check localStorage il_v560_confirm_sent_ids — should contain the booking ID.
  4. Re-accept guard: on the already-accepted booking, click ✓ again. Toast reads "ℹ️ Booking already accepted — confirmation email already sent." — zero new emails fire.
  5. Teams URL in invite: book another test meeting, click 🎥 (not ✓) to accept with Teams. Open the resulting calendar invite in Gmail or Apple Mail (or forward yourself from Outlook) — the event body should contain a purple "Click here to join the meeting" block with the raw Teams URL in plain text inside the body, not just in Outlook's native Teams panel.
  6. eventId coverage: inspect any v5.60-era booking in DevTools after Accept — eventId should be set regardless of whether ✓ or 🎥 was used. Reschedule a ✓-accepted (non-Teams) booking — the Outlook event should move to the new time.
ℹ️ Rollback: re-deploy iLearnHCC_v5_59.zip (the last validated deployed build).
Known deferrals: (1) The PHP magic-link ✅ approve path still doesn't create a calendar event or Teams meeting — it only sets status to Accepted and sends a plain confirmation email. Safia should use the portal ✓ / 🎥 buttons for full flow. Making the magic-link path create events too is a sensible v5.61 follow-up but requires adding Graph API credentials to the PHP side (currently JS-only). (2) The existing createM365CalendarEvent skip-with-no-Teams error logic rewrites a 4xx-teams failure as a teams:false retry. If the Azure app registration truly lacks OnlineMeetings.ReadWrite, every 🎥 click fails-then-retries — the event gets created but without Teams. Check m365_activity log for "Teams meeting rejected by Graph" entries; if present, the Azure admin needs to grant the permission. v5.60 doesn't change this behaviour.
🏷️ v5.59 — Provider recordNo Backfill + Audit Trail recordNo + Subscriber Duplicate Message + Booking Availability Check
  • 🏷️ Provider records get record numbers immediately — fixed at three layers. Live QA on production confirmed 13 of 13 providers had no recordNo even though all 34 provider-type leads were correctly stamped. Three-part fix: (1) Sweep — added 'providers' to _v546SweepMissing's key list (ilearn-admin.js:39699) so the late-arrival sweep now covers providers alongside leads/contacts/suppliers/invoices/POs. (2) Save hook — added _v546WrapSaver('saveProviders', 'providers') so every provider-save path (admin-portal manual add, bulk import, lead-conversion) now stamps recordNo on write. (3) autoLoadFromServer hook — wrapped autoLoadFromServer so the sweep re-runs after every server-sync pull, not just once on DOMContentLoaded. This was the root of Safia's "when a provider shows up under leads its not assigning the record id right away" report: new provider applications arriving via server sync 4+ seconds after page load missed the single-shot sweep and had to wait for the next page reload. (4) One-time backfill — gated by il_v559_recordno_providers_migrated, stamps every existing provider once per device, inheriting from the linked lead's recordNo via convertedFromLead when one exists. Idempotent and self-disabling after first run.
  • 📋 Audit reports now include Record #. logAct(icon, msg) and logAudit(icon, category, details, user, role) both now accept an optional trailing recordNo argument. When not passed, they auto-extract a REC-YYYY-NNNN token from the message/details if one's embedded (common for save-triggered audit entries). Every audit entry now carries a recordNo field — empty string when unknown, which is fine for system-level events like logins. The exportAudit CSV gains a Record # column (header is now Time, User, Role, Category, Record #, Details), and archiveAudit exports the same expanded format. Existing audit entries without recordNo export with empty string — zero data loss, backward compatible.
  • ✉️ Duplicate subscribers see "You're already subscribed" on the website. The server has been returning {added:false, message:'Already subscribed.'} for dupes since v4.23c, but the website was ignoring the response — every submission showed "🦋 Subscribed! Welcome to the iLearn community" regardless. Fix: pushSubscriberToServer now returns a Promise resolving to the parsed response. nlSub awaits it and shows "✉️ You're already subscribed to our newsletter!" for dupes. Also gates the public_notify admin-alert fetch on added===true so duplicate submissions no longer spam the admin notification feed.
  • 📅 Website bookings now check availability + block double-bookings + respect business hours. Four parts: (a) new PHP endpoint public_get_booked_slots returns Accepted/Pending slots for a date (or date range) as {date: [time, time, ...]} — public, rate-limited 60/hour per IP, returns ONLY dates/times with zero PII. (b) Website calls it the moment a date is clicked on the booking calendar; time-slot buttons for taken slots get line-through + "not-allowed" cursor + "⛔ That time is already booked" toast on click. Cache flushes on each openBooking() so a returning visitor never sees stale availability. (c) confirmBooking does a final freshness re-check right before submit — if the slot became taken during the user's fill-in time (e.g. while typing their email), bounces them back to the time picker with a clear message. (d) Server-side double-book guard inside public_append_booking's atomic_db_update: scans existing bookings before appending, returns HTTP 409 Conflict with {error:'slot_taken'} if another booker snuck in during the ~60s client cache window. Website handles the 409 by invalidating its cache and sending the user back to the time picker. Business-hours config now read from il_cal_settings.businessHours if the admin has configured it (start/end/lunchStart/lunchEnd); falls back to the existing 9-11am + 1-3pm pattern when not configured. Weekends and admin-blocked dates still blocked at the calendar layer, so nothing outside business days can be picked.
📌 Scope / files touched
  • ilearn-admin.js — added 'providers' to _v546SweepMissing keys + saveProviders save wrapper + autoLoadFromServer post-sync sweep hook + one-time provider recordNo backfill (gated by il_v559_recordno_providers_migrated); logAct + logAudit accept optional recordNo + auto-extract REC-YYYY-NNNN; exportAudit + archiveAudit CSVs gain Record # column; _PORTAL_BUILD → v5.59.
  • ilearn-db.php — new public_get_booked_slots public endpoint (rate-limited 60/hr, zero PII); server-side double-book guard in public_append_booking's atomic update with HTTP 409 response; PORTAL_BUILD → v5.59.
  • ilearnhcc-website.htmlpushSubscriberToServer promisified; nlSub awaits and toasts the right message; openBooking flushes slot cache; selectDate fetches taken slots and disables taken time-slot buttons; confirmBooking does final availability re-check + 409 handling; business-hours config helper reads il_cal_settings.businessHours.
  • ilearnhcc-admin-v2.html — v5.59 release-notes card; title/sidebar/readme version bumps.
⚠️ Validation path
  1. Upload all 11 files → confirm .htaccess has the leading dot → purge Cloudflare → hard-reload.
  2. Title bar reads v5.67 · Apr 2026; db_ping returns build: 'v5.59'.
  3. Provider recordNo: open Providers page — all 13 existing providers now show a Record # pill. Console should log [iLearn v5.59] providers recordNo backfill complete once. Add a new test provider manually → its recordNo pill appears immediately on save, not on next reload.
  4. Audit recordNo: create/edit any contact or lead → open Audit Trail → Export CSV. Open the CSV — the new "Record #" column should be present with values for contact/lead/provider/supplier actions and empty strings for system events.
  5. Subscriber dup: on the public website, subscribe with an email that's already a subscriber — toast reads "✉️ You're already subscribed to our newsletter!" instead of the welcome toast. Admin 🔔 Alerts pill does NOT tick up. Subscribe with a genuinely new email — standard welcome toast + admin alert fires.
  6. Booking availability: accept a booking in the admin portal for tomorrow at 10am. Open the public website booking calendar in a fresh/incognito tab → pick tomorrow's date → the 10:00 AM slot shows greyed-out + line-through. Clicking it shows "⛔ That time is already booked" toast. Try submitting a booking for that slot by forcing the UI → server returns 409 and you're bounced back to the time picker.
ℹ️ Rollback: re-deploy iLearnHCC_v5_58.zip (the last validated deployed build).
Known deferrals (scope control): (1) On-screen Audit Trail table doesn't yet have a visible Record # column — stored data already carries it and CSV export includes it. Adding the UI column touches the thead/sort-key mapping/row-render; deferred to keep this release tight. (2) The Calendar Settings page doesn't yet expose a UI for editing businessHours directly — it reads the key if present, but admins currently have to write to il_cal_settings via the existing settings JSON path. A dedicated Business Hours editor card is a natural v5.60 item.
⚠️ Consolidation note: Server was on v5.55 when v5.58 was prepared — v5.56 and v5.57 were never deployed to production. All v5.56 and v5.57 fixes are bundled into v5.58. After deploying v5.58, v5.56 and v5.57 become orphan/reference-only versions. Rollback target is v5.55.
🆕 New in v5.58
  • 📅 Booking reschedule now includes duration + email + calendar stay in sync. Three separate bugs were resolved together. (1) updateCalendarEvent (fires on reschedule) had a hardcoded 60*60*1000 for the Outlook/Google event end time — so every reschedule snapped the event to a 1-hour slot regardless of the original duration. (2) createM365CalendarEvent (fires on initial Accept) had the same hardcoded 60 min, silently creating 1-hour Teams events even for 30-minute bookings. (3) The reschedule modal had no duration field at all, so admins couldn't change a booking's length even when explicitly needed. v5.58 adds a Duration <select> (15/30/45/60/90/120 min, default 30) to the reschedule modal, persists it as booking.duration, includes the new duration in the branded reschedule email (shows "Previous: Apr 20 at 9:00 (60 min)" → "New: Apr 21 at 10:00 (30 min)" in the email table), writes it into the reschedHistory audit trail, and swaps both hardcoded 60*60*1000 sites for (booking.duration || 30) * 60 * 1000.
  • New bookings default to 30 minutes — admin can extend. Website booking calendar at ilearnhcc-website.html was defaulting to 60 min and reading any duration from il_cal_settings without a cap. v5.58 changes the default to 30 min and caps the website-side parsed value at 30 min (website bookings cannot exceed 30; preserves the 30-minute policy). Admin-side meeting form default flipped from "60 min (selected)" to "30 min (selected)". Admin can still pick any duration 15-120 min when creating meetings directly, and can extend any booking up to 240 min via the reschedule modal.
  • ✉️ Email template branding auto-wrap. Previously every caller had to remember to wrap bodies via _styledBrandHTML(subj, body) before passing to sendPortalEmail/sendEmailAny. Any caller that forgot sent an unbranded email — different font, no logo, no header/footer, inconsistent look. v5.58 adds an auto-brand wrapper to both send functions that detects the canonical branded signature (600-wide table + 16 px border radius) and wraps automatically if missing. Callers that already wrap are safely detected and passed through unchanged. Logo is picked up from ag.template_header_imgag.logo_datalocalStorage.il_agency_logo, in that order — matches existing priority. Result: every email going out — reschedule, booking confirmation, welcome, password-reset, campaign, compliance, etc. — has the same violet→fuchsia gradient header with logo, consistent font/colours, and the "Ministry of Education · Licensed Agency · HCCAO Member" footer.
  • 🔍 Global search now finds new features + record numbers. Extended _doGlobalSearch with: (a) a curated feature-action catalog — typing "reschedule", "archived", "reset call", "restore chat", "teams meeting", "voice memo", "archive settings", "release notes", "30 min", or "email template/branding" now returns a clickable result that navigates to the relevant screen or invokes the helper (e.g. _v557ResetCallState() for "reset call"); (b) record-number search — typing #12345, RN-12345, or just 12345 now matches recordNo across contacts, leads, providers, and suppliers. Fills the "search by record number — deferred" gap noted in the v5.50 release notes.
📦 Bundled from v5.57
  • 📦 Archived Leads kanban filter fix — kanban cards now carry data-lead-id attribute so both v5.30 and v5.32 filter paths actually work. Archived Leads toggle button reverts correctly.
  • 📞 "Other end already on the line" stuck-state fix — 45 s connecting watchdog + oniceconnectionstatechange listener + pagehide hangup + global window._v557ResetCallState() escape hatch prevent _state from getting stuck at 'connecting' and auto-declining every subsequent offer as busy.
📦 Bundled from v5.56
  • 📥 Chat restore banner is one-time + actually worksil_v556_restore_decision localStorage flag plus new chat_file_write server action that writes to both ilearn-chat.json AND main DB (chat_poll reads the file first, so main-DB-only writes were invisible in v5.55).
  • 🎥 Teams link in confirmation emails — race fixed — fallback timeout in setBookingStatus and meetings flow bumped from 2.5 s to 15 s so Graph has time to return the real Teams join URL before the idempotency guard locks.
📌 Scope / Files touched
  • ilearn-db.phpchat_file_repair + chat_file_write actions (bundled from v5.55/v5.56), chat_send signal age-out + main-DB-mirror filter, chat_poll stale-signal filter, PORTAL_BUILD → v5.58.
  • ilearnhcc-admin-v2.html — v529 script block: timestamp-staleness check, localStorage-persisted dedup, chat_file_repair IIFE + one-time restore banner with new decision flag, oniceconnectionstatechange listener, _v557ConnectingWatchdog, _v557ResetCallState, pagehide hangup. Voice-memo preview bar DOM. Reschedule modal: new Duration <select> + duration wiring + new-duration in email + calendar event respects duration. Meeting form default 60 → 30. v5.58 release-notes card. Title/sidebar/readme version bumps.
  • ilearn-admin.js — v5.52 voice memo split into _stopToPreview + _sendPreview. renderLeadKanban card gains data-lead-id. setBookingStatus fallback 2500 → 15000 ms (+ same for meetings flow). createM365CalendarEvent respects booking.duration. Auto-brand wrappers in sendPortalEmail + sendEmailAny. Global search feature catalog + record-no lookup. _PORTAL_BUILD → v5.58.
  • ilearnhcc-website.html — booking duration default 60 → 30, cap at 30 min.
⚠️ Validation path
  1. Upload all 11 files → confirm .htaccess has the leading dot → purge Cloudflare → hard-reload.
  2. Title bar shows v5.67 · Apr 2026; db_ping returns build: 'v5.58'.
  3. Restore banner (one-time): banner appears once. Click Yes, restore chat → toast "Restored N messages. Refresh to see them in Team Chat." Refresh → banner stays gone.
  4. Leads archived view: Leads → click 📦 Archived Leads → kanban filters to archived-only; click again → returns to all active leads.
  5. Booking reschedule: Bookings → pick a booking → reschedule modal now has Date + Time + Duration select (defaults to current duration or 30). Change to 45 min, save. Attendee email shows "Previous: (30 min)" → "New: ... (45 min)". If booking has a Teams/Google event, the event in Outlook/Calendar now reflects the new duration.
  6. 30-min default: open website booking calendar → inspect payload — duration field is 30. Admin Meetings form → Duration dropdown shows "30 min" selected by default.
  7. Email branding: send any transactional email (welcome, reschedule, password reset). Inbox should show the violet→fuchsia gradient header with logo, consistent footer. No "plain text"-looking emails.
  8. Global search: type "reschedule" / "archived" / "reset call" / "voice memo" → clickable results appear. Type a record-number like #1234 or 1234 → matches any contact/lead/provider/supplier with that recordNo.
  9. Calls: two-tab test. If a call ever shows bogus "peer in another call," run _v557ResetCallState() in DevTools.
ℹ️ Rollback: re-deploy iLearnHCC_v5_55.zip (last validated production build). v5.56 and v5.57 ZIPs are orphaned and should not be deployed.
  • 📦 "Archived Leads button doesn't revert / no way to go back" — root-caused and fixed. Live-QA found that document.querySelectorAll('#leadKanban [data-lead-id]') returned zero matches even with 96 leads in the database. Cause: renderLeadKanban in ilearn-admin.js (~line 25683) wrote kanban cards with onclick="editLead(id)" but NO data-lead-id attribute. Both the v5.30 (_applyArchivedOnlyFilter) and v5.32 (applyArchivedOnlyFilter) archived-view filters target [data-lead-id] selectors, so the filter effectively no-op'd on the kanban board — active leads stayed visible in archived view, and when the user clicked "📦 Archived View — click to exit" to toggle back, nothing appeared to change because the kanban had never actually changed when they entered archived view. v5.57 adds data-lead-id="'+l.id+'" to the card root div. Both existing filter code paths now work correctly without further changes.
  • 📞 "Video or call shows the other end is already on the line but that is not the case" — root-caused and fixed. The callee-side WebRTC _state could get stuck at 'connecting' after a call attempt where ICE negotiation silently stalled (common on symmetric NAT / cellular networks without TURN configured). Neither onconnectionstatechange nor oniceconnectionstatechange fires in that limbo on some browsers, so _state never returns to 'idle'. Every subsequent incoming offer then hit the if (_state !== 'idle') { _sendSignal('decline', { reason: 'busy' }); } branch in _handleIncomingOffer — caller sees "📴 Call declined — peer in another call" / "already on the line" even though callee is, in fact, NOT in another call.
    v5.57 defense-in-depth fix in the v529-voicevideo-js block:
    1. New oniceconnectionstatechange listener. Some browsers surface ICE failures via this event but NOT via onconnectionstatechange. We listen on both and reset on 'failed'.
    2. 45-second _v557ConnectingWatchdog starts on every transition into 'connecting' (both caller-side when peer ACCEPTED and callee-side when user clicks ✓ Accept). If we haven't reached 'in-call' by 45 s, the watchdog force-fires _resetCall and a user-visible "⚠️ Call connection timed out" toast. Cleared on successful transition to 'in-call' and in _resetCall.
    3. pagehide listener sends a best-effort hangup signal when the tab is closed mid-call, so the peer's _pc transitions to 'disconnected' (which DOES reset them) instead of being orphaned in 'connecting'.
    4. Global escape hatch window._v557ResetCallState() — callable from DevTools console to force _state back to 'idle' without a page reload. If a user reports stuck "busy" state in the field, they can run this to unstick calling immediately.
  • 📌 Scope. Two files touched:
    • ilearn-admin.jsrenderLeadKanban card div gains data-lead-id attribute (1 line); _PORTAL_BUILD bumped
    • ilearnhcc-admin-v2.html — v529 script block: oniceconnectionstatechange handler, _v557StartConnectingWatchdog/_v557ClearConnectingWatchdog, watchdog started in 2 places (caller _handleAnswer and callee _v529AcceptIncoming), cleared in _resetCall and on successful connection, window._v557ResetCallState escape hatch, pagehide listener with best-effort hangup
    Zero PHP changes. Zero database schema changes. PORTAL_BUILD bumped to v5.57 in both JS and PHP. Rollback = revert the edits + build constants.
  • ⚠️ Validation: deploy → purge Cloudflare → hard-reload. Leads/Archived: Navigate to Leads. Click 📦 Archived Leads — the kanban should now filter down to only the 3 archived leads (badge says "ARCHIVED VIEW"). Click the button again (now reads "📦 Archived View — click to exit") — you should return to all 93 active leads with the banner/badge gone. Test the ☰ Table view the same way. Calls: Open DevTools console. Make a test call between two tabs; confirm the connection completes and console logs show no watchdog warnings. If you ever see "📴 Call declined — peer in another call" when the peer is definitely not in a call, run _v557ResetCallState() in DevTools — this is the escape hatch. On a slow-ICE network you may see the new "⚠️ Call connection timed out" toast after 45 s of stuck 'connecting'; that's the watchdog firing correctly and the system self-healing.
  • 🧹 Known cleanup deferred: The archived-view system is still three-layered (v5.30 banner, v5.32 badge, v5.44 button-state sync). With the data-lead-id fix all three now function as intended, but a proper consolidation into one authoritative archived-view controller is out of scope for v5.57 and flagged for a future release.
Still carried forward from v5.56
  • 📥 Restore decision flag (one-time semantics, Yes/Dismiss both stamp it)
  • 💾 chat_file_write action writes to both ilearn-chat.json and main DB so restores are actually visible
  • 🎥 Teams-link race fixed (15 s fallback vs the old 2.5 s that was losing to Graph)
🔧 v5.57 — Leads Kanban Fix, Call-State Busy-Loop Fix, Watchdog + Escape Hatch
  • 📦 Archived Leads button appeared to do nothing — root-caused and fixed. Two separate archived-view filters exist (v5.30's _applyArchivedOnlyFilter at HTML line ~14498, and v5.32's applyArchivedOnlyFilter at line ~15853). Both call board.querySelectorAll('[data-lead-id]') to find kanban cards that should be hidden when not archived. Problem: the kanban card HTML generator in renderLeadKanban() (ilearn-admin.js line 25683) never wrote that attribute — it only added onclick="editLead(id)". Result: querySelectorAll returned an empty NodeList every time, so the filter silently no-op'd. Entering archived view did nothing visible (all 93 active cards still rendered), and the "← Back to Active Leads" exit path looked broken because there was nothing visibly different to "go back from." Fix: one-line change in renderLeadKanban — the outer card div now has data-lead-id="<id>". Both v5.30 and v5.32 filters now target the right nodes.
  • 📞 "Other end shows already on the line" when they aren't — root-caused and fixed. Trace: user clicks 📞 → their _stateoutgoing-ringing; peer accepts → both sides go to connecting. In connecting, _pc.onconnectionstatechange or _pc.oniceconnectionstatechange is supposed to move us forward to in-call (on success) or idle (on failure via _resetCall). On some NAT/firewall combos, neither event ever fires when ICE silently stalls — _pc sits in connecting limbo indefinitely, so _state stays stuck at connecting. Every subsequent incoming offer then hits the if (_state !== 'idle') branch in _handleIncomingOffer and auto-declines with reason: 'busy'. The caller sees "peer in another call" even though the callee isn't. v5.57 adds FOUR defenses: (1) _pc.oniceconnectionstatechange listener — catches ICE failures that connectionstatechange misses. (2) _v557ConnectingWatchdog — 45-second hard cap on any connecting transition. If we haven't reached in-call by then, force _resetCall and toast "Call connection timed out." (3) pagehide listener — best-effort hangup signal when tab closes so the peer doesn't think we're still in-call. (4) window._v557ResetCallState() — global escape hatch callable from DevTools Console to force-reset without a page reload. If you ever report "calls all say busy," open console and run that function.
  • 📌 Scope. Two files touched:
    • ilearn-admin.js — one-line addition to renderLeadKanban card HTML: data-lead-id="'+l.id+'"; _PORTAL_BUILD bumped
    • ilearnhcc-admin-v2.html — v5.29 script block: new _pc.oniceconnectionstatechange listener; _v557ConnectingWatchdog timer started on both connecting transitions (caller-received-answer + callee-accepted); watchdog cleared in _resetCall, onconnectionstatechange, and oniceconnectionstatechange on success; pagehide event listener; window._v557ResetCallState escape hatch; PORTAL_BUILD bumped
    Zero PHP changes (server is still v5.56's chat_file_write + v5.55's chat_file_repair). Zero database schema changes. PORTAL_BUILD bumped to v5.57 in both JS and PHP. Rollback = revert the edits + build constants.
  • ⚠️ Validation: deploy → purge Cloudflare → hard-reload. Leads fix: navigate to Leads page, click 📦 Archived Leads — the 3 archived cards should now be the only ones visible, the "📦 ARCHIVED VIEW" badge appears at the top of the page, the button label flips to "📦 Archived View — click to exit." Click the button (or the ← Back button on the badge) — you should return to the full active view with all 93 cards visible. Call fix: two-tab call test — caller clicks 📞, callee accepts. If the connection stalls for more than 45 seconds in connecting, you'll now see a "⚠️ Call connection timed out" toast and both sides return to idle. If a call DOES get stuck, open DevTools Console and run _v557ResetCallState() — you'll see the toast "📴 Call state manually reset — you can now make/receive calls again" and subsequent calls will work. Watch for: console logs like [iLearn call v5.57] CONNECTING watchdog fired — call stuck, resetting after a stuck call, or [iLearn call v5.57] ICE failed — resetting on NAT-blocked networks.
  • 🗂 Follow-up candidate for v5.58 (not in this release): The archived-view architecture has two parallel controllers (v5.30's banner-based system and v5.32's badge-based system) that both hook into render events and both sync state separately. Works now that data-lead-id is present, but the dual-controller design is fragile. A proper consolidation into one authoritative archived-view module is recommended as a follow-up cleanup release.
Still carried forward from v5.56
  • 📥 Chat restore via chat_file_write (writes to both ilearn-chat.json + main DB)
  • 🎥 15 s fallback for Teams-meeting confirmation emails (Graph can take 3-5 s)
  • 👻 Phantom video-call popup killed (timestamp staleness + persistent dedup, v5.55)
🔧 v5.56 — v5.55 Hotfix: Restore Actually Works, Teams Link in Confirmation Emails
  • 📥 Restore banner kept reappearing — fixed. v5.55's gate was just "show if chat has < 5 real messages." With no explicit one-time flag, every page refresh re-evaluated the gate and re-showed the banner. v5.56 adds localStorage.il_v556_restore_decision — stamped to "yes:<count>" on a successful restore, to "dismissed" on Dismiss. Banner returns early if the flag is set. On a failed restore the flag is deliberately NOT stamped, so the user can retry on next load. You'll see the banner exactly ONE more time after deploying v5.56 — that's intentional, because the decision flag is brand-new and empty for every existing browser.
  • 💾 Restore didn't actually restore chat messages — fixed. Root cause: chat_poll reads ilearn-chat.json first and only falls back to the main DB's il_chat_messages if the file doesn't exist. v5.55's restore wrote to main DB only via replace_key, so the 165 restored messages NEVER reached clients — the file still held the old 4-message state and every page load pulled those 4 back into localStorage, which is why the banner kept reappearing too. v5.56 adds a new server action chat_file_write that accepts {messages:[...]} and overwrites BOTH ilearn-chat.json AND il_chat_messages in the main DB (with defensive _callSignal filtering and a 500-item cap to match chat_send's non-signal cap). The client restore now calls chat_file_write instead of replace_key, so the restore is visible to every client on the next poll.
  • 📎 Banner copy clarified. Old banner said "Restore 165 text messages" with no explanation of what was NOT included, leading to your "notifications aren't coming back" confusion. v5.56 banner now reads: "Restore chat messages from April 13 backup? · 165 text messages will be restored to your chat history. · Notifications cannot be restored — they are stored per-browser and have no backend backup." So you know up front that il_notifications is a localStorage-only store and no amount of server backup magic will bring it back.
  • 🎥 Teams link missing from booking confirmation emails — fixed. Race condition in setBookingStatus: a 2.5-second setTimeout fallback fired _sendBookingConfirmationEmail with teamsJoinUrl:null BEFORE Microsoft Graph finished creating the Teams meeting (Graph commonly takes 3–5 s). The fallback stamped _confirmationEmailSent:true as an idempotency guard, so when Graph actually completed and the real email with the Teams link tried to fire, the guard blocked it — attendee received a confirmation with no Teams link. v5.56 bumps the fallback from 2500 ms to 15000 ms. This is safe because createM365CalendarEvent ALWAYS fires onComplete synchronously in every code path including the no-M365-configured case, so a legit no-M365 booking still emails in ~0 ms via the onComplete path; the 15 s timer just never finds anything to do. Same two-line fix applied to the meetings flow (_createM365MeetingEvent caller) which had an identical race.
  • 📌 Scope. Three files touched:
    • ilearn-db.php — new chat_file_write action that writes to both ilearn-chat.json AND il_chat_messages; PORTAL_BUILD bumped
    • ilearnhcc-admin-v2.html — IIFE's doRestore switched from replace_key to chat_file_write; maybeOfferRestore gated on new il_v556_restore_decision flag; banner DOM rewritten with clearer copy explaining notifications can't be restored; Yes/Dismiss handlers stamp the decision flag
    • ilearn-admin.jssetBookingStatus fallback bumped 2500 → 15000 ms; same bump in the meetings-flow fallback; _PORTAL_BUILD bumped
    Zero database schema changes. PORTAL_BUILD bumped to v5.56 in both JS and PHP. Rollback = revert the edits + build constants.
  • ⚠️ Validation: deploy → purge Cloudflare → hard-reload. You WILL see the restore banner once more — that's intentional. Click Yes, restore chat. Banner should change to "✅ Restored 168 messages. Refresh to see them in Team Chat." Refresh. Open Team Chat: you should now see the 165 Apr 13 messages plus the handful of post-Apr-13 voice memos, sorted chronologically. Refresh again — the banner should NOT reappear. Book a test meeting with the 🎥 Teams option, accept it from the admin portal, check the attendee's inbox — the confirmation email should include the "🎥 Join Teams meeting" button with a working Teams URL (may take up to 15 s on slow Graph responses; the button appears once Graph confirms).
Still carried forward from v5.55
  • 👻 Phantom video-call popup killed (timestamp staleness guard + persistent dedup + server age-out)
  • 🧹 One-time chat-file scrub (removed ~400 stale call signals)
  • 🎤 Voice memo preview-then-send UX
🔔 v5.55 — Phantom Call Popup Killed, Voice-Memo Preview-Then-Send, Chat File Scrub
  • 👻 "Incoming video call from Safia Ali" that kept appearing out of nowhere — root-caused and fixed. v5.54 added an in-memory dedup map (_v554HandledSessions) to suppress replayed offer signals, but that map was wiped on every page refresh. Meanwhile, ilearn-chat.json kept call signals in a 400-item ring buffer — hours of retention in real use. On every fresh reload, appendChatMessages replayed historical offer signals through _handleIncomingOffer before dedup had warmed up. The screenshot you sent showed a real artifact from earlier test calls sitting in the chat file from ~54 minutes prior.
    v5.55 fixes this in four layers: (1) Client-side TIMESTAMP STALENESS GUARD — _handleIncomingOffer now rejects any offer whose msg.timestamp is older than 60 s; the caller's own 20 s outgoing-timeout fired long ago so no legitimate offer can be that late. (2) Client-side DEDUP PERSISTENCE — _v554HandledSessions now reads/writes to localStorage.il_v555_handled_call_sessions so decline/accept state survives refresh. (3) Server-side AGE-OUT — chat_send filters signals > 120 s old from ilearn-chat.json on every write; chat_poll filters them from every response. Call signals can no longer accumulate. (4) Server-side MAIN-DB MIRROR FIX — the non-signal write path used to mirror the WHOLE ring buffer (signals included) to il_chat_messages, which was why the main DB grew to 400 signals + 1 real message. Now only non-signal messages are mirrored; il_chat_messages will always contain only real text + voice memos.
  • 🧹 One-time chat-file scrub. New server action chat_file_repair reads ilearn-chat.json, strips every _callSignal entry regardless of age, rewrites the file, and syncs the cleaned array to il_chat_messages. The client fires this automatically once per browser on first load of v5.55 (gated by localStorage.il_v555_chat_signal_scrub); the toast reports how many signals were removed. In your case this will drop the live chat state from 400 messages down to the 1 real voice memo.
  • 🎤 Voice memo UX — record then preview then send (was: record then auto-send). Old flow: tap 🎤, record, tap 🎤 → immediate upload + send, no way to review or cancel a mistake. New flow: tap 🎤 to start, tap ⏹ to STOP (no send), a preview bubble appears inline in the composer area with ▶️ playback, ✕ cancel, and ➤ Send. Only ➤ Send actually uploads to /repo and calls chat_send. Cancel discards cleanly. If you tap 🎤 again while a preview is showing, the existing preview is discarded and a new recording starts.
  • 📥 Chat history restore from backup — opt-in toast. On first load of v5.55, after the signal scrub, the client checks whether il_chat_messages has fewer than 5 real text messages. If so, it offers a toast button "📥 Restore 165 text messages from April 13 backup?" — clicking it fetches backup-2026-04-13_215846.json via the existing restore_backup endpoint, merges the 165 real messages with any current voice memos (dedupes by id), and pushes via replace_key. Non-destructive default; requires one explicit click from you. Skip it and nothing changes.
  • ℹ️ Notifications — scope note. il_notifications is localStorage-only and has never been server-synced. Historical notifications cannot be recovered from server backups because they never existed there; current-browser notifications (2 items right now) are all that physically exists. Going-forward server-sync for il_notifications is out of scope for v5.55 to keep this release tight — flagging as a v5.56 candidate.
  • 📌 Scope. Three files touched:
    • ilearn-db.phpchat_send adds pre-append signal age-out (120 s) and main-DB-mirror signal-filter; chat_poll response-filter for stale signals; new chat_file_repair action; PORTAL_BUILD bumped
    • ilearnhcc-admin-v2.html — v5.29 script block: _handleIncomingOffer timestamp staleness check (_V555_STALE_OFFER_MS = 60 s); _v554HandledSessions persisted to localStorage.il_v555_handled_call_sessions; new on-load IIFE il_v555_chat_cleanup_and_restore that calls chat_file_repair and offers the opt-in restore toast
    • ilearn-admin.js — v5.52 voice-memo block refactored: _stopAndSend replaced by _stopToPreview + _sendPreview; inline preview bubble UI (▶️ ✕ ➤) injected into chat composer; window._v552ToggleVoiceRecord now handles start/stop/discard-preview states; _PORTAL_BUILD bumped
    Zero database schema changes. PORTAL_BUILD bumped to v5.55 in both JS and PHP. Rollback = revert the edits + build constants.
  • ⚠️ Validation: deploy → purge Cloudflare → hard-reload. Watch the console: [iLearn v5.55 scrub] removed N call signals from chat file should appear within ~3 s of load, where N will be around 400 on first load. Then the top-bar toast "📥 Restore 165 text messages from April 13 backup?" appears — click to restore or ignore to skip. Confirm: no phantom incoming call modal appears during or after the scrub. Open Team Chat: chat history shows (165 messages if you restored, or just the recent voice memo if you didn't). Tap 🎤 on the composer, record a short message, tap ⏹ — preview bubble appears WITHOUT auto-sending; tap ▶️ to verify playback, tap ✕ to cancel or ➤ to send. Make a real call between two tabs: caller clicks 📞, callee's modal appears, callee declines, refresh the callee tab — modal does NOT reappear (this was the core bug).
Still carried forward from v5.54
  • 💾 Voice memos stored as URL references (v5.54), not base64 embeds
  • 🚫 ESC key + 45 s auto-dismiss for stuck incoming modals (v5.54)
  • 📎 Clickable M365 email attachments (v5.54)
🛟 v5.54 — Chat History Protection, Stuck-Call Modal Fix, M365 Attachments Clickable
  • 💾 Chat history erasure — root-caused and fixed. v5.52's voice memo feature stored the base64 audio data DIRECTLY inside each chat message's _voiceMemo.data property. A 20-second webm/opus memo is ~250KB base64-encoded. Every voice memo sent was synced to il_chat_messages in the main database via save_db(). After several memos, il_chat_messages ballooned to multi-megabytes; the full DB hit PHP's memory limit during load/save; save_db() silently failed or truncated; the next autoLoadFromServer pulled a truncated/empty chat store; every user saw their chat history gone. v5.54 moves voice memos OUT of the chat message — the audio is first uploaded via upload_file to /repo/voicememo_NNN.webm, then only the URL + mime + duration travel in the chat message. Each chat message is now small (~200 bytes) regardless of audio length. One-time repair IIFE il_v554_voicememo_repair runs 5s after load: scans il_chat_messages, strips embedded _voiceMemo.data from every message, marks them as legacy (placeholder bubble still renders), and frees however many KB were wasted. Result: chat history stays durable, DB stays lean, the save_db failure loop is broken.
  • 🚫 Incoming call modal that wouldn't dismiss — fixed. Symptom: accepting or declining the call did nothing; the incoming modal kept re-appearing. Root cause: _handleIncomingOffer had no sessionId deduplication, so every poll cycle that replayed the same offer from ilearn-chat.json re-triggered the modal as if a new call was coming in. v5.54 tracks handled sessionIds for 120 seconds — if an offer for that session arrives again, it's silently ignored. Accept, decline, busy-auto-decline, and the new 45-second auto-dismiss all mark the session handled. Also added an escape hatch: pressing ESC forcibly dismisses any stuck incoming modal and sends a decline. Finally, if the ringtone has been going for 45 seconds straight with no user action, the modal auto-dismisses — prevents the "phantom ring" problem where a declined offer keeps re-polling and re-ringing.
  • 📎 Email attachments now clickable (M365). Microsoft 365 attachments rendered as non-clickable <span> pills — you could see the filename and size but had no way to open or save them. Only the IMAP path had clickable <a href="data:..."> links. v5.54 makes every M365 attachment pill a clickable <a onclick="_v554M365DownloadAttachment(…)">. Clicking fetches the full attachment via https://graph.microsoft.com/v1.0/users/{email}/messages/{msg}/attachments/{att} with the active access token, decodes contentBytes, wraps in a Blob, and triggers a browser download with the original filename. Header label now reads "📎 ATTACHMENTS (click to download)" so the affordance is visible. Shows "⏳ Downloading…" → "✓ Downloaded" on the clicked pill. Graceful failure toasts on expired session or Graph errors.
  • 📌 Scope. Two files touched:
    • ilearn-admin.js — voice memo send rewritten to use upload_file + URL reference; receive renderer reads url with data fallback for legacy messages; v5.54 repair IIFE for existing messages; M365 attachment pills + download handler appended
    • ilearnhcc-admin-v2.html — v5.29 script block: _v554HandledSessions dedup map, _v554MarkSessionHandled/_v554IsSessionHandled helpers, _v554ForceDismissIncoming escape hatch, ESC keydown listener, 45s ringtone-auto-dismiss wrap around _startRingtone/_stopRingtone
    Zero PHP changes. Zero database schema changes. PORTAL_BUILD bumped to v5.54 in both JS and PHP. Rollback = revert the four edits + build constants.
  • ⚠️ Validation: deploy → hard-reload both caller + callee tabs. Chat history repair runs automatically 5s after load — console shows [iLearn v5.54 repair] stripped embedded voice memo data from N message(s). Freed ~X KB. if there was anything to strip. New voice memos: tap 🎤, say hello, tap again — sent as a URL-referenced message; DB stays small. Incoming call: caller clicks 📞, callee sees incoming modal, callee clicks ✕ or presses ESC — modal dismisses cleanly and does NOT reappear. Email client M365 mode: open a message with attachments → pills are clickable and labelled "click to download" → click one → file downloads to your Downloads folder.
Still carried forward from v5.53
  • 🛠 Server-side chat_send hardening — call signals skip load_db/save_db/FCM; full try/catch with error_log; lock contention returns 503 not 500
  • 📞 v5.51 _sendSignal fix (signals actually leave the browser using getSrvCfg)
  • 🔔 Two-sided ringer/ringback/chimes (v5.51), AudioContext auto-unlock
  • ⏱ Outgoing call 20-second timeout (v5.52), contacts flashing fix (v5.52)
  • 🔍 Search by Record ID + modal badge + photo reconcile (v5.50)
  • 📇 Record ID column + sticky table headers (v5.48/5.49)
🛠 v5.53 — HTTP 500 on Call Signals Fixed (Server-Side Defensive Rewrite of chat_send)
  • 🎯 Your HTTP 500 toast on call-button click was my own v5.51 error-surfacing code doing its job. After v5.52 shipped the client-side _sendSignal fix (swapping the undefined getCfg() for getSrvCfg()), signals finally started reaching chat_send on the server — and that's when the SERVER started returning 500. The v5.29 chat_send handler was doing four expensive things for every message, including ephemeral WebRTC call signals: (1) lock and write ilearn-chat.json, (2) load_db() the entire main database (~500KB+), (3) save_db() the entire main database back, (4) FCM push to the callee's mobile devices. A single voice call sends 5–10 signals in rapid succession (SDP offer + ICE candidates + SDP answer + hangup) — multiplying all four steps by 5–10. Any one step could exhaust PHP memory, fail a json_encode, throw a fatal in FCM's openssl_sign (the @ operator does NOT catch fatals), or just time out. One failure = 500 = every call silently refused.
  • 🪶 Call signals now skip the heavy main-DB round trip. Signals are inherently ephemeral (valid for seconds, replaced within the same call), so v5.53 detects _callSignal on the incoming message and writes it ONLY to ilearn-chat.json. Not to the main DB. chat_poll already reads from ilearn-chat.json first and only falls back to the main DB if that file is missing — so callees still receive signals through the normal polling pipeline. Effect: one 10KB file-append instead of a 500KB-load + 500KB-save. Latency drops from seconds to milliseconds, and the memory-pressure failure mode is eliminated.
  • 📵 Call signals now skip FCM push. There was zero value in FCM-pushing [call-signal] to a user's phone (the body was the literal string "[call-signal]" — useless), and openssl_sign or network failures in the FCM path could throw fatals on the server. Skipping FCM for signals removes that entire failure surface and stops spamming the callee's phone with up to 10 useless push notifications per incoming call.
  • 🪤 Full try/catch wrap around chat_send with error_log at every failure path. Any failure now returns a structured JSON error with a detail field AND writes a [iLearn chat_send v5.53] … entry to cPanel → Errors so you can diagnose exactly which step blew up (failed fopen, failed flock, json_encode refused a malformed payload, disk full, FCM threw, etc.). No more generic 500s that leave you guessing. The main-DB mirror and FCM push are each wrapped in their own inner try/catch so a failure in either does NOT 500 the whole request — the chat file already holds the source of truth at that point.
  • 🔒 Lock-acquire now reports 503 instead of 500 on contention. If flock(LOCK_EX) can't get the lock (another write is in progress), the handler returns HTTP 503 Service Unavailable with a "try again" message instead of a bare 500. The client retries naturally on the next signal attempt.
  • 🧹 Chat file ring-buffer size unchanged for regular messages (500) but capped at 400 for signal-heavy periods. Minor tuning — prevents the chat file from exploding during a burst of ICE candidates on a flaky network.
  • 📌 Scope. Single-file change: ilearn-db.php — the chat_send action block (~45 lines) rewritten in place (~95 lines with try/catch + branches). Zero changes to JS, HTML, manifest, SW, or database schema. PORTAL_BUILD bumped to v5.53 in both JS and PHP. Rollback = revert the chat_send block (diff is contained) + revert build constants. Client v5.52 still works against v5.53 server (the fix is server-side only); and conversely, v5.53 server still handles pre-v5.52 clients correctly since regular messages still go through the main-DB mirror path.
  • ⚠️ Validation checklist after deploy: Upload ilearn-db.php + the build-bumped HTML/JS → purge Cloudflare → hard-reload both tabs (caller + callee). Open Team Chat on caller's tab, click the teammate's row to open a DM thread, click 📞. Expected: "📞 Calling …" toast appears, ringback tone plays, callee's tab shows the incoming-call modal + ringtone starts. Critically, no HTTP 500 toast. If it does still fail, cPanel → Errors will have a line like [iLearn chat_send v5.53] uncaught exception: … at /home/…/ilearn-db.php:NNN that tells us exactly where — paste that line in the next session and I'll patch the specific step. If the signal goes through but the callee doesn't ring, the receive-side path is next to investigate.
Carried forward (deployed with v5.53 — all previously packaged)
  • 🎤 Voice memos in Team Chat (v5.52) — record / send via 🎤 button, play inline via <audio controls>
  • 🔁 Contacts table flashing fix (v5.52) — render throttle + decorator debounce
  • ⏱ Outgoing call 20-second timeout (v5.52) — no more stuck in "Calling…" forever
  • 📞 v5.51 _sendSignal fix (client now uses getSrvCfg — signals actually leave the browser)
  • 🔔 Two-sided ringer/ringback/chimes (v5.51), AudioContext auto-unlock, _v551DiagCall() debug helper
  • 🔍 Search by Record ID + badge in edit modals + topbar photo reconcile (v5.50)
  • 📇 Record ID column on Contacts/Suppliers/Invoices/POs (v5.48), sticky table headers (v5.49)
🎤 v5.52 — Voice Memos + Contacts Flashing Fix + Call Timeout + v5.50/5.51 Bundled
  • 🎤 Voice memos in Team Chat. A new 🎤 button next to Send in the chat composer. Tap once to start recording — the button turns red, pulses, and shows the elapsed time (⏹ 0:05). Tap again to stop and send. The recording is encoded as audio/webm;codecs=opus (with fallbacks to webm / ogg-opus / mp4 depending on browser support), base64-wrapped, and transmitted via the existing chat_send pipeline as a normal message with a _voiceMemo property holding { mime, duration, data }. On the receiver side, a wrap around buildChatBubble replaces the text marker with an inline <audio controls> player so the recipient can play, pause, and scrub right in the chat bubble. Capped at 3 minutes to prevent accidental long recordings; rejected if < 0.6 seconds (mistap). Works on both broadcast and DM threads.
  • 🔁 Contacts table flashing — fixed. After navigating to Contacts, the tbody was visibly flashing every ~3 seconds and under sustained load could freeze the renderer entirely. Root cause: the 3-second db_ping heartbeat detects any server-hash change and triggers autoLoadFromServerrenderContacts → the v5.46 render wrap fires _v546DecorateAll via a 50ms setTimeout. Under cross-tab sync bursts or user typing while the page is auto-refreshing, renders queued faster than the decorator could finish, and the tbody was being rebuilt + re-decorated rapidly. v5.52 adds two guards:
    • Render throttle — if a specific render function (renderContacts, renderLeads, etc.) was called < 150ms ago, the duplicate call is coalesced (dropped). The in-flight render still completes, so no data is lost; only the visible flash is suppressed.
    • Decorator debounce_v546DecorateAll now runs at most once per 350ms regardless of how many renders queue up. Leading-edge invocation if enough time has passed, trailing-edge otherwise so the final decorate always lands.
    Result: even with the 3s heartbeat pulling live data, the tbody stays stable; there's no CPU storm; the renderer doesn't freeze.
  • Outgoing call 20-second timeout. If the peer doesn't answer within 20 seconds, the caller gets a clean "📴 No answer — the other party may be offline" toast, the ringback tone stops, a soft end chime plays, and the call state resets. Previously the caller was stuck in "Calling…" forever if the peer was offline or didn't pick up. Cleared the moment the peer accepts, declines, or hangs up — no effect on normal call flow.
  • 📦 v5.50 + v5.51 bundled into this single release. Since neither was deployed yet, v5.52 carries forward every previously-packaged fix:
    • v5.50 — search by Record ID, Record ID badge in edit modals, topbar photo reconcile on every sync
    • v5.51 — root-cause fix for calls not reaching the other party (_sendSignal was using undefined getCfg() instead of getSrvCfg()), caller ringback tone, accept/decline/end chimes on both sides, AudioContext auto-unlock for Chrome autoplay policy, full console diagnostic logging, _v551DiagCall() debug helper
  • 📌 Scope. Two files touched: ilearn-admin.js (render throttle + decorator debounce patched into the v5.46 IIFE at end of file; voice recorder IIFE appended ~150 lines) and ilearnhcc-admin-v2.html (mic button added to chat composer; outgoing-call timeout wired into the v5.29 script block). Zero PHP changes. Zero database schema changes. PORTAL_BUILD bumped to v5.52 in both JS and PHP. Rollback is clean revert of the four edits + build constants.
  • ⚠️ Validation path: deploy → hard-reload → navigate to Contacts → the table should settle and stay still (no 3-second pulsing). Open Team Chat → 🎤 button appears next to Send → tap, grant mic permission, say a short test, tap again → voice memo appears as an audio player in the chat feed; other users on the same thread hear it with an inline player. Click a teammate's row → 📞/📹 appear in thread header → click 📞 → DevTools console logs 📞 OUTGOING voice call → …, ringback tone plays, and one of three things happens within 20 seconds: peer accepts → chime + connect; peer declines → chime + toast; no one answers → timeout + toast. If any step fails, the last [iLearn call v5.51] console log pinpoints the break.
What's NOT in this release (deliberately deferred)
  • 🌐 TURN server. WebRTC still uses STUN-only. Cross-network calls (cellular ↔ wifi, symmetric-NAT) may fail at ICE negotiation. Set up a TURN provider and drop the config into localStorage.il_turn_config as {"urls":"turn:…","username":"…","credential":"…"}.
  • 📥 Voice memos don't yet surface in the 🔔 notifications feed. They live in the Team Chat thread only. Push/email notification when a voice memo arrives is a future follow-up.
  • 📝 Transcription. Voice memos are not transcribed. Whisper-style transcription via server-side API is a future follow-up.
📞 v5.51 — Voice/Video Calls Actually Work Now (Root-Cause Fix) + Two-Sided Ringer & Chimes
  • 🐛 Root cause of "calls not reaching the other party": a one-liner bug in v5.29's signal-send path. _sendSignal resolved the server config via window.getCfg() — but getCfg doesn't exist in this build; the real helper is getSrvCfg. So every call signal (offer, answer, ICE, decline, hangup) hit the if (!cfg.url) return guard and silently returned BEFORE the fetch. No signal ever left the caller's browser. The caller saw "Calling…" in the call panel while the callee's tab received nothing. Confirmed by live synthetic probe: sending the same payload via getSrvCfg() gets HTTP 200 + message id back from the server, whereas the v5.29 path short-circuited at the config check. Fix: _v551GetCfg() wrapper tries getSrvCfg, falls back to getCfg if it's ever defined, then falls back to parsing localStorage.il_server_cfg directly. Belt-and-braces so the signal path survives whatever helper a future build uses.
  • 📣 Signal failures now surface via toast — no more silent fails. v5.29 wrapped all signal errors in if (window._DEBUG) so they disappeared unless you had _DEBUG set in console. v5.51 unconditionally logs each failure as console.error and shows a user-facing toast: "⚠️ Call signal rejected (HTTP 5xx)", "⚠️ Network error sending call signal — retry in a moment", or "⚠️ Cannot start call — server connection not configured". Non-2xx server responses also surface. No more guessing whether a call actually went out.
  • 🔔 Ringer + sound alerts now on BOTH sides of the call. Previously only the callee heard anything (the 440/480Hz dual-tone ringtone). v5.51 adds:
    • Caller ringback — 425Hz tone, 2 seconds on / 2 seconds off, while _state === 'outgoing-ringing'. Mimics the "ring…ring…" feedback of a phone call so the caller knows the system is actually ringing the other end (not frozen).
    • Accept chime — C→E→G ascending arpeggio (523→659→784Hz) on BOTH sides when peer accepts. Positive audible confirmation.
    • Decline chime — G→E descending on BOTH sides (392→330Hz). Clearly signals rejection.
    • End chime — E→C descending (659→523Hz) on BOTH sides when either party hangs up.
    • Louder default ringtone — peak gain raised 0.15 → 0.25 (+67%) so it's more noticeable over ambient noise or other browser tabs.
  • 🔓 AudioContext auto-unlock on first user interaction. Chrome's autoplay policy blocks AudioContext creation before a user gesture, which was silently killing the ringtone in Chrome when a call came in on a tab that hadn't been interacted with yet. v5.51 registers a single-fire click/touchstart/keydown listener at document-level that plays a 1ms silent buffer to prime the shared AudioContext. Unlocks once per session; removes itself after firing. Also switched from a per-call _ringtoneCtx (created + closed each time) to a _sharedAudioCtx that persists across calls so ringback and ringtone don't tear each other down.
  • 🔬 Full diagnostic mode — see exactly where a call breaks. Every signal send, signal receive, and state change now emits a clearly-visible console.info('[iLearn call v5.51] …') line. Example session on the caller side: 📞 OUTGOING voice call → safia@…→ SEND offer → safia@… session=call-1776…← ACK offer stored on server← RECV answer from safia@…✓ peer ACCEPTED — connecting. If something breaks, the last log before the gap tells you which step failed. Added _v551DiagCall() global function runnable from DevTools console:
    • _v551DiagCall() — dump current state, peer connection, ice candidates, audio context, recent signals
    • _v551DiagCall('ringtone') — play callee tone for 3 seconds
    • _v551DiagCall('ringback') — play caller tone for 5 seconds
    • _v551DiagCall('chime','accept'|'decline'|'end') — hear each chime
    • _v551DiagCall('testSend','user@email.com') — send a synthetic offer signal and see if the server accepts it
  • 📌 Scope. Single-file change: ilearnhcc-admin-v2.html inside the <script id="v529-voicevideo-js"> block. Zero changes to JS, zero changes to PHP, zero database changes. Touches ~110 lines across the original 500-line v5.29 script: the ringtone helpers are refactored, _sendSignal is fixed + instrumented, _startOutgoingCall/_handleAnswer/_handleDecline/_handleHangup/_v529AcceptIncoming/_v529DeclineIncoming/_v529Hangup each get a chime + console log, _resetCall also stops the ringback timer, and the public debug object gains _v551DiagCall. PORTAL_BUILD bumped to v5.51 in both JS and PHP. Rollback = revert the script block changes + build constants.
  • ⚠️ Validation — critical path: deploy and hard-reload. Open Team Chat → click a teammate's row to open a DM thread with them. The 📞 and 📹 buttons appear in the thread header. Click one. You should see: the call panel slides in saying "Calling <name>…", AND (new!) you hear a slow 425Hz ringback tone repeating. On the other tab / other device, the incoming call modal pops up + 440/480Hz ringtone starts. Accept → BOTH sides hear the C-E-G accept chime, ringtone+ringback stop, and the WebRTC streams connect. Open DevTools Console on both sides — you'll see the full signal flow logged. If anything fails, the last console log before the break pinpoints the stage.
  • 🌐 Not addressed in this release: TURN server configuration. Calls across different networks (cellular ↔ wifi, or two different offices with symmetric NAT) may still fail at the ICE negotiation stage — STUN alone handles ~70% of cases, the other 30% need a TURN relay. Set one up at a provider like Twilio / CoTURN / Xirsys and drop the config into localStorage.il_turn_config as {"urls":"turn:…","username":"…","credential":"…"}. Log message on page load now includes "(no TURN — cross-network calls may fail)" to make the implication visible.
🔍 v5.50 — Search by Record ID, Record ID in Edit Modals, Topbar Photo Refresh Fix
  • 🔍 Global search now matches Record IDs. Type REC-2026-0042 — or even just a fragment like 0042 — into the topbar search box and matching records from every section (leads, contacts, suppliers, invoices, purchase orders) appear in a dedicated "Record ID matches" section of the results panel. Clicking a result navigates to the appropriate section. The search wrap is additive: existing name/email/notes matches still appear in their usual sections; the recordNo matches show up beneath them with a section header. Up to 10 recordNo matches per query to keep the panel scannable.
  • 🏷️ Record ID badge now appears in every edit modal. Open any existing Contact, Supplier, Invoice, Lead, or Purchase Order for editing — a small violet REC-YYYY-NNNN badge now sits right next to the modal title (e.g. "✏️ Edit Contact   REC-2026-0007"). Click the badge to copy the Record ID to your clipboard — a "📋 Record ID copied" toast confirms. In Add mode the badge is hidden since no recordNo has been generated yet; switching from Edit → Add in the same session clears any stale badge carried over. Zero changes to modal HTML markup — the badge is injected and cleared by a wrap on editContact, openM_sup, editInvoice, editLead, editPO, plus a belt-and-braces openM() wrap that clears the badge whenever an Add/New modal opens.
  • 📸 Topbar profile photo refresh fixed. Reports of the topbar avatar flashing back to the "A" initial letter during a page reload, or staying on the initial after a multi-device session switch, traced to a race between the v5.41 session-resume reconciliation (runs 1 second after session load) and autoLoadFromServer (can take longer than 1 second on slow connections or large DBs). If the users roster arrived later than 1 second after page load, the 1-second reconcile call found no photo in the roster and bailed out; nothing triggered another reconcile afterward. v5.50 adds a single _refreshSessionPhoto() call at the end of every autoLoadFromServer success path, right after il_last_sync_ts is stamped. Idempotent — if the session photo already matches the roster's photo it's a no-op. If the roster's photo is newer (e.g. you updated it on another device), the topbar picks it up on the next sync without requiring a fresh login.
  • 🛡️ Scope and safety. One code diff in ilearn-admin.js at ~line 21213 inside autoLoadFromServer for the photo fix, plus a new ~200-line IIFE appended at end of file for the search + modal badge features. Zero changes to PHP, zero changes to the record-number generator / migration / save-path wraps / column decorator from v5.46/v5.48. PORTAL_BUILD bumped to v5.50 in both JS and PHP. Rollback = revert the three hunks + build constants. Legacy portal state is fully forward-compatible: any record without a recordNo still shows "—" in the column and no badge in the modal.
  • 📌 Validation checklist: after deploying, open DevTools Console and reload. Type REC- in the topbar search box — the results panel should show a "Record ID matches" section at the bottom with up to 10 entries. Type a partial recordNo like 0007 — same. Click any result and verify it navigates to the right section. Click a row's ✏️ Edit button on any Contact, Supplier, Invoice, Lead, or Purchase Order — verify the violet recordNo badge appears right of the modal title. Click the badge — verify the "📋 Record ID copied" toast and that your clipboard actually contains REC-YYYY-NNNN. Close the modal, click "+ Add Contact" — verify NO badge appears (Add mode clears). For the photo: force-reload the portal once or twice and watch the topbar — it should go straight to the photo without flashing to "A".
What's NOT in this release (deliberately deferred)
  • 📎 Record ID on audit-trail + communication log entries — when you email a contact, the email log row still doesn't include the contact's recordNo as metadata. Low-effort addition, queued for a follow-up.
  • 🖨️ Record ID in invoice/PO PDFs and CSV exports — the data is present on each record but not yet rendered into the PDF layout or the CSV export columns. Queued.
  • 🔎 Per-section "Search by Record ID" filter — the GLOBAL search matches recordNo; the per-section filter inputs (e.g. Contacts section's own search) don't yet. Easy extension, can be added once you confirm the global search UX.
🪟 v5.49 — Gmail-Style Contained Table Viewport + Sticky Headers (All Sections)
  • 📐 Tables now scroll within their own viewport instead of pushing the page. Every data table across the portal (Contacts, Suppliers, Invoices, Parent Invoices, Purchase Orders, Leads table view, Tasks, Users, Subscribers, Campaigns, Blog, Testimonials, Bookings, Payments, Meetings, Enrollments, Providers, Audit Trail, and a dozen others) is now contained in a fixed-height frame so the horizontal scrollbar, pagination, action buttons, and section footer are always reachable without page-level scroll. Mirrors Gmail's inbox design where the message list is a contained viewport with a sticky header and internal scroll.
  • 📌 Table headers stay stuck to the top of the viewport. Scroll down through 28 contacts or 87 leads — column headers (Name ⇅, Email ⇅, Record ID, Phone, etc.) remain visible at the top of the table frame instead of scrolling away with the rows. No more losing which column is which when deep in a list. Uses position: sticky with z-index: 5 and an opaque background so row content doesn't bleed through.
  • 🧰 Pure CSS, zero JS. Live DOM inspection on v5.48 confirmed that every data table in the portal is wrapped in one of exactly two classes: .tw (6 tables — contacts, subscribers, and a few others) or .table-wrap (15 tables — suppliers, invoices, POs, tasks, users, etc.). Both wrappers already had overflow:auto but neither had max-height, so they'd grow to fit the entire table content, pushing everything below the fold. v5.49 adds a single CSS rule that sets max-height: calc(100vh - 260px) on both wrapper classes, plus sticky thead styling. 260px leaves room for the fixed topbar (~48px), section heading, stats bar, filter row, and pager below. Small tables stay compact because max-height doesn't force growth — only tables that exceed the frame get the contained-scroll behaviour.
  • 🧱 Border rendering preserved. Sticky thead requires border-collapse: separate to render correctly across Chrome and Safari, which would otherwise break the existing border-bottom on body rows (border-collapse: collapse was the old default). v5.49 restores the visual by re-adding per-cell bottom borders via explicit border-bottom: 1px solid var(--b) on tbody td and suppressing the last row's border. The existing inset box-shadow on thead cells preserves the column-header underline. End result is visually identical to v5.48 except for the new contained-scroll and sticky header behaviour.
  • 📱 Touch-scrolling preserved. The existing -webkit-overflow-scrolling: touch on .tw is kept and added to .table-wrap. Momentum scroll on iOS Safari still works within the frame.
  • 📌 Scope. Single-file change: ilearnhcc-admin-v2.html. Eight CSS lines added to the existing TABLES block at ~line 225 (injected right after the existing .tw rule). Zero changes to JS, zero changes to PHP, zero render-function changes. PORTAL_BUILD bumped to v5.49 in both JS and PHP. Rollback = delete the new CSS block, revert build constants.
  • ⚠️ Validation focus: deploy, hard-reload, navigate to Contacts. The table should now fit within a fixed frame roughly 651px tall (on a 911px viewport). Scroll down INSIDE the frame — row content scrolls, column headers stay at top. Scroll RIGHT inside the frame — action buttons at the far right become reachable without scrolling the page. Switch to Suppliers, Invoices, Purchase Orders, Tasks, Users, Bookings — same behaviour. On a large monitor where all columns fit, no horizontal scrollbar appears (unchanged). On a phone / narrow window, the horizontal scrollbar appears within the frame, not at the page bottom.
What's NOT in this release (deliberately deferred)
  • 🔎 Sticky first column (freeze Name/Company) — Gmail doesn't do this either, but some portals freeze the leftmost identifier column so it stays visible during horizontal scroll. Could be added with a second position: sticky; left: 0 on the first cell of each row. Not in v5.49 scope; ask and we'll queue it.
  • 🪄 Resizable column widths — out of scope; would require column-width drag handles + persistence.
✨ v5.48 — Dedicated "Record ID" Column on Contacts, Suppliers, Invoices, POs, Leads
  • 📇 Record ID is now a proper column, not a pill under the name. Every primary table — Contacts, Suppliers, Invoices (both Generic and Parent Invoices), Purchase Orders, and the Leads table view — now has a new Record ID column inserted at position 2 (immediately after Name / Company / Invoice # / PO#). Each cell shows the record's REC-YYYY-NNNN as a violet pill. Missing-data rows show a dash (—) placeholder so the column is always aligned. Lead kanban cards keep the bottom-of-card pill treatment from v5.46 since kanban has no concept of columns.
  • 🧭 Duplicate-ID gotcha handled. Live DOM inspection on v5.46 exposed that #invTbody exists TWICE in the admin portal — once for the Inventory table (thead starts with "Asset") and once for the Invoices table (thead starts with "Invoice #"). The v5.47 decorator was grabbing whichever came first in document order (Inventory), so invoices never got a pill. v5.48's _v548FindTbodyByHeader(id, requiredHeaderText) walks all elements with the given id and returns the one whose sibling thead contains the disambiguating keyword. Fixes the invoice problem and protects against any similar future id collision. Parent invoices (#pinvTbody) are treated as a separate table with their own Record ID column.
  • 🧱 Zero render-function changes — still purely additive. The column header is injected into each table's <thead><tr> at position 2 the first time the section renders (idempotent — won't re-add). After every render, _v548InjectRowCells walks each body row and appends a new <td> at the matching column position. Row-to-record matching uses the same robust allowlist strategy from v5.47: walk all [onclick] descendants and match against function names like editContact, editSupplier, editInvoice, editPO. Rows already containing a td.v548-recno-cell are skipped, so multiple re-renders don't duplicate cells.
  • 🗑️ Old pill-under-name removed from table views. The v5.46/v5.47 pill-under-name decoration is no longer applied to table rows — the dedicated column supersedes it. Kanban cards keep the v5.46 pill class (.v546-recno-pill) unchanged. If you see both a pill AND a column-cell on any row, hard-reload to pick up the v5.48 code path.
  • 🔁 Late-arrival sweep carried forward from v5.47. _v546SweepMissing() still runs on every load at ~4s, stamping any record that arrived after the one-shot v5.46 migration. Idempotent no-op once everything is stamped. Console logs only when it actually fills a gap.
  • 📌 Scope. Single-file change: ilearn-admin.js. The v5.47 decorator block (roughly 120 lines at end of file) replaced with the v5.48 column-injection version (~170 lines). Zero changes to render functions, zero changes to the record-number generator, migration, save-path wraps, or record-number copy logic between sections. PORTAL_BUILD bumped to v5.48 in both JS and PHP. Rollback = revert the decorator block, bump the build back.
  • ⚠️ Validation focus: after deploying, open DevTools Console and reload. Navigate to Contacts — new Record ID header appears as the second column, each row shows a violet REC-YYYY-NNNN. Same for Suppliers, Invoices, Parent Invoices, Purchase Orders, and Leads (table view). Switch Leads to Kanban — cards still show the pill at the bottom, column treatment doesn't apply. Scroll through all 28 contacts → every row should have a cell. Scroll through 6 suppliers → same. 3 invoices, 3 POs → same. If any row shows "—", that record genuinely has no recordNo — the late-arrival sweep should have stamped them all by the time you look, but if you see "—", wait 5 more seconds and reload; the sweep catches it on the next pass.
🐞 v5.47 — Record-Number Pills Now Render (v5.46 Display Fix + Late-Arrival Sweep)
  • 🎯 Two issues found during v5.46 post-deploy validation. Data side worked perfectly — all 87 leads, 28 contacts, 6 suppliers, 3 invoices, 3 POs got stamped with REC-YYYY-NNNN numbers, lead→contact inheritance at 7/7, counters persisted to il_record_counters. But the UI pills never appeared in the tables, AND 5 leads were still showing as missing a recordNo after the initial migration. Root causes were separate:
  • 🏷️ Fix 1 — Decorator tbody IDs were wrong. Live DOM inspection on v5.46 revealed the actual tbody IDs in this build are #cTbody (contacts), #supTbody (suppliers), and #invTbody (invoices) — not #contactTbody, #supplierTbody, #invoiceTbody as the v5.46 decorator assumed. The decorator ran on every render but always found zero rows, so zero pills were ever added. Corrected the IDs. Also strengthened the row→record match: instead of inspecting only the first onclick descendant (which sometimes is a row-select checkbox with no id argument), the decorator now walks all onclick descendants and matches against an allowlist of edit/open function names (editContact, editSupplier, editInvoice, etc.) — so rows that don't have data-*-id attributes still resolve reliably.
  • 🗂️ Fix 2 — Kanban card decoration for leads. The Leads section defaults to kanban view, not the table. v5.46 only decorated tbody tr markup, so leads never got pills unless the user explicitly switched to table view. Added _v546DecorateLeadKanban() which scans #leadKanban [onclick*="editLead"] cards and appends the pill to the card itself. Kanban cards + table rows are now both decorated on every render.
  • 🔁 Fix 3 — Late-arrival sweep for records missing recordNo. The v5.46 migration was one-shot, gated by il_v546_recordno_migrated. Any record that arrived from a server pull AFTER the 3.5s migration timer had already fired stayed unstamped — we saw 5 of 87 leads in this state post-deploy (all created in April 2026, most with ids newer than the initial migration ran). Added _v546SweepMissing() which runs on every page load at 4.0s: walks leads, contacts, suppliers, invoices, and purchase_orders, calls the already-idempotent _v546EnsureRecordNo on any row without a recordNo, and persists via sd() if anything changed. Costs essentially nothing when every record is already stamped (no-op loop). Console logs [iLearn v5.47] late-arrival sweep stamped missing recordNos on <key> whenever it does find and fill a gap.
  • 📌 Scope. Single-file change: ilearn-admin.js. The v5.46 IIFE's _v546DecorateRow, _v546DecorateSection, and _v546DecorateAll replaced with v5.47 versions that use correct tbody IDs, walk all onclick attributes, handle kanban cards, and run the late-arrival sweep before decorating. Zero changes to the record-number generator, migration, or save-path wraps — those were already correct. PORTAL_BUILD bumped to v5.47 in both JS and PHP. Rollback = revert the decorator block, bump the build constants back.
  • ⚠️ Validation focus: after deploying, open DevTools Console and reload. Within ~5 seconds, navigate to Leads — you should see a small violet REC-YYYY-NNNN pill at the bottom of every kanban card. Switch to Table view (the ≡ Table button at top) — same, but now as a pill under the name. Go to Contacts → pills under every name in the table. Same for Suppliers, Invoices, Purchase Orders. Console may log [iLearn v5.47] late-arrival sweep stamped missing recordNos on leads if any newly-arrived records needed filling. No counter duplication — the migration flag from v5.46 prevents re-runs of the big migration; the sweep only fills genuine gaps.
✨ v5.46 — Unified Record-Number System + Category Edit Fixes
  • 🏷️ Every lead, contact, supplier, invoice, and PO now has a unique customer reference number. Format REC-YYYY-NNNN (year-qualified, 4-digit zero-padded sequence, counter resets yearly). The number is stamped on a lead at creation and carries through the entire customer lifecycle: when a lead converts to a contact, the contact inherits the lead's recordNo rather than generating a new one — so you can trace a single customer journey from first inquiry through every subsequent invoice and purchase order under the same ID. Invoices attached to a supplier by company name automatically inherit the supplier's recordNo; POs do the same. Live-DOM verified: all 87 existing leads, 28 contacts, 6 suppliers, 3 invoices, and 3 POs are backfilled by the one-time migration, with lead→contact pairs sharing numbers wherever convertedFromLead/convertedToContactId linkage already existed.
  • 🔧 How the number gets attached — five save-path wraps + one conversion wrap. saveLead, saveContact, saveSupplier, saveInvoice, and savePO are each wrapped with a stamp-after-save helper that calls the idempotent _v546EnsureRecordNo on any freshly saved row that doesn't already have one. For invoices and POs, the wrap first tries to inherit from a matching supplier (case-insensitive company-name match) before minting a fresh number — so a supplier's recordNo is the anchor for every invoice and PO linked to that supplier. The Lead→Contact handoff (_ensureContactForWonLead) is separately wrapped to copy the lead's recordNo onto the new contact rather than generate a new one. All wraps are idempotent and guarded by .__v546Wrapped flags so repeated script loads don't double-wrap.
  • 🗂️ One-time chronological migration on first v5.46 load. Gated by localStorage.il_v546_recordno_migrated. Runs ~3.5 seconds after DOMContentLoaded to let autoLoadFromServer finish, then walks records in four passes: (1) leads sorted by id ascending get sequential numbers; if a lead has convertedToContactId, the matching contact inherits the same number; (2) remaining contacts are stamped, inheriting from convertedFromLead when possible; (3) suppliers get independent numbers; (4) invoices and POs inherit from their matched supplier, or mint fresh if unmatched. The counter store il_record_counters is persisted via sd() so every device picks up the same sequence state and no two devices issue the same number. Console logs [iLearn v5.46] One-time record-number migration complete — counters: … when done.
  • 👀 UI: violet pill on every table row. A post-render decorator wraps renderLeads, renderContacts, renderSuppliers, renderInvoices, and renderPOs. After each native render completes, the decorator walks the visible rows, looks up the corresponding record by data-*-id attribute (or falls back to parsing the onclick handler's id argument), and appends a small violet pill (REC-YYYY-NNNN) to the primary name cell. Tooltip reads "Record ID — carries through from lead to contact to invoices/POs". Zero changes to the render functions themselves — the decorator is purely additive and skips rows that already have a pill. Idempotent on re-render.
  • 🗃️ Supplier edit: category is now preserved. Live-diagnosis on your portal confirmed two existing suppliers with category values ("General" and "Dues/Subscriptions") that aren't in the modal's <option> list. When you'd hit Edit, the <select> had no matching option and silently defaulted to the first one ("Office Supplies") — so saving without interacting with the category dropdown was overwriting the real category. Fixed: openM_sup() now checks every option value against the stored s.category, and if there's no match, injects a new <option> with that value (suffixed " (custom)") before setting the select's value. The original category survives edit intact.
  • 📄 Invoice edit: same fix. editInvoice() now dynamically injects any stored invoice category not present in the dropdown's option list. Walks all <option>s including those inside <optgroup>s (the invoice category select is grouped by Generic vs Parent). Inserts the injected option into the currently visible optgroup so the form stays visually consistent with the rest of the grouped UI. Covers categories added via the v5.26 "+ Add new category…" prompt as well as legacy values.
  • 📌 Scope. Two files touched. ilearn-admin.js: new _v546 helper functions inside openM_sup (~line 25333) and editInvoice (~line 30096) for the category fix; a new ~260-line IIFE appended at end of file containing the record-number generator, migration, save-path wraps, convert wrap, and render decorator. PORTAL_BUILD bumped in both JS and PHP. Zero changes to renderBookings, renderLeads, or any other render function (the decorator wraps them externally). Zero schema changes — recordNo is just a new string field added to existing records on save. Rollback = delete the IIFE + revert the two category-fix edits + clear localStorage.il_v546_recordno_migrated on each device.
  • ⚠️ Validation focus: after deploying, open DevTools Console and reload. Within ~4 seconds you should see [iLearn v5.46] One-time record-number migration complete — counters: {year_YYYY: N}. Then navigate Leads → every row should now show a violet REC-YYYY-NNNN pill next to the name. Same for Contacts, Suppliers, Invoices, POs. Open an existing supplier with category "General" → the dropdown should show "General (custom)" as the selected option, not "Office Supplies". Open an existing invoice → same. Create a fresh lead → verify a new recordNo appears on it. Drag that lead to Won → verify the auto-created contact has the SAME recordNo. Create an invoice against a known supplier → verify the invoice row shows the supplier's recordNo.
What's NOT in this release (deliberately deferred)
  • 📧 Email / chat metadata stamping — outbound emails and chat messages addressed to a known contact don't yet include the contact's recordNo in their metadata. The data is available (look up the contact by recipient address), but wiring it into sendPortalEmail and the chat send paths adds another few surfaces and was held for a follow-up. The CRM UI surfaces (tables, badges) are the primary ask; this extension adds it to audit trails.
  • 🔍 Search by record number — the global search box doesn't yet match records by recordNo. Low-effort addition but touches globalSearch and a couple of section-search functions; deferred to keep this release focused.
  • 🖨️ PDF / CSV / print layouts with recordNo — invoice PDFs, PO PDFs, supplier CSV exports don't yet include the column. Another small follow-up.
🐞 v5.45 — Booking Email Dates, Status Wording ("Accepted"), and Teams Clarification
  • 📅 Booking emails were missing date/time after magic-link approval. Live DOM probe of a real stored booking confirmed date="", dateLabel="", time="13:00", timeLabel="1:00 PM" — time was populated, date was blank. Two root causes stacked together: (1) the magic-link approval/decline email at ilearn-db.php ~line 6216 read only the canonical date and time fields with no fallback to the human-readable dateLabel / timeLabel, so any row missing canonical data showed "TBD"; (2) the public_append_booking handler at ~line 5942 set $date only if the payload matched a strict YYYY-MM-DD regex and set $dateLabel only from the payload's dateLabel field — never derived one from the other, so any pre-v5.38 website client that sent only date: "Apr 18, 2026" (human label) stored both fields empty. Fixed both: magic-link email now uses the same label-first fallback pattern as the initial new-booking emails, and public_append_booking now reverse-derives canonical ISO date from a parseable dateLabel when the ISO form is absent.
  • Booking status renamed "Confirmed" → "Accepted" end-to-end. All write paths now store 'Accepted': setBookingStatus() normalises the input (so legacy code passing 'Confirmed' still routes correctly), the calendar-day modal buttons write 'Accepted', the updateBookingStatus() helper normalises + says "Booking accepted!" in its toast, and the PHP magic-link handler writes 'Accepted'. All read paths (bookings table badge, calendar mini-card badge, calendar-day modal status pill, Accept button label) display "Accepted" regardless of the stored string — legacy 'Confirmed' rows render identically to new 'Accepted' rows so the migration is forward-safe in both directions. The badge stays green, the toast stays green, and email copy will be revisited in a follow-up release if you want the booker-facing wording to change too.
  • 🔄 One-time bookings migration runs on v5.45 load. Gated by il_v545_bookings_cleanup_done in localStorage (v5.21 / v5.43 pattern). Walks every row in il_bookings and: (a) rewrites status: 'Confirmed'status: 'Accepted'; (b) back-fills missing date from a parseable dateLabel; (c) back-fills missing dateLabel from a valid ISO date; (d) recovers both when they're empty but the booking's notes field contains the canonical "Date: Apr 18, 2026 at 1:00 PM" pattern that confirmBooking() stamps into every new record. Any row that's changed triggers a sd('bookings', ...) that pushes the canonical form up to the server so other devices see it on their next autoLoadFromServer pull. Runs once per device, then never again. Non-destructive — only fills empty fields, never overwrites populated ones.
  • 🎥 Teams link clarification. Teams meeting auto-creation IS already implemented in the admin-portal approval flow (v5.07 + v5.11) — the 🎥 button on each bookings row calls setBookingStatus(id,'Accepted',{teams:true}), which invokes createM365CalendarEvent with isOnlineMeeting: true + onlineMeetingProvider: 'teamsForBusiness'. On Graph success the onlineMeeting.joinUrl is captured, stamped onto the booking record as teamsJoinUrl, and surfaced in the branded confirmation email with a prominent "🎥 Join Teams meeting" button. Teams is NOT fired from the magic-link approval path (server-side, no admin M365 token available there) and NOT included in the initial new-booking email (no event exists yet at that moment). Those stay deferred — they'd each require server-side M365 Graph credentials, which is scope well beyond a hotfix.
  • 📌 Scope. Two files changed. ilearn-admin.js: setBookingStatus normaliser (~line 8095), renderBookings status normaliser + button labels (~lines 8063, 8080-8082), calendar-mini badge (~line 3046), calendar-day modal badge + buttons (~lines 17409, 17421-17423), updateBookingStatus helper (~line 23810), v5.45 one-time migration block (~line 21079). ilearn-db.php: public_append_booking reverse-derivation (~line 5951), magic-link email label-fallback (~line 6215), magic-link handler writes 'Accepted' (~line 6196). PORTAL_BUILD bumped in both. Zero schema changes. Rollback = revert diffs + set localStorage.il_v545_bookings_cleanup_done = '' on any migrated device (re-pulls canonical from server) or let it ride since legacy readers handle both strings.
  • ⚠️ Stack is at SEVEN if you deploy before validating. SIX were marked "all confirmed" by you before this release. Per your standing rule, next session must be validation focused on the v5.45 booking flow end-to-end: submit a new website booking, verify the auto-reply and admin notification have date+time, click ✅ Approve in the admin email, verify the approval email shows dates and calls it "Accepted", go to the admin portal, verify the bookings table badge says "Accepted", click 🎥 and verify a Teams meeting is created + the confirmation email includes the join button. If all six pass the stack is clean and v5.45 becomes the new baseline.
What's NOT in this release (deliberately deferred)
  • 🎥 Teams link in the initial new-booking email — would require server-side M365 Graph credentials (no admin token is available at public_append_booking time) and creating the Graph event before the booking is even approved. Non-trivial; stays deferred.
  • 🎥 Teams meeting from magic-link approval — same constraint: the server-side token_act handler has no M365 delegated token. Could be added with an on-portal-load reconciliation that creates the Graph event the next time the admin opens the portal with ?booking_result=ok&booking_action=approve in the URL. Out of scope for this hotfix.
  • 📧 Booker-facing confirmation-email wording update — the subject line still says "Your iLearn booking is confirmed" and the body still uses "confirmed". The admin-facing labels are what v5.45 renamed. If you want the booker wording updated too, call it out and it's a small follow-up.
🐞 v5.44 — Archived Leads View Had No Exit Path on Reload
  • 📦 Clicking "Archived Leads" and then reloading the page left users stuck with no visible way back to active leads. Live screenshot confirmed: after entering archived view the kanban would correctly filter to just Declined/Approved leads, but the "← Back to Active Leads" banner was gone. Root cause: _openArchivedLeadsView() at ilearnhcc-admin-v2.html ~line 13592 only injects the #v530-archived-banner element when the button is CLICKED. The archived-view flag (il_v530_archived_view) persists in localStorage across reloads, but the banner creation code never re-runs on page init — so any reload, new tab, or return-to-portal session while the archived flag was on dropped the user into the filtered view without the exit button.
  • 🔁 Fix is two-pronged for defense in depth. (1) Made the "Archived Leads" button TOGGLE — if already in archived view, clicking it now calls _v530ExitArchivedView() and returns to active leads; otherwise it opens archived view as before. Button label flips to "📦 Archived View — click to exit" with a violet-tinted background whenever archived view is active, so the toggle behavior is visible. (2) Added _v544EnsureBannerOnLoad() which runs on DOMContentLoaded AND whenever nav('leads') fires (window.nav is wrapped) — it checks il_v530_archived_view and re-injects the banner with its "← Back to Active Leads" button if the flag is true but the banner is missing. Users now have two independent ways to exit archived view regardless of how they got there.
  • 🧩 Shared helper added. Banner HTML is now built by _v544MakeArchivedBanner() — used by both _openArchivedLeadsView() (button-click path) and _v544EnsureBannerOnLoad() (reload/nav path). Keeps the markup in one place so future edits to the banner text don't drift between call sites.
  • 📌 Scope. Single file change: ilearnhcc-admin-v2.html. The primary v5.30 button install path at ~line 13580 is extended with toggle logic and a button-state syncer (_v544SyncArchivedBtnState). The fallback install path at ~line 14370 is mirrored — same toggle behavior, same fallback safety net. _v530ExitArchivedView() also updated to sync the button visual on exit. PORTAL_BUILD bumped to v5.44 in both JS and PHP. Zero schema changes, zero server-side edits, zero CSS changes. Rollback = revert the diffs in the v5.30 block and bump the build constants back.
  • ⚠️ Validation backlog is now at SIX stacked unvalidated releases (v5.39, v5.40, v5.41, v5.42, v5.43, v5.44). Per your standing rule — "never stack unvalidated releases; require specific confirmation before proceeding past one undeployed version" — next session must be a clean sweep validation, not more code. If any fix in the stack has a regression, attributing it across six releases will be significantly harder than attributing across one.
What's NOT in this release (deliberately deferred)
  • ⚙️ Teams link auto-generation on bookings — would require server-side M365 Graph /me/events POST with isOnlineMeeting: true, onlineMeetingProvider: 'teamsForBusiness' and a persisted admin token context. Non-trivial, not in scope for a hotfix.
  • 📧 M365 auto-added calendar invite to info@ilearnhcc.com — Outlook's inbound-email event detection, not something the portal initiates. Outside code reach.
🐞 v5.43 — Lead Generation "Reset All Filters" Stuck on Declined-Only View
  • 🎛️ Clicking "↺ Reset all filters" on Leads looked like it did nothing — the view stayed locked to Declined (Lost) leads only. Live-browser diagnosis with instrumented localStorage.setItem caught the exact culprit on the first try: autoLoadFromServer() at ilearn-admin.js ~line 21060 iterates every il_* key in the server DB response and writes it back to localStorage. The three per-device UI preference keys used by the Leads section (il_v533_lead_stage_filter — which stage to filter to; il_show_archived_leads — whether the Show Archived toggle is on; il_v530_archived_view — whether the Archived-Only banner view is active) were never in the server-side skip list, so they round-trip. Flow of the bug: user clicks "Reset all filters" → resetAllLeadFilters() correctly clears local values → next autoLoadFromServer (triggered on page events, chat poll, or background timer) pulls the three keys back from the server copy that was set earlier from a stage-header click → filter snaps back to "Lost" within seconds, leads view reverts to Declined-only. Same bug pattern as v5.21 (random dark-mode across devices); same fix pattern applied here.
  • 🛡️ Fix is four-pronged, matching the v5.21 theme-sync pattern exactly. (1) Added the three keys to _neverSend in buildDatabase so future sessions stop pushing them to the server. (2) Added them to _neverOverwrite in autoLoadFromServer so even if the server still has stale values from pre-upgrade devices, local cleared state is preserved. (3) Added a v5.21-style one-time cleanup (gated by il_v543_leads_cleanup_done) that fires ?action=delete_key for each of the three keys on the server the first time each device loads v5.43, scrubbing the stale server record. (4) Fixed resetAllLeadFilters() (in the v5.38 reset block) to also remove il_v530_archived_view and the associated v5.30 archived-view banner — the v5.38 author missed this separate v5.30 flag, so even a pre-sync local reset left the page in archived-only mode where _applyArchivedOnlyFilter would hide every non-archived lead after the next render.
  • 📌 Scope. Two files. ilearn-admin.js: three strings added to _neverSend, three strings added to _neverOverwrite, one v5.43 one-time cleanup block added alongside the v5.21 one. ilearnhcc-admin-v2.html: four lines added to resetAllLeadFilters() (one for il_v530_archived_view removal, three for banner removal). PORTAL_BUILD bumped to v5.43 in both JS and PHP. Zero schema changes. Rollback = revert the diffs, revert the build constants, optionally clear localStorage.il_v543_leads_cleanup_done on any device that ran the cleanup.
  • 🔍 Diagnosis methodology worth calling out. Root cause was found by monkey-patching Storage.prototype.setItem with a stack-capturing wrapper, clearing the three keys, calling renderLeads(), and reading back the captured writes. The stack trace pointed directly at autoLoadFromServer line 21060 — no guessing required. Preserve this pattern for any future "localStorage key keeps coming back after I delete it" bug: it takes roughly 20 lines of JS and rules out half of what would otherwise need source-diving.
  • ⚠️ Validation backlog is now at FIVE stacked unvalidated releases (v5.39, v5.40, v5.41, v5.42, v5.43). Next session MUST be validation, not more code. Per your own standing rule — "never stack unvalidated releases; require specific confirmation before proceeding past one undeployed version" — this is now the exact failure pattern that caused the two prior regression incidents. If any validation surfaces a new regression, attributing blame across five releases will take longer than usual.
🐞 v5.42 — Stacked Profile Photos in Greeting Pill (follow-up to v5.41)
  • 👥 Topbar greeting pill was rendering TWO stacked profile photos. After v5.41 deployed, a live screenshot surfaced a distorted-looking image inside the greeting pill — not a corrupt photo as it first appeared, but two correctly-loaded copies of the user's profile photo overlaid at different offsets. Live DOM inspection found the greeting pill (#topbarWelcome) contained both #topbarAvatarCircle (the canonical modern 26×26 avatar pair — #topbarAvatarImg + #topbarAvatarInitial — hardcoded in the HTML at line 1670) AND #topbarUserPhoto (a 32×32 legacy wrapper with an inner <img>). The legacy wrapper was being dynamically injected on every DOMContentLoaded by a v4.89-era hook at ilearn-admin.js line 17684–17694 that predates the canonical avatar circle. _refreshSessionPhoto() faithfully populates both surfaces. Before v5.41, _refreshSessionPhoto() was only called from rare paths (profile-save, onboarding photo upload, mid-session photo swap), so the duplication was invisible to most users. v5.41 turned on reconciliation on every page load for every logged-in user — and the duplication became visible for anyone with a profile photo. v5.42 neutralises the legacy injection hook (block commented out with rollback instructions inline). #topbarAvatarCircle remains the sole topbar avatar surface. updateTopbarPhoto() and the #topbarUserPhoto branches inside _refreshSessionPhoto() silently no-op because their target element no longer exists. No other call sites depend on #topbarUserPhoto being in the DOM.
  • 📌 Scope. One surgical edit to ilearn-admin.js: lines 17684–17694 (the injection hook) commented out and annotated. PORTAL_BUILD bumped to v5.42 in both JS and PHP for the x-ilearn-build header. Zero HTML changes other than title tag + sidebar badge + this release notes card. Zero CSS. Zero schema. Zero migration. Rollback = uncomment the block.
  • ⚠️ Validation backlog is now at FOUR stacked unvalidated releases (v5.39, v5.40, v5.41, v5.42). Next session MUST be end-to-end QA on all four, not more code. Critical paths to walk: topbar shows exactly one profile photo (v5.40+v5.41+v5.42), Everyone chat badge is clear after the repair (v5.40), next test booking fires 2 emails not 4 (v5.40), #srvDot is 10×10 on phone not 44×44 (v5.40), reschedule modal works (v5.39). If any regression surfaces, attributing blame across four stacked releases will be harder than usual.
What's NOT in this release (deliberately deferred)
  • ⚙️ Teams link auto-generation on bookings — would require server-side M365 Graph /me/events POST with isOnlineMeeting: true, onlineMeetingProvider: 'teamsForBusiness' and a persisted admin token context. Non-trivial, not in scope for a hotfix.
  • 📧 M365 auto-added calendar invite to info@ilearnhcc.com — Outlook's inbound-email event detection, not something the portal initiates. Outside code reach.
🐞 v5.41 — Stale-Session Photo Reconciliation (follow-up to v5.40 Bug 1)
  • 👤 Topbar profile photo — the other half of the bug. v5.40 fixed the login-handler branch that was unconditionally hiding #topbarAvatarImg and showing an initial letter. Live QA after deploying v5.40 surfaced that the topbar still showed "A" for Anthony Hosein. Diagnosis: the session object (sessionStorage.il_session) had no photo field, yet the users roster (gd('users')) clearly contained Anthony's photo URL at https://www.ilearnhcc.com/repo/photos/user_1775608140369_…. v5.40's fix only read from the session — so a user whose session was established before their photo was added to the users table (which happens when an admin uploads via User Management, or when the photo was added on a different device) stayed permanently on an initial letter. Re-logging in wasn't triggering the login handler either because the admin portal auto-resumes from sessionStorage on reload, bypassing _enterPortalUI() entirely. v5.41 adds reconciliation at two spots: (1) the login-handler photo-copy block falls back to a users-roster lookup by email when the auth response's user.photo is empty; (2) the session-resume setTimeout at ilearn-admin.js ~line 22884 now calls _refreshSessionPhoto() with no arguments — that function already has the roster lookup built in, it writes the photo back to sessionStorage, and refreshes every surface that displays the user's photo (topbar avatar pair, #topbarUserPhoto slot, any open chat bubbles). Idempotent: no-op when the session already has a photo or the users roster has none. Runs once per page load.
  • 📌 Scope. Two surgical additions to ilearn-admin.js. Zero HTML changes. Zero PHP changes other than the PORTAL_BUILD bump for the x-ilearn-build header. Zero schema changes. _PORTAL_BUILD bumped to v5.41 in JS + PHP. Rollback = revert the two diffs + revert both build constants.
  • ⚠️ Validation backlog. v5.39 + v5.40 + v5.41 are now three stacked hotfixes, none of them formally QA'd end-to-end. Next session should be validation, not new builds. Walk through: Bug 1 (v5.40+v5.41 photo), Bug 2 (v5.40 chat badge + retroactive cleanup), Bug 3 (v5.40 booking emails), Bug 4 (v5.40 mobile srvDot), and confirm each is resolved before any new scope.
🐞 v5.40 — Four Root-Caused Bug Fixes (Login Avatar · Chat Badge · Booking Emails · Mobile Dot)
  • 👤 Topbar profile photo no longer resets to an initial on every login. Root cause: the login-success branch in ilearn-admin.js around line 2313 unconditionally set #topbarAvatarImg.style.display='none' and populated #topbarAvatarInitial with the first letter of the user's name, regardless of whether sessionUser.photo existed. The User Management row rendered photos correctly (via the renderUsers override at line 17148) and the "Save Profile" modal flow handled photos correctly (via mpSaveProfile at line 12042), but the login path had never been updated to honour photos. Every fresh login wiped the topbar back to an initial letter until you opened My Profile and re-saved. v5.40 mirrors the mpSaveProfile conditional into the login handler: photo present → show image, else → show initial. Also invokes _refreshSessionPhoto(sessionUser.photo) at the tail of the login block so chat bubbles and any other photo surfaces sync immediately without a second trigger.
  • 🔕 Team Chat false unread badges on Everyone — full root-cause + one-time retroactive repair. Live-browser diagnosis found Everyone showing "3" unread while every broadcast message in il_chat_messages for that period was either a _notify record (booking requests, parent inquiries) or had userId==='website' — all of which the v5.24 / v5.35 filter chain is supposed to drop before the unread bumper runs. Confirmed via synthetic probe that the filter chain IS currently live (sending a fake website-origin message produced no unread bump, sending a real broadcast did). So the "3" was stale residue from earlier sessions where a race condition let website messages slip past an incomplete filter. Worse, il_chat_messages contained 122 orphaned website-origin entries even though il_v482_events_migrated=1 and il_v464_chat_cleaned=1 were both set — those migrations marked themselves done but the il_system_events store was empty. v5.40 adds _v540RepairChatStore(), a one-time idempotent cleanup gated by localStorage.il_v540_chat_store_repair that runs 1.5s after chat init: scans il_chat_messages, moves every record matching _notify / userId==='website' / userName==='Website' / isSystem===true / userName.indexOf('🌐')!==-1 into il_system_events (dedup by id, capped at 1000), persists the trimmed chat store, and zeros out il_chat_unread_per_thread.all so the stale counter clears on next load. Also hardens the v5.24 filter (both _filterForActive in renderChatMessages and the unread-bumper in appendChatMessages) to reject the same expanded pattern set, preventing any future race conditions from slipping similar records through.
  • 📧 Website bookings no longer fire 4 emails — cut to 2. Root cause in ilearnhcc-website.html confirmBooking() at line 2968: a single meeting booking fired both POST ?action=public_append_lead (via pushLeadToServer(_lead)) AND POST ?action=public_append_booking. The lead endpoint sends an inquirer auto-reply + an admin "New lead" notification; the booking endpoint sends an inquirer auto-reply + an admin "New booking with Approve/Decline magic-links" notification. That's 4 emails total per booking — 2 near-duplicates for the inquirer, 2 near-duplicates for the admin, plus a redundant FCM push on the lead channel. v5.40 adds a server-side guard in public_append_lead (ilearn-db.php ~line 5731): when the incoming source field is "Website Booking" (case-insensitive), skip the entire email-and-FCM block. The lead record is still written to il_leads so the kanban view is unchanged; only the duplicate notifications are suppressed. Net: booking submitter gets 1 booking-specific email with meeting details, admin gets 1 booking-specific email with Approve/Decline buttons, admin FCM gets the booking-channel ping. Not addressed this release: automatic Teams link generation (would require server-side M365 Graph /me/events POST with isOnlineMeeting: true and a persisted admin token context; deferred). The "calendar invite added to info@ilearnhcc.com automatically for M365" behaviour is Outlook's inbound-email event detection, not portal-initiated — noted here for the record, outside our code's reach.
  • 🟢 Giant green dot in the sidebar on mobile — blown up by a too-broad touch-target selector. Root cause at ilearnhcc-admin-v2.html line 662: the mobile CSS block (@media (max-width: 768px)) had a selector button, input[type="button"], input[type="submit"], [onclick], .clickable, .btn, .act, .cb, .ec-folder with min-width:44px !important; min-height:44px !important. The bare [onclick] term matches ANY element with an onclick handler — including the #srvDot span in the sidebar header (a 10×10 px decorative server-status indicator next to "iLearn CRM" that has onclick="testServerConnectionFull()"). On phone, the 44px touch-target minimum inflated that 10×10 span to 44×44, and because the inline style set border-radius:50%, the result was an enormous green circle dominating the sidebar header. Fix: narrow the selector to specific element types (button, a[onclick], [role="button"], div[onclick], li[onclick]) with :not([data-no-touch-target]) exclusion and explicit :not(#srvDot) on the div[onclick] branch, plus a belt-and-suspenders #srvDot{width:10px!important; height:10px!important; min-width:10px!important; min-height:10px!important} mobile rule lower in the same block. The data-no-touch-target attribute is now available as an escape hatch for any future decorative inline clickable that shouldn't get the 44×44 minimum.
  • 📌 Scope. Four surgical fixes across three files. ilearn-admin.js: login-handler avatar block. ilearnhcc-admin-v2.html: mobile CSS [onclick] selector + #srvDot clamp + v5.24 filter hardening + _v540RepairChatStore IIFE member. ilearn-db.php: public_append_lead email-skip guard. PORTAL_BUILD bumped to v5.40 in both JS and PHP. No schema changes. No migration required other than the self-gating one-time cleanup. Rollback: revert the four diffs, revert the build constants, clear localStorage.il_v540_chat_store_repair on any client that ran the cleanup if you want it to re-run post-rollback.
What's NOT in this release (deliberately deferred)
  • ⚙️ Teams link auto-generation on bookings — would require server-side M365 Graph /me/events POST with isOnlineMeeting: true, onlineMeetingProvider: 'teamsForBusiness' and a persisted admin token context. Non-trivial, not in scope for a hotfix.
  • 📧 M365 auto-added calendar invite to info@ilearnhcc.com — Outlook's inbound-email event detection, not something the portal initiates. Outside code reach.
🛠️ v5.39 — Hotfix Bundle (Reschedule / M365 Email Body + Mark-Read / Booking Time / Calendar Cleanup)
  • 🔁 Reschedule modal — "booking not found" bug killed, plus system-wide window.gd audit. The v5.38 reschedule block looked up the booking via window.gd('bookings'), but gd is declared at the top of ilearn-admin.js as const gd = function(k){…}. const at the top level does not attach to window — so window.gd was undefined, the IIFE fell through to its empty-array fallback, and .find() returned undefined. Clicking 📅 Reschedule opened the modal with an empty summary and no booking id, and Save did nothing. Verified live in the browser: typeof gd === 'function' but typeof window.gd === 'undefined' — only gd has this issue; sd, toast, openM, renderLeads, renderBookings, publishToServer are all declared as function statements so they stay on window. The v5.38 IIFE was the most user-visible victim, but a grep found 11 more code sites in older IIFE blocks (v5.33–v5.36, leads/tasks/social/blocked-dates/chat) using the same broken pattern — they fell through silently to empty arrays and nobody noticed because those paths had their own fallbacks. v5.39 fixes all 19 occurrences system-wide (8 in v5.38 block + 11 in older blocks). Release-notes prose still shows window.gd as example text; that's intentional.
  • 📧 M365 email body — the other half of the truncation story. v5.38 fixed the IMAP MIME walker, but the M365 (Outlook) path in ecOpenMessage was never fetching the full body at all. The list fetch at line 26429 only requests bodyPreview (Graph caps that at ~100 chars), and the open handler then rendered msg.body || msg.previewmsg.body is never populated, so every M365 email was displayed as its first 100 chars and then cut. v5.39 rewrites the M365 branch to fire a GET /v1.0/users/{email}/messages/{id}?$select=body,replyTo,toRecipients,hasAttachments on open, render the full body.content as HTML or pre-formatted text based on body.contentType, and pull the attachments list via the separate /messages/{id}/attachments endpoint when hasAttachments is true. Shows a "⏳ Loading message…" placeholder while the fetch is in flight so the header appears instantly.
  • ✉️ M365 mark-read — the helper that was never defined. ecOpenMessage line 27022 and ecToggleRead line 27112 both called ecM365MarkRead(msgId, readState), but the function literally did not exist in ilearn-admin.js. It threw a silent ReferenceError (the call was inside an async path so it was swallowed), msg.seen was only flipped locally, and the next folder refresh pulled the unread state back from Graph. v5.39 adds the helper using the exact same PATCH /messages/{id} {isRead:true} pattern that ecBulkMarkRead (line 26947) has been using correctly all along. The v5.37 changelog claiming "M365 path works correctly" was wrong — it never worked.
  • Booking time field — reject of "9:00 AM". The website picker sends time strings like "9:00 AM" / "1:00 PM", but public_append_booking in PHP validated with /^\d{2}:\d{2}$/ which only accepts 24-hour HH:MM. Every 12-hour submission failed the regex, $time stayed empty, and the booking email showed "TBD" under Time even when the user had picked 1:00 PM. v5.39 widens the regex to accept H:MM AM / HH:MM PM (optional space, optional periods) and converts to canonical 24-hour for time. A new timeLabel field stores the original human label — mirrors how dateLabel already works. The branded email template now prefers timeLabel over time, so recipients see "1:00 PM" not "13:00".
  • 🧹 Calendar page — stale "moved to Settings" card removed. The integration-moved reminder card has been on the Calendar page since v3.54; the feature it points to has been live for ~100 releases and the card is now just noise. Deleted cleanly; Scheduled Meetings + Blocked Dates sections shift up.
  • 📌 Scope. In-place edits only — no new v5.39 IIFE block. Four ilearn-admin.js edits (PORTAL_BUILD, PORTAL_BUILD_DATE, ecM365MarkRead function added, ecOpenMessage M365 branch rewritten). Three ilearnhcc-admin-v2.html edits (title/sidebar/readme version bumps, one div-removal on Calendar page, 8 window.gdgd replacements inside v5.38 IIFE). Two ilearn-db.php edits (time format parsing + timeLabel field, PORTAL_BUILD bump). One sw.js edit (CACHE_NAME bump). Zero schema changes (timeLabel is an additive field on new records, existing records without it are untouched). Zero changes to ilearnhcc-website.html — the website side was already correct in v5.38.
  • ⚠️ Known issue NOT fixed here — broken logo in Gmail. The logo file at https://www.ilearnhcc.com/ilearn-new-logo-design.png returns HTTP 200 with a valid 17.67 KB image/png, served directly by Apache (Cloudflare isn't even in the request chain for that path). When Gmail shows a broken image, the cause is client-side: either the "Ask before displaying external images" setting, or Gmail's image-proxy rate-limit for a low-reputation sender domain. Proper server-side fix requires converting _pub_internal_mail to CID-inline attachments (multipart/related) — a bigger refactor than this hotfix warrants. For now, click Gmail's "Display images below" once and it'll be sticky for the sender.
📨 v5.38 — Three Deep Fixes (Email Body / Leads Filter State / Booking Reschedule)
  • 📧 Email body truncation — root-caused recursive MIME walker in PHP. The email_body endpoint's MIME walker only iterated top-level multipart sections. Modern emails (forwards, system notifications, Gmail/M365 bodies) use nested structures like multipart/mixed → multipart/alternative → (text/plain, text/html), so the body part at depth 2+ was never reached and only the first ~130 chars of the multipart wrapper were returned. v5.38 replaces the flat loop with a recursive walker that descends every nested level using dotted section numbers ("1", "1.1", "2.1"…). Charset conversion to UTF-8 is applied when a non-UTF-8 charset is declared. A new plain_legacy field preserves the OLD walker's result as a safety fallback — if the new walker regresses on an unusual MIME shape, the client falls back to the legacy field instead of showing "(empty)".
  • 🎯 Leads view filter state — unstickable filters fixed + visible "Reset all filters" button. Root-caused a bug in v5.33's applyLeadStageFilter(): when the stage filter was cleared, the function returned early without resetting tr.style.display / col.style.display back to empty — so rows and kanban columns stayed hidden even though no filter was active. Worst affected the archived-leads flow: toggle archived → click stage filter → exit archived → stage filter persists but is partially applied, leaving some leads invisible with no way to get them back except a full page reload. v5.38 rewrites the filter to unconditionally reset display state on every call, then apply the current filter (or not) cleanly. Plus a new purple pill button "↺ Reset all filters" appears above the Leads page whenever ANY filter is active (stage, archived, or both) — one tap clears everything.
  • 📅 Website booking date/time — ISO canonical format + full reschedule + calendar update path. Four-part fix:
    • Part A — Correct date format on submit. The website was sending _selDate ("Apr 20, 2026") as the booking's date field. The admin calendar mini-grid matches bookings by ISO date (2026-04-20), so submitted bookings never appeared on the right calendar day. v5.38 sends both date: _selIso (canonical YYYY-MM-DD) and dateLabel: _selDate (human-readable). PHP accepts both; admin UI renders from ISO.
    • Part B — Reschedule modal. New "📅 Reschedule" button on every pending booking. Opens a modal with date/time pickers, a comments field for the admin to note WHY the reschedule happened, and a "Save & Notify" action. Existing booking record is updated with new date/time, and the old date/time is stamped in a new reschedHistory array (so the full trail is preserved for audit).
    • Part C — Auto-send email to the booker. On save, a branded reschedule email fires automatically using the existing sendEmailAny path + _styledBrandHTML template. Subject: "Updated time for your meeting — [Agency]". Body shows the new date/time prominently, the admin's comments (if entered), and a "reply to this email with alternative times" fallback.
    • Part D — Teams/Google calendar event update. If the booking has an existing eventId (created when originally confirmed with a calendar event), v5.38 fires a PATCH to /v1.0/users/{email}/events/{eventId} (M365 Graph) or /calendar/v3/calendars/primary/events/{eventId} (Google) with the new start/end datetimes. Graph and Google both auto-send update notifications to attendees from their servers, so the attendee gets a "this meeting has been updated" calendar invite in addition to the branded email from step C. If no eventId exists (booking was confirmed without a calendar event), the branded email is the only notification.
  • 🧪 QA pass before delivery. Syntax validation across all surfaces, caller tracing for applyLeadStageFilter, and integration check against prior renderLeads wraps (v5.32 / v5.33 / v5.34 / v5.36 chains). No existing functionality rewritten — all fixes additive except the PHP MIME walker which is a full-replacement with a legacy-fallback field for safe rollback.
  • 📌 Scope. One <style id="v538-css"> + <script id="v538-js"> pair in HTML, a reschedule-booking modal added to HTML, one PHP function replaced (email_body recursive walker), one PHP record field added (dateLabel), one website JS edit (ISO date). PORTAL_BUILD bumped to v5.38. Rollback = delete v5.38 block + revert 2 PHP edits + 1 website edit + 1 ilearn-admin.js edit (email body fallback). Breaking rollback note: any bookings submitted while v5.38 is live will have ISO dates; rolling back to v5.37 will show them as ISO in the admin UI (not "Apr 20, 2026") until manually touched. Low stakes, one-time cosmetic issue.
🔧 v5.37 — Five Targeted Fixes (Email Read / Calendar Cleanup / Status Bars / Lead Categories / No More Prefills)
  • ✉️ Email client — clicking a message now actually marks it read (IMAP). Root-caused a pre-existing bug in ecOpenMessage(). Two paths exist: M365 (working correctly — fires ecM365MarkRead) and IMAP. The IMAP path only set msg.seen=true locally and re-rendered the list; there was NO server-side call, so the message would come back unread on the next folder refresh and the bold "unread" styling would return. v5.37 adds a mirror of the M365 behavior: when a previously-unread IMAP message is opened, an email_flag POST with op:'read' fires to the backend so the seen state persists. Uses the same endpoint ecToggleRead has always used (line 27089) — no new backend code, same auth, same payload shape.
  • 📅 Availability Settings redirect card removed from Calendar page. The v5.34 card that said "Meeting duration, buffer, available hours & days have moved to Settings → Calendar" is no longer visible. The hidden form inputs it was wrapping stay in the DOM because renderCalendarSettings() and saveCalSettings() in ilearn-admin.js still read #calDuration/#calBuffer/#calStart/#calEnd/#calMon-Fri directly — removing the inputs would crash those functions. CSS-hiding the visible card is the safe surgical fix. The Calendar page now shows just Blocked Dates + Scheduled Meetings + Google/M365 integration notice.
  • 📊 Record-count status bar on every data table. The "11 subscribers" footer under the Subscribers table was the only one in the portal. v5.37 replicates that pattern everywhere: Contacts, Leads, Tasks, Suppliers, Invoices, Parent Invoices, Campaigns, Blog, Testimonials, Inventory, Bookings (Scheduled Meetings), Purchase Orders, Users all get a subtle "N items" footer at the bottom of their data card, auto-updating as rows are added/removed. Plural is handled (e.g., "1 contact", "23 contacts"). Implementation: one JS injector that registers each page by tbody id and runs on render + MutationObserver. Styled identically to the existing Subscribers bar for consistency.
  • 🏷️ Lead Type — custom categories that persist across devices. v5.26 brought this to Invoice categories, v5.30 brought it to Supplier categories. v5.37 extends the same pattern to the Lead Type dropdown in Add Lead / Edit Lead. Click the dropdown → at the bottom, a new purple italic row "+ Add new lead type…" appears → click → prompt for the name → saved to il_lead_types and immediately selected on the form. Custom entries appear with a ★ prefix on subsequent opens and sync across devices via the normal data pipeline (same as other categories).
  • 🧹 No more data prefilling on postal / country / province fields. Eight locations across the portal were auto-prefilling "Canada" / "ON" / "Ontario" on contact, supplier, and employee forms. Multiple users reported this was confusing — they'd save a record thinking the field was their entry when it was just the default. v5.37 removes all prefills on these fields system-wide: HTML value="Canada" attributes removed from supplier + contact country inputs; HTML selected attributes stripped from Ontario default on Contact province + Employee province selects (first option is now an em-dash placeholder); JS fallbacks in contact-save (|| 'Canada'|| ''), contact-reset (.value='Canada'.value=''), contact-edit-populate (|| 'Canada'|| ''), and supplier-save (|| 'Canada'|| '') all switched to empty defaults. Fields now require explicit user entry. Existing records keep their stored values unchanged; only net-new entries see the difference.
  • 📌 Scope. One <style id="v537-css"> + <script id="v537-js"> pair for items 2–4, plus targeted edits in 4 HTML locations and 5 JS locations for item 5, plus one surgical 18-line addition to ecOpenMessage for item 1. Zero PHP changes. Zero schema changes. Zero changes to ilearnhcc-website.html. PORTAL_BUILD bumped to v5.37.
📱 v5.36 — Full Mobile UX Overhaul (20 Findings from v5.35 Audit)
  • HIGH severity fixes (5):
    • 🎯 H1 — Bulk action bars no longer overflow the phone viewport. All 8 bulk-action bars (contacts/suppliers/leads/subscribers/campaigns/blog/testimonials/payments) had a hardcoded min-width:380px and absolute positioning that broke at ≤380px viewports and collided with the bottom nav. v5.36 overrides to full-width-minus-margin at ≤480px, pins above the bottom nav (adds bottom: calc(56px + safe-area)), uses flex-wrap:wrap so long action lists flow, and shrinks button padding to stay tappable.
    • 🔘 H2 — Modal footer buttons no longer escape the viewport. All .mft footers get flex-wrap:wrap; gap:8px at ≤420px, Cancel/Save buttons become full-width 50/50 below that, and buttons enforce a 44px minimum height for reliable tap targets.
    • 📐 H3 — Form grids collapse cleanly on phone across the whole portal. 15 multi-column form layouts (.fgrid, inline grid-template-columns:repeat(3…), etc.) now force 1-col at ≤600px. Labels + inputs stack; no more crushed 3-col fields with truncated labels. Affects New Invoice, Edit Contact, Blocked Date, Add Meeting, Edit Lead, Add Payment, and ~9 others.
    • 📍 H4 — FAB stack no longer dominates the phone screen. The 4 floating action buttons (💬 chat, 📓 quick notes, 🧮 calculator, 🔔 alerts) were stacking vertically over ~240px of the bottom-right corner — 36% of an iPhone SE screen. v5.36 installs a FAB orchestrator: on phone, only the highest-priority FAB is visible by default; a single + More toggle expands the stack on demand. Automatically hides all FABs when the on-screen keyboard opens. Respects the bottom nav position so FABs never overlap it.
    • 📋 H5 — Leads kanban gets a phone-optimized list view. The 5-column horizontal-scroll kanban was brutal on phone (users reported swiping past cards they wanted to tap). v5.36 adds a Stage List mobile view: on phone width, Leads defaults to a vertical list grouped by stage with collapsible stage sections, count badges, and one-tap stage filtering via the v5.33 clickable stage headers. Kanban view remains available via the existing view toggle for wider viewports or landscape tablets.
  • MEDIUM severity fixes (6):
    • ⚙️ M2 — Settings tabs horizontal-scroll on phone. 8 tabs × 110px min-width wrapped awkwardly. Now they scroll horizontally with snap-align, the active tab auto-scrolls into view on switch, and a subtle edge-fade hint shows when there's more to the right. Same UX pattern Social Hub tabs use (v5.33).
    • 🧩 M3 — Manage Widgets picker modal — 1-col on phone. 14 widgets in a 2-column grid was cramped. Single column at ≤600px with full-width toggle rows.
    • 🔍 M4 — Search inputs get mobile-optimized keyboards. 25 search inputs across the portal had type="text" + generic keyboard. v5.36 JS stamps type="search" + inputmode="search" + autocomplete="off" on every input[placeholder*="Search"]. iOS now shows the search keyboard with a dedicated "search" go-key and a clear-x inside the field. Android shows the magnifier icon.
    • 📊 M5 — Stats bars — smart 2-up + truncation-safe. Stats cards at 2-up on phone were truncating long labels like "Overdue invoices" and "Archived leads". v5.36 shortens labels to .lbl single-word equivalents on phone (e.g. "Overdue", "Archived"), tightens padding, and drops sparklines on phone so the number is always readable.
    • 👆 M6 — Swipe-to-delete on cardified table rows. On phone, dragging a table row left ~60px reveals a red Delete button. Fires the existing row-delete handler. Covers Contacts, Leads, Tasks, Suppliers, Subscribers, and any table with a data-*-id attribute on <tr>. Safe: requires ~60px movement so accidental swipes don't fire.
    • 🎢 M7 — prefers-reduced-motion honoured. FAB glow, chat pulse, v5.32 field-flash, v5.33 filter-pill slide, v5.34 quote-widget gradient — all animations suppressed when the user's OS has Reduced Motion enabled. Accessibility win.
  • LOW severity fixes (9):
    • 🖋️ L1 — PO signature on phone — shows a "Signature works best on a wider screen" hint with an "Open on Desktop" CTA since the canvas is unusable at phone width.
    • 🏷️ L2 — Topbar page title — max-width raised from 82→160px at ≤420px, from 110→220px at ≤768px, using the actual section name from the nav instead of the page's generic heading.
    • ↩️ L3 — Modal-over-modal back button — when a modal opens on top of another modal, the new modal's close button flips to a "← Back" arrow so users aren't confused by the stacked state.
    • 📅 L4 — Calendar day cells — event dots grow from 6px→10px on phone, a small "+N" badge shows when more events than dots fit, and tap target is raised to 48px.
    • 🎭 L6 — Emoji prefix stripped from page title on phone — frees up ~28px of horizontal space; the emoji still appears in the sidebar nav.
    • 🖼️ L7 — Modal images + tables capped at max-width:100% — a 2400px provider photo or lead attachment no longer blows out modal width on phone.
    • 👥 L8 — Team Presence widget — vertical list on phone (was a horizontal flex-wrap that created inconsistent alignment).
    • 📝 L9 — Autocomplete attributes on form fields — JS stamps autocomplete="email" / "tel" / "name" / "address-line1" on matching input types. iOS AutoFill + password manager suggestions now work across all contact/lead/invoice forms. Purely additive.
    • ⬇️ L10 — Pull-to-refresh on data pages — on Leads / Contacts / Tasks / Invoices / Inventory / Bookings, pulling down from the top of the page triggers the same "force refresh from server" path the ↻ buttons use. Only activates on mobile.
  • Deliberately NOT in v5.36:
    • M1 (breakpoint-consolidation refactor) — merging 38 @media blocks into one is an architectural rewrite, not a patch. Future planning session.
    • L5 (video-call mobile validation) — that's testing work, not code.
  • 📌 Scope. One <style id="v536-css"> + <script id="v536-js"> pair at the end of the admin HTML. Zero PHP changes. Zero schema changes. Zero changes to ilearnhcc-website.html. PORTAL_BUILD bumped to v5.36. Rollback = delete the v5.36 block + revert PORTAL_BUILD. This is the largest single mobile release in the portal's history — if you encounter any specific page regression, use the rollback procedure; v5.35 behavior is preserved underneath.
🐞 v5.35 — Two Root-Caused Bug Fixes (Mobile Tables + False Chat Alerts)
  • 📱 Mobile table views — half of the tables were never getting the card layout. The v5.12 mobile-enhance shim only targeted .table-wrap table in both CSS and JS, but the portal uses two wrapper classes: .table-wrap (15 tables) and .tw (6 tables, including Contacts, the Leads table view, Social Post History, and Campaigns). Those six have been silently falling through to horizontal-scroll mode with forced min-width: 820px on phones for 18 months. v5.35 extends the shim to cover .tw table as well — same card layout, same auto-generated column labels, same action-row footer. Also overrides inline min-width and table-layout:auto styles on the child <table> with !important so the v5.35 CSS actually wins the cascade (without this override, horizontal scroll would still happen even with display:block).
  • 🔕 False "chat" notifications on website alerts — root-caused and fixed. Every time a new lead or subscriber arrived from the website, the user saw TWO notifications fire: one via the proper 🔔 Alerts pill (v4.62 il_notifications system — correct), and one via the 💬 chat FAB glowing + a fake "🌐 Website" system message appearing in the chat feed + _chatUnread++ incrementing. The second path was legacy v3.x code in _checkWebsiteNotifications() at ilearn-admin.js:1735-1752 that predates the v4.62 notifications architecture. It bypassed the _isSystemMessage classifier because it stamped messages with userId:'system' + userName:'🌐 Website' (with an emoji prefix) — the classifier checks for userId==='website' / userName==='Website' (no emoji). It also used isSystem:true instead of system:true. v5.35 wraps _checkWebsiteNotifications by intercepting appendChatMessages() and _chatUnread for the specific '🌐 Website' pattern so the useful render reactions (re-rendering Leads/Contacts/Subscribers pages when new data arrives) and the transient toast still fire — but the chat-side spam stops cold. Also retroactively cleans any accumulated 🌐 Website system messages from il_chat_messages on load so old false entries disappear.
  • 🧪 QA notes before delivery. This release went through a four-step code-level QA pass: (1) syntax validation on all surfaces (node --check, PHP lint, BS4 inline-script scan), (2) caller tracing for _checkWebsiteNotifications — both call sites (publishToServer post-sync hook at :21772, server-data-received hook at :31513) confirmed safe to continue invoking with the wrap in place, (3) full table enumeration — 22 <table> tags, classified each by wrapper class or standalone, confirmed no regression on standalone tables (like the Scheduled Meetings inner table which relies on its own layout), (4) integration check against v5.33 and v5.34 wraps for the same functions — no overlap, no double-wrap collision. Found one secondary issue during QA: the "Send to all" modal's inner table (pg-campaigns recipients) is inside .tw but is a one-shot modal so users rarely see it on mobile — noted but not addressed this release.
  • 📌 Scope. One <style id="v535-css"> + <script id="v535-js"> pair, zero PHP changes, zero schema changes, zero changes to ilearnhcc-website.html. PORTAL_BUILD bumped to v5.35 for the x-ilearn-build header. Rollback = delete the v5.35 block + revert PORTAL_BUILD. Validation backlog now at 11 changesets; I'll say it one more time: the next session should be validation, not new builds.
🚀 v5.34 — Six QoL Items (Calendar Cleanup / Decline Email / Dup-Lead Fix / Layout Stability / Quote Widget / Mobile Topbar)
  • 🧹 Availability Settings removed from main Calendar page — lives only in Settings → Calendar. The Calendar page now shows Blocked Dates + Scheduled Meetings + Booking Requests. Availability (meeting duration, buffer, hours, weekdays) has a single source of truth at Settings → 📅 Calendar. Eliminates the v5.33 ambiguity where two identical forms could drift out of sync.
  • ✉️ Confirm + Decline booking emails with branded copy. Pre-v5.34 the Confirm button sent a branded email but Decline only updated the status silently — no email to the requester. v5.34 fires a branded decline email using the agency template (warm wording suggesting alternative dates), and the Confirm email is unchanged. Both are idempotent (the existing _confirmationEmailSent guard extended to cover _declineEmailSent), so clicking Confirm→Decline→Confirm doesn't spam the requester.
  • 🔁 Provider-application lead duplicates — root-caused and fixed. Website provider applications pushed a lead once immediately, then pushed again after the file-upload callback completed to attach the file URL. The PHP public_append_lead endpoint was append-only — it ignored any client-supplied id and minted a fresh server-side id every call, so the second push created a duplicate instead of updating the first. Fix: public_append_lead now upserts by client-supplied id when present (merging new fields like fileUrl onto the existing record, preserving admin-modified fields like stage, assigned, notes). Also catches email-based near-duplicates within a 5-minute window. Existing duplicates in the DB can be cleaned via the 🔀 Find Duplicates tool in the sidebar.
  • 📌 Leads page "moving on its own" — root-caused and fixed. An 8-second background poll (_startWebsitePoll) was calling renderLeads() which did a full board.innerHTML = … wipe-and-rebuild on every tick, even when the data hadn't changed. This caused visible layout shifts, scroll position resets, and flicker. Fix: v5.34 wraps the poll path with a content hash (sorted lead ids + stages + updated timestamps) — if the hash matches the last render, the re-render is skipped entirely. When a re-render does happen, window.scrollY and kanban column scrollLeft are preserved across the rebuild via requestAnimationFrame. Also suppresses poll re-renders while the user is actively interacting (mouse in kanban, modal open, focused input).
  • 💬 New dashboard widget: Motivational Quote of the Day. Rotates through 100 curated quotes keyed by day-of-year so every device shows the same quote on the same date and the quote changes at midnight. No network calls — the quote list is inline. Widget respects the existing 🧩 Manage Widgets visibility system (toggle on/off like any other widget). Lives in the Quote Widget card with violet gradient border, quote text in italic, author attribution below, and a small "Day N of 366" footer.
  • 📱 Mobile topbar shows the logged-in user name. v5.22 hid #topbarWelcome on phone to free space; v5.34 brings it back in a compressed form — first name only, condensed pill style, 44 px tap target, tappable to open your profile. The overall topbar layout is tightened: hamburger · page title (truncated to 110 px) · stretch · user pill · 🔔 bell · 💬 chat. Everything else (mail, sync dot, search) moves to More ≡. Result: no more overflow, user identity always visible.
  • 📌 Scope. One <style id="v534-css"> + <script id="v534-js"> pair, an upsert-by-id patch to public_append_lead in ilearn-db.php, and the removal of the Availability Settings <div class="card"> from pg-calendar. PORTAL_BUILD bumped to v5.34. Zero schema changes (the id field on leads already existed; we just stop discarding it). Rollback = delete the v5.34 block + revert the two file edits. Validation backlog now at 10 changesets deep (v5.28 → v5.34); I continue to recommend a dedicated validation session before further builds.
🚀 v5.33 — Clickable Filters + Mobile Polish + Social Audit + Calendar Reorg + Booking Approvals
  • 🎯 Clickable lead stage headers + Overdue Actions on dashboard. The New / Contacted / Pending / Approved / Declined column headers on the Leads kanban are now clickable buttons — tap a header to filter the view to just that stage; tap again (or the 🎯 All stages pill that appears) to clear. Dashboard Open Tasks widget is also clickable: tapping ⚠️ N overdue navigates to Tasks with the overdue filter pre-applied. Invoice Overdue filter works the same way from the Invoices stats bar. All filters use the same persistence pattern as v5.32's archived filter — they survive every renderLeads() / renderTasks() / renderInvoices() re-render via wrap.
  • 📱 Mobile dashboard widgets — one per line, full contents. At ≤ 768px viewport widths, all dashboard widgets switch to a single-column layout (instead of 2-column grid which squeezed contents). Cards no longer truncate, mini charts render full-width, activity feed and growth chart each get their own row. The 🧩 Manage Widgets pill stays condensed as a floating action button. Sticky stats bars on Leads, Tasks, Invoices, and Campaigns pages also stack vertically on phone so every number is readable.
  • 🗂️ Social Media Hub — new Audit Trail section in Connections tab. Compliance-grade record of every post broadcast across Facebook / Instagram / LinkedIn. Columns: When / Who (sender name + role) / Platform(s) / Content preview / Status / Actions. Sourced from il_social_posts[*].sendHistory (populated by the v5.16 send-audit hooks — so existing post history is retroactively visible). Filter by platform, sort by date, export as CSV. Lives at the bottom of the Connected Accounts tab so it's immediately adjacent to the connection status for each platform.
  • 📅 Calendar nav reorganization. "Calendar Settings" sidebar item (which was actually the full Calendar page under a misleading label) has been split: the page itself is now 📅 Calendar under the Main nav section, a new 📋 Booking Requests item under a dedicated Bookings nav section takes the bookings table into its own view, and a new 📅 Calendar tab in Settings holds the Availability + Blocked Dates + Meeting Duration configuration. Both locations read/write the same il_cal_settings store so changes anywhere propagate everywhere.
  • ✉️ Website bookings now flow through admin approval with email quick-response. Previously website bookings only created a lead and never appeared in the Calendar page's Booking Requests table. v5.33 fixes this: website submissions push to both il_leads and il_bookings so the request shows up in Booking Requests immediately, and an email notification with ✅ Approve / ❌ Decline magic-link buttons goes to the configured admin address. Links are HMAC-signed with a 72-hour expiry and single-use enforcement — clicking Approve or Decline from the email updates booking status instantly without requiring the admin to log in. Security: token includes bookingId + action + expiresAt signed with a server secret; replay attempts show a friendly "link already used" page.
  • 📌 Scope. One <style id="v533-css"> + <script id="v533-js"> pair, plus a new booking_token_act endpoint in ilearn-db.php, a 40-line addition to ilearnhcc-website.html (push to il_bookings), a new email template booking_request_admin, and sidebar + settings-tab HTML edits. PORTAL_BUILD bumped to v5.33. Zero schema migrations — il_bookings already exists, il_cal_settings unchanged. Rollback = delete the v5.33 block + revert sidebar / settings edits + remove the PHP endpoint. Five features built on one release per your explicit request; v5.32 flagged a validation backlog and the same note applies here.
🧯 v5.32 — Five Targeted Bug Fixes (Archived Leads / Weather / Email / Settings)
  • 📦 Archived Leads view no longer leaks active leads back in. The v5.30 _applyArchivedOnlyFilter() only ran from two triggers (opening the view, page-show event). Any subsequent renderLeads() call — pagination, sort, stage change, drag-drop, auto-sync — re-rendered everything and silently washed the filter out. Fix: v5.32 wraps window.renderLeads so that whenever the archived-view flag (il_v530_archived_view) is on, the filter re-applies after every render. A persistent grey 📦 ARCHIVED VIEW badge pinned to the top of the Leads page makes the mode unambiguous at a glance, and a matching ← Back to Active Leads button on the badge exits the view cleanly.
  • 📍 "📍 Set location" now lands on the right field. The v5.30.1 click handler used selector [id*="location" i] which matches #invLocation (inventory) first in DOM order — so clicking the topbar link scrolled to the Inventory Location field inside Settings → Data tab instead of the Weather Location field on the Agency tab. Fix: v5.32 targets #setWeatherLoc explicitly, calls switchSettingsTab('agency') first to ensure the field is mounted and visible, then scrolls + focuses + flashes the field with a brief violet highlight so it's obvious where to type.
  • 🌡️ Temperature pill stuck at --°C — root-caused and fixed. The Open-Meteo geocoder needs a city name, and fetchWeather() was splitting the stored address on "," and taking the first part. If the value stored was space-separated (e.g. "Orangeville ON" — no comma, which is how the Agency Info field auto-saves when you type it that way), split(',')[0] returned the whole string, Open-Meteo returned 0 results, and the pill silently fell back to --°C forever. Fix: v5.32 installs a resilient multi-candidate parser — it tries comma-separated candidates first, then progressively strips trailing words ("Orangeville ON" → fails → "Orangeville" → succeeds). Runs once on page load so the pill repairs without a reload. If every candidate fails to geocode, the pill shows a clickable ⚠️ Can't geocode link that routes to Settings → Agency, Weather Location field.
  • ✉️ Email reader body no longer clips words at the right edge. Pane 3 of the email client (#pg-emailclient > div > div:nth-child(3)) had overflow: hidden with descendants inheriting overflow-wrap: normal — a recipe for silent right-edge clipping on any unbroken text: long URLs, quoted email threads, monospace code blocks, deeply-nested reply chains. Short messages looked fine; anything with wide content chopped words. Fix: v5.32 forces overflow-wrap: anywhere + word-break: break-word on all reader-pane descendants, switches the pane to overflow-y: auto, caps <img> / <table> / <video> at max-width: 100%, and forces <pre> / <code> blocks to wrap instead of horizontal-scroll.
  • ⚙️ 📆 Leads Archive Settings card no longer appears on every tab. Settings tabs use data-setgroup="tabname" + a CSS rule ([data-setgroup]:not(.setg-visible) { display:none !important }) to show only the cards matching the active tab. v5.31's _installArchiveSettingsCard() injected #v530-archive-settings-card into #pg-settings dynamically without setting data-setgroup — so the hide rule never matched it, and the card rendered on all 7 tabs (Portal, Account, Agency, Data, Email, Integrations, Business) simultaneously. Fix: v5.32 installs a MutationObserver on #pg-settings that stamps data-setgroup="business" onto the card whenever it appears. Card now only shows on the ⚙️ Business tab where it logically belongs.
  • 📌 Scope. One <style id="v532-css"> + <script id="v532-js"> pair at the end of the admin HTML, plus a one-line PORTAL_BUILD bump in ilearn-db.php. Zero schema changes, zero changes to ilearnhcc-website.html, zero PHP logic changes. All five fixes are additive on top of v5.31 — rollback = delete the v5.32 block + revert PORTAL_BUILD string. v5.30 and v5.31 code untouched.
🧯 v5.31 — Bug Fixes + Sounds + Lead Color-Coding
  • 🔴 Red "New portal version available" banner — ROOT-CAUSED and fixed. The PHP version_gte() regex in ilearn-db.php was /^(\d+)\.(\d+)([a-z]*)$/ — it only accepted 2-part versions. When v5.30.1 shipped (the first 3-part version), the regex failed entirely, both parsed components fell through to 0, and every publish got rejected as outdated_client. Refreshing couldn't clear the banner because it re-fired on the next publish. Fix: new regex /^(\d+)\.(\d+)(?:\.(\d+))?([a-z]*)$/ parses all 3 numeric components plus optional suffix, and the comparison chain was extended to compare patch versions too. Verified against 10 edge cases including v5.30.1 vs v4.22 (was the broken case), v5.30.1 vs v5.30, and v10.0 vs v9.99. As defense-in-depth I also cut this release as v5.31 (2-part) so the banner stops immediately on deploy even before the PHP update propagates via Cloudflare. PORTAL_BUILD on the PHP side bumped from v5.22 to v5.31.
  • 🏢 Supplier custom category — actually works now. v5.30 had a one-character logic bug: the modal-open hook checked modal.style.display !== '', but when openM('mAddSupplier') opens the modal via CSS class, style.display stays as '', so the hook never fired and the supCat dropdown never got the + Add new supplier category… row. Fix: check getComputedStyle(modal).display !== 'none' instead. Same pattern applied to the Lead modal and Contact modal hooks so they fire reliably too.
  • 📜 Campaign Audit Trail card now renders on the Campaigns page. The v5.30 page observer used :not([style*="display:none"]) to find the active page — but the portal uses class="page on" (no inline styles), so the observer always reported the first page in DOM order (pg-dashboard) as visible. That's why the campaign audit card (and the Settings archive card, and the suppliers-page hooks) never rendered. Fix: the v5.31 observer selects .content > .page.on instead, and re-fires every v5.30 page-specific initializer on navigation. The audit filter was also broadened to scan category / action / details / msg / type fields (the portal audit log uses a mix) so anything mentioning campaigns — sends, edits, creates, deletes — now shows up.
  • 🔊 Login sound. Ascending C5-E5-G5-C6 major arpeggio plays when the login screen clears after successful sign-in. Pure Web Audio API — no external file, no CDN dependency, works on mobile and desktop. About 600ms long.
  • 🔇 Logout sound. Descending G5-E5-C5 three-note farewell plays when you confirm the "Sign out of Admin?" dialog. Same Web Audio approach.
  • 🎨 Lead entry windows — color-coded. The Add/Edit Lead modal now has a 6px-wide coloured left border that matches the lead type: Parent Inquiry = teal, Provider Application = violet, Partnership = orange, Referral = pink, Government / Agency = sky, Other = gray. The modal header also gets a subtle gradient tint in the same colour. Beside the modal title sits a coloured stage pill — New / Contacted / Pending / Approved / Declined — that updates live as you change the stage dropdown. Makes it visually obvious at a glance what kind of lead you're editing and what state it's in.
  • 📄 Parent invoices now linked to their contact record. Open any Parent contact in the Edit Contact modal and scroll down — there's a new 📄 Sent Invoices section showing every invoice with parentId === contact.id (or matching email, as a fallback). Five columns: Invoice #, Date, Due, Total, and a coloured Status pill (Draft / Sent / Paid / Overdue / Void). A summary row at the bottom totals the billed amount, paid amount, and outstanding balance. For contacts with no invoices the section shows an empty-state message. For brand-new contacts (no ID yet) the section is hidden until save.
  • 📌 Scope. One <style id="v531-css"> + <script id="v531-js"> pair plus the PHP version_gte patch + PORTAL_BUILD bump. Rollback = delete the HTML block (features revert); revert the PHP file to keep the old version check if ever needed. The v5.30 code is untouched — v5.31 repairs the v5.30 bugs rather than replacing v5.30.
🧯 v5.30.1 — Topbar & Manage Widgets Polish
  • 🧩 Manage Widgets pill no longer overlaps the topbar "+ Contact" button. The legacy #v441AddWidgetBtn was absolute-positioned at top:12px / right:16px inside #pg-dashboard, which visually crowded the right side of the topbar on desktop — especially the + Contact pink action button. v5.30.1 keeps the legacy button hidden on desktop and renders a new inline 🧩 Manage Widgets pill at the top of the dashboard content area, right-aligned in its own flex row above the stat grid. Mobile keeps the v5.28 icon-only floating override unchanged.
  • 📍 Weather pill "Set location i…" truncation — fixed. When no location is configured, the pill previously showed the full text "Set location in Settings" which got chopped to "Set location i…" in the narrow topbar slot. v5.30.1 swaps that for a compact clickable 📍 Set location link styled in violet — one tap routes to Settings and scrolls the location field into view. The temperature slot also now consistently shows --°C (with the C) when unconfigured, instead of a bare --°.
  • 📐 Topbar right-side spacing tightened on desktop. A 10px gap between the search bar, + Contact, and + Campaign buttons plus white-space: nowrap on the action buttons so they never wrap awkwardly at mid-widths. Mobile layout is untouched.
  • 📌 Scope. One <style id="v5301-topbar-polish-css"> + <script id="v5301-topbar-polish-js"> pair. Zero changes to ilearn-admin.js apart from the version string. Zero PHP / schema changes. Rollback = delete the block.
🆕 v5.30 — What's New
  • 🏷️ Leads: "Won" → "Approved", "Lost" → "Declined". The display labels in the kanban column headers, the Add/Edit Lead stage dropdown, the table filter, the stage pills, and the stats bar all now read Approved/Declined. Internal stage values remain Won / Lost so all 26 places in ilearn-admin.js that filter by stage === 'Won' keep working unchanged and zero data migration was needed. The rename is a pure display-layer wrapper on renderLeads(). If you ever need to revert, delete the v5.30 script block.
  • 🔧 Retroactive orphan sweep for previously-approved leads. v5.26 installed the hook that creates a contact whenever a lead is moved to Won, but leads that were already in Won before v5.26 shipped never went through the hook — they're still in the DB as Won leads with no convertedToContactId stamp and no matching contact. v5.30 runs a one-time sweep on first load per device (guarded by localStorage.il_v530_orphan_swept) that calls _ensureContactForWonLead(id) for every orphan. The function itself was verified working on a Provider Application orphan in the previous session (barbara → Provider contact created successfully), so this just retroactively feeds the same known-good function the remaining orphans. You'll see a toast on first load: "🔧 v5.30 created N missing contacts from previously-approved leads".
  • 🏢 Supplier custom category. Same pattern as v5.26's invoice categories. The Category dropdown in Add Supplier (and the suppliers filter bar) now ends with a purple italic "+ Add new supplier category…" row. Click it → name the category → it's saved to il_supplier_categories and syncs across devices via the normal data pipeline. Custom entries appear with a ★ prefix so you can tell them apart from the built-in eight.
  • 📜 Campaign Audit Trail card on the Campaigns page. A new card at the bottom of the Campaigns page renders a filtered mirror of the main audit log — every entry where the category, details, or action mentions "campaign". Reverse-chronological, capped at 50 most-recent entries, with a 🔄 Refresh button. The main audit log is untouched; this is a read-only secondary view so campaign history is visible in the same section it belongs to.
  • 📦 Archived Leads — dedicated page with configurable period. The old "📁 Show Archived" toggle is retired in favour of a cleaner split: a new 📦 Archived Leads button opens a dedicated view with a banner and a ← Back to Active Leads button. In Settings, a new 📆 Leads Archive Settings card lets you pick how long Won/Lost leads stay in the active kanban before auto-archiving — 7 / 14 / 30 / 60 / 90 / 365 days or Never auto-archive. Default stays at 7 days for backward compatibility with v4.60. A 🧹 Run archive sweep now button runs the sweep on demand so you can archive old leads immediately without waiting for the next page reload.
  • 📌 Scope — one additive block, zero schema changes. All v5.30 changes live in a single <style id="v530-qol-css"> + <script id="v530-qol-js"> pair at the end of the admin HTML. No PHP changes. No database migrations. Internal stage values unchanged. Rollback = delete both blocks; any leftover il_supplier_categories / il_archive_period_days / il_v530_* keys are harmless.
🎥 v5.29 — Voice / Video Calling on Team Chat
  • 📞 1-on-1 voice & video calls in Team Chat. Open a direct-message thread with a teammate and two new buttons appear in the chat thread header — 📞 for voice, 📹 for video. Click either and your browser prompts for camera / mic permissions, then the peer sees a full-screen incoming-call modal with Accept / Decline buttons and a ringtone. Accept → WebRTC handshake completes in 3–5 seconds → both parties see each other's video and hear each other. Call buttons never appear on the "Everyone" broadcast channel — calling is strictly 1-on-1 by design.
  • 🎛️ In-call controls. Floating draggable panel on desktop (360 × auto, bottom-right by default, drag the header to reposition), full-screen on mobile. Shows remote video full-bleed with a picture-in-picture self-preview, call timer (counting up from 00:00), and three controls: 🎤 mute, 📹 camera on/off, 📴 hang up. Mute and camera toggles flip icon + colour when engaged. Voice-only calls show a 🔊 placeholder instead of the remote video.
  • 🛎️ Incoming call experience. Full-screen modal with the caller's name, call type (voice vs video), a pulsing avatar, and two big circular buttons — ✓ Accept / ✕ Decline. Ringtone is a pure Web Audio API 440+480 Hz tone pair repeating every 2.5 s — no external file, no CDN dependency, works offline. Declining shows a 📴 "Call declined" toast on the caller's side. If the callee is already in another call, the second incoming offer auto-declines with a "📞 N is in another call" toast to the caller.
  • 📡 Signaling via existing chat pipeline, zero backend changes. Call-signal messages (SDP offers, answers, ICE candidates, decline, hangup) are sent as regular chat messages with a hidden _callSignal property. A wrap around appendChatMessages and renderChatMessages filters these out of the visible chat stream and routes them to the WebRTC state machine. No PHP changes, no new endpoints, no schema changes. Piggybacks 100% on the v5.24 chat DM infrastructure.
  • 🌐 ICE: STUN-only by default — TURN support scaffolded. Calls use Google's public STUN servers (stun.l.google.com:19302 + stun1.l.google.com:19302). For ~30% of calls between users on different networks (cellular ↔ wifi, symmetric-NAT), STUN alone can't punch through — calls fail with a "⚠️ Call failed — could not connect (NAT traversal)" toast. To enable TURN without a code change, paste into the browser console: localStorage.il_turn_config = JSON.stringify({urls:'turn:YOUR-SERVER:3478', username:'u', credential:'p'}) and reload. Options if/when you need TURN: Twilio Network Traversal (~$0.40/GB audio, $4/GB video — very reliable) or self-hosted CoTURN on a $6/month VPS.
  • ⚠️ Known limitations. (1) 1-on-1 only — no group calls; the Everyone broadcast thread doesn't get call buttons. (2) No recording, no screen share — can be added in a future release if requested. (3) No ringback tone for outgoing calls — just a "Ringing…" overlay. (4) Requires HTTPS (you have this via Cloudflare, no issue). (5) WebRTC cannot be tested in a single tab — you need two users signed in from two different devices/browsers to actually validate the end-to-end flow.
  • 🧪 QA plan. Pair up with Anthony (or yourself on a second device). (1) Open Team Chat → click Anthony in the contact list → 📞 and 📹 buttons should appear in the thread header. Broadcast "Everyone" should show no buttons. (2) Click 📹 → grant camera + mic → "Ringing…" on your side → Anthony sees full-screen modal with ringtone. (3) Anthony accepts → within 3–5 s both see each other. (4) Toggle mute, camera off, hang up — all three work. (5) Reverse direction: Anthony calls you. (6) If possible, test cross-network (cellular vs wifi) — this is where STUN-only may fail, letting you know if TURN is worth configuring.
  • 📌 Scope. All v5.29 changes live in one <style id="v529-voicevideo-css"> + <script id="v529-voicevideo-js"> pair plus three DOM nodes (#v529-incoming-modal, #v529-call-panel, and the call buttons injected into #chatThreadHeader). Zero PHP changes. Zero database changes. Rollback = delete the CSS/JS blocks and the three DOM nodes.
v5.28 — What's New
  • 🧩 Dashboard "Manage Widgets" button — icon-only on phone. The button was absolute-positioned at top-right with the full "🧩 Manage Widgets" label, overlapping the page title at mobile widths. v5.28 shrinks it to a 44×44 circular icon-only button with the puzzle emoji and a tooltip. Tap target stays at the Apple HIG minimum, desktop layout is unchanged.
  • 📊 Stat grid orphan-card fix. The dashboard has 5 stat cards (Contacts / Subscribers / Providers / Leads / Open Tasks). In the 2-column mobile grid, the 5th card was stranded in a half-empty row. v5.28 makes the last card span both columns when the count is odd, so you get a clean 2 + 2 + 1 (full-width) layout. The circular progress rings that render behind each stat card were also shrunk from 80 × 80 to 56 × 56 on phone so they stop overlapping the number text.
  • 🎯 Auxiliary FAB cleanup — "pink + over More" fixed. The email / calculator / quick-notes floating action buttons were duplicating sections already reachable from the bottom nav and were colliding with the bottom-nav's More button on phone. v5.28 hides all three on phone (body.has-bottom-nav). The team-chat FAB is kept because the bottom-nav's 💬 Chat tab uses the same toggleChat() handler. The 🔔 notification bell is shrunk to 44 × 44 and repositioned to the right side above the chat FAB.
  • ⚙️ Settings page mobile rebuild. Every card now fills the screen width with consistent 12 px padding. Card headers wrap when the title + action button don't fit on one line. 2-column inline grids inside settings cards collapse to 1 column. Long code snippets / read-only inputs (cron URLs, tokens, API keys) break with word-break: break-all so they don't cause horizontal scroll. #pg-settings { overflow-x: hidden } as a safety net.
  • 📅 Calendar page mobile optimization. The Availability Settings + Blocked Dates 2-column layout now stacks cleanly. Mini-calendar day cells get a 40 px minimum height so they're tappable without misfires. Month-nav ‹ and › buttons grow from ~20 px to 40 × 40 tap targets. Day-of-week toggles (Mon/Tue/Wed…) get 34 px min-height and 6 × 8 padding so you can tick/untick without squinting. Tapping a day cell with events now triggers a detail popup listing the events for that date (currently via alert() — future release may upgrade to a proper sheet).
  • 🛡️ Global "no horizontal scroll" safety net. Added overflow-x: hidden to .content at ≤ 768 px, max-width: 100% + box-sizing: border-box on all .card elements, and min-width: 0 on .card-bd / .card so inline-width cards can shrink. This catches several other pages that had similar overflow issues (Reports, Audit, Inventory).
  • 📌 Scope — one isolated CSS+JS block. All v5.28 changes live inside <style id="v528-mobile-polish-css"> + <script id="v528-mobile-polish-js"> at the end of the admin HTML, all rules inside @media (max-width: 768px). Zero desktop changes. Zero PHP changes. Zero data-layer changes. Rollback = delete both blocks.
v5.27 — What's New
  • 🍞 Toast centering fix on phone. The v5.22 mobile override set left:12px and right:12px on toasts but forgot to reset the base rule's transform: translateX(-50%), so every toast was pushed about half its own width off the left edge of the screen — cut off on phones. v5.27 resets the transform at phone widths so left/right do the centering, and the slide-in animation is switched to an opacity fade since the previous translate-based animation no longer fits the full-width toast.
  • ✉️ Email client mobile — stack navigation. Before v5.27 the three-pane email layout collapsed to a cramped vertical stack on phone with the folder list clamped to 120 px, forcing you to scroll through the whole layout to open a message. v5.27 adopts the same stack-navigation pattern used for Team Chat in v5.24: you see the folders first → tap a folder → message list fills the screen → tap a message → the reader fills the screen. Each subview has a ← Back button in its header to walk back up. Desktop is completely unaffected — the 3-pane layout survives at widths ≥ 769 px.
  • 📱 Customizable mobile shortcuts in the bottom nav. The four non-"More" slots in the phone bottom navigation bar are now fully customizable. Go to Settings → 📱 Mobile Navigation Shortcuts, tap any section in the catalog to slot it into the bar, tap an already-selected section to remove it. The catalog has 22 sections covering most of the portal (Home, Chat, Calendar, Email, Contacts, Leads, Tasks, Invoices, Payments, Campaigns, Subscribers, Social, POs, Suppliers, Inventory, Payroll, Reports, Blog, Forms, Provider Map, Users, Settings). Defaults remain Home / Chat / Calendar / Email for users who never customize. Preference stored in localStorage.il_mobile_nav_shortcuts — per-device, so your phone and tablet can have different shortcut sets.
  • 📌 Scope — three mobile-only changes, zero server touches. All three live in one isolated CSS + JS block at the end of ilearnhcc-admin-v2.html. No PHP changes. No database schema changes. Rollback = delete the <style id="v527-mobile-refine-css"> and <script id="v527-mobile-refine-js"> blocks.
  • 🎥 Video / voice chat — not yet shipped, see roadmap. Adding peer-to-peer video to Team Chat was also on the v5.27 list but shipping it correctly needs a TURN server (without one, calls fail ~30-40% of the time between different networks) plus ~2000 lines of WebRTC signaling + UI. It's deferred to its own dedicated release (tentatively v5.28) so the infrastructure decision (Twilio Network Traversal vs. self-hosted CoTURN) can be made first.
v5.26.1 — Hotfix
  • 🧯 Invoice "+ Add new category…" option now actually appears. v5.26 shipped the plumbing for custom invoice categories but the add-new option wasn't showing up in the New Invoice modal. Root cause: the portal HTML has a pre-existing duplicate-id bug — #invCategory, #catGenericOpts, and #catParentOpts all appear twice in the DOM (once inside the Inventory modal, once inside the Invoice modal). v5.26 used getElementById('invCategory') which returns the first match, so the populate function was rewriting the Inventory modal's select instead of the Invoice modal's.
  • 🎯 Fix — scoped DOM queries. v5.26.1 swaps every getElementById lookup in the v5.26 invoice-categories block for document.getElementById('mAddInvoice').querySelector('#invCategory'), guaranteeing we always hit the Invoice modal's select regardless of what other elements in the DOM share the id. Click the Category dropdown in New Invoice and the purple italic + Add new general category… row is there at the bottom as designed.
  • ⚠️ Note — underlying duplicate-id bug is still there. Two elements sharing the same id is invalid HTML and could cause similar subtle bugs in other places. v5.26.1 works around it defensively, but a proper fix would be to rename one of the duplicate ids (probably to invCategoryInventory) — that's a larger refactor with potential ripple into other code paths and is deferred for now.
v5.26 — What's New
  • 🎯 Won → Contact auto-creation — fixed. Before v5.26, moving a lead to the Won column by dragging in the kanban or selecting Won from the stage dropdown did not create a contact — only the explicit 👤 Convert button did. v5.26 adds a single idempotent function _ensureContactForWonLead() that fires from every trigger (kanban drag, dropdown change, Convert button, saveLead). Three independent dup checks (convertedToContactId stamp, convertedFromLead back-link, email match) make it safe to call repeatedly without creating duplicates.
  • 🏷 Lead type → contact type mapping — rebuilt. Previously any lead that wasn't a "Provider Application" became a Parent contact — so Partnership / Referral / Government / Other leads were all mis-classified. v5.26 maps every lead type in the form dropdown to its own contact type: Parent Inquiry → Parent, Provider Application → Provider, Partnership → Partner, Referral → Referral, Government / Agency → Agency, Other → Other.
  • 🧭 Kanban stages renamed: "Qualified" → "Pending", "Proposal Sent" removed. New pipeline is New → Contacted → Pending → Won / Lost. Any existing leads with old stage values (Qualified, Proposal Sent, Proposal, Reviewing) are migrated to Pending by a one-time data-repair sweep on first v5.26 load per device. The repair is idempotent and logs a toast + audit entry with the number of leads touched.
  • 📄 Invoice categories — add your own. New Invoice form's Category dropdown now ends each group with a "+ Add new general category…" / "+ Add new parent-invoice category…" option. Clicking it prompts for a name, saves the new category to il_invoice_categories, and immediately selects it on the form. Custom categories appear with a ★ star in subsequent dropdowns. They sync across devices like any other portal data, and show up in future invoice forms automatically.
  • 🔒 Duplicate-lead protection, tightened. The v5.15 anti-dup-push protection in saveLead() is preserved. v5.26 adds an extra layer on the conversion path: even if the same lead's Won transition fires twice in rapid succession, only one contact is ever created. If the convert was cancelled (user dismissed the Convert button's confirm dialog), the lead stays in its current stage — no ghost state.
  • 🔄 Rollback is surgical. Delete the v526-pipeline-invoice-js script block, revert the _LEAD_STAGES array + normalizer aliases in ilearn-admin.js, and revert the Category <select> in the HTML. Invoice categories users added will still exist in il_invoice_categories on the server (harmless — ignored once the script is removed). Lead stages migrated to "Pending" will show up as "Pending" in the old UI too; if you want them back in "Qualified" specifically, that would need a targeted data rewrite.
v5.25 — What's New
  • 🔑 Biometric login via WebAuthn / Passkeys. Face ID, Touch ID, Windows Hello, Android fingerprint, and hardware security keys (YubiKey, etc.) are now usable as a fast alternative to password login. Enroll a device in Settings → 🔑 Biometric Login → Manage → Enroll this device, and next time you open the login page, type your email and the 🔒 Unlock with biometric button appears. One tap, Face ID prompt, you're in.
  • 🧱 Additive, not a replacement. Password login works exactly as it did before — same doLogin(), same session flow, same ?action=login endpoint. Biometric is layered on top. If you never enroll a passkey, you never see the biometric button. If enrollment fails, verification fails, or the user cancels the prompt, the error is shown gently and password login remains available on the same screen. You cannot lock yourself out by failing a passkey flow.
  • 🔒 Proper server-side signature verification. New PHP module ilearn-webauthn.php (~600 lines) implements a minimal CBOR decoder, COSE-to-DER EC public-key conversion for P-256, and full signature verification using openssl_verify. Every login is verified server-side: origin in clientDataJSON must match, challenge must match the one generated by the server for this attempt, signature must verify against the stored EC public key. Challenges are single-use and bound to PHP $_SESSION.
  • 📱 Multiple devices per account. The passkeys field on each user record (u.passkeys[]) is an array — enroll your iPhone, your laptop, a YubiKey, and all three work independently. Each enrolled passkey has a device label you choose at enrollment, plus creation date and last-used timestamp visible in Settings → 🔑 Biometric Login → Manage. Revoke any passkey individually from the same dialog.
  • 🧠 Graceful degradation on every path. Browser doesn't support WebAuthn → 🔒 button hidden, Settings card shows "Not supported" with a note to try Chrome/Safari/Firefox/Edge. Email has no enrolled passkey → 🔒 button hidden (no account-existence leak — an unknown email returns the same response as "no passkeys"). User cancels Face ID prompt → friendly message, password field still focused. Server can't verify signature → generic "Invalid credentials" response, no stack trace leaked.
  • ⚙️ What's deliberately NOT implemented (documented scope). No attestation chain verification — the attestation statement is present during enrollment but we trust the already-authenticated user's decision to enroll. This matches what most small-team tools do and is appropriate for an internal admin portal where users are known. Only ES256 (ECDSA P-256 SHA-256) is supported — this covers 99% of modern authenticators including every Apple / Windows Hello / Android / YubiKey 5 device.
  • 🚪 Six new server actions, all rate-limited. webauthn_support_check (probe if an email has passkeys — returns false for nonexistent accounts to avoid enumeration), webauthn_register_start / webauthn_register_finish (auth'd enrollment), webauthn_login_start / webauthn_login_finish (public login), webauthn_list_passkeys / webauthn_delete_passkey (auth'd management). All six use the existing _pub_rate_limit_ok helper so abuse protection matches the rest of the portal.
  • 🧹 Rollback is a two-file delete. Remove the require_once line in ilearn-db.php, delete ilearn-webauthn.php, delete the <script id="v525-webauthn-js"> block, and remove the #lBioBtn button + Biometric Login Settings card. The passkeys field left on user records is harmless — ignored by any other code. Password login keeps working throughout.
v5.24 — What's New
  • 💬 Team Chat overhaul — real instant-messenger UX. The floating chat widget is now a two-pane UI: contact list on the left (profile photo, name, status dot, last-seen, unread count), active conversation on the right. Click any teammate to start or resume a DM. Click Everyone at the top to return to the team-wide broadcast channel. No backend changes — existing messages with isDirect / toUserId already route correctly and show up in the right thread immediately on deploy. All 400+ existing messages are preserved.
  • 🟢 Manual status setting — 🟢 Available / 🟡 Away / 🔴 Busy / ⚫ Offline. Pick your status from the header selector in the chat panel. Stored on your user record (chatStatus + chatStatusUpdated) so it syncs across devices within a minute. Overrides the automatic last-seen bucketing — if you set "Busy" manually, teammates see 🔴 even if you're actively typing. "Offline" is sticky (you stay offline until you change it); other manual statuses auto-expire after 8 hours and fall back to automatic, so a status you set on your phone three days ago doesn't haunt you.
  • 📱 Mobile: chat fills the screen. At ≤ 768 px the panel takes over the full viewport with a stack-navigation pattern — contact list first, tap a teammate to drill into their conversation, ← button in the conversation header returns to the contact list. The bottom-nav 💬 Chat tab (v5.23) now opens this flow directly. Clears the bottom nav via safe-area-inset-bottom padding so your iPhone home indicator doesn't overlap messages.
  • 🔢 Unread-per-thread counts. Each DM thread AND the Everyone channel have their own red unread-count badge in the contact list. Incremented when an incoming message lands in a thread that isn't the one you're currently viewing. Cleared automatically when you click into that thread. Stored in localStorage.il_chat_unread_per_thread — per-device, not synced (so "read on desktop" doesn't clear the badge on your phone).
  • 🎯 Contact list — profile photos, status dots, last-seen. Each teammate row shows a 36 × 36 avatar (real photo if uploaded, coloured initial circle fallback), name, role, a little status dot overlay (🟢 pulses for Available), and last-seen text ("12m ago", "3h ago", "just now") for anyone not Available. Rows sort by status — Available at the top, then Away, Busy, Offline — and by name within each bucket. Search box at the top filters by name.
  • 🧩 Thread-header with avatar + live status. The top of the conversation pane shows the active thread's avatar, name, and live status line ("🟡 Away · last seen 4m ago"). Everyone channel shows the shared 👥 icon and "Team-wide broadcast channel". Status refreshes automatically every 15 seconds while the chat panel is open.
  • 🔄 Nothing broke — data layer untouched. Message schema unchanged, PHP endpoints unchanged, SSE + polling unchanged. The v5.24 JS wraps renderChatMessages and appendChatMessages with thread-aware filters; the original functions run underneath. Rollback = delete the <style id="v524-chat-overhaul-css"> + <script id="v524-chat-overhaul-js"> blocks and revert the #chatPanel DOM to its v5.23 layout. Existing messages and DM history survive the rollback intact.
  • 🪟 Desktop panel is bigger. Went from 380 × 500 px (one pane) to 640 × 560 px (two panes), still bottom-right, still draggable, still minimizable. Respects viewport — max-width:calc(100vw - 32px) and max-height:calc(100vh - 120px) so it never pushes off a small laptop screen.
v5.23 — What's New
  • 📱 Mobile polish round 2 — three tight fixes after v5.22 feedback. Topbar no longer overflows the phone viewport, light mode is now enforced on mobile, and the bottom nav is reshaped to the long-requested Home / Chat / Calendar / Email / More layout. All three changes live inside one isolated v5.23 — MOBILE POLISH ROUND 2 CSS + JS block at the end of the admin HTML. Desktop users see zero difference — every new rule is scoped inside @media (max-width: 768px).
  • 🧭 Topbar overflow fixed on phone. v5.22 left roughly 390 px of right-side content (⚙️ Settings gear, 🔔 "Alerts" pill label, sync-pill status text + ▾ menu, + Contact, + Campaign) fighting for ~175 px of real estate on a typical phone. v5.23 hides all five of those at ≤ 768 px. Bell, mail, chat, and sync-dot survive — each a clean 44 × 44 px tap target. Settings stays reachable through the sidebar; + Contact and + Campaign reach through their own section pages; sync status is visible as a coloured dot.
  • ☀️ Light mode enforced on mobile. User request: "Mobile view should always remain in light mode as primary." Implemented as four cooperating mechanisms: (1) on first load at phone width, data-theme is forced to light; (2) setTheme('dark') is a no-op at ≤ 768 px (shows a friendly toast: "Light mode stays on for mobile"); (3) on resize above 768 px, the user's saved desktop preference is restored; (4) a MutationObserver watchdog snaps the attribute back to light if any stray code flips it on phone. localStorage.il_theme is preserved throughout so desktop dark-mode users are not affected.
  • 🧩 Bottom-nav reshaped to Home / Chat / Calendar / Email / More. The previous 📊 Home · 👥 Contacts · 🎯 Leads · 🧾 Invoices · ≡ More layout from v5.22 is replaced with 📊 Home · 💬 Chat · 📅 Calendar · ✉️ Email · ≡ More. The displaced Contacts / Leads / Invoices items moved into the More sheet alongside everything else. Tapping 💬 Chat opens the existing team-chat floating panel (a proper full-screen chat page is scheduled for v5.24). Calendar and Email navigate to their existing sections normally.
  • 🧹 Duplicate bottom nav removed. v5.22 accidentally shipped two bottom-nav implementations — #mobileBottomNav (from the first v5.22 block) and #v522BottomNav (from the second v5.22 block) both installed on phone and rendered simultaneously. v5.23 neutralises the duplicate by adding an early-return inside _installBottomNav(). The CSS for the dead #mobileBottomNav remains in the file (harmless) but no element is ever created.
  • 🔄 Scope — nothing touched beyond the phone breakpoint. No PHP endpoints changed. No data model changes. No desktop CSS modified. Existing v5.22 CSS blocks untouched. Rollback = delete the v5.23 style + script blocks and remove the return; line inside _installBottomNav() — reverts to v5.22 behaviour exactly.
v5.22 — What's New
  • 📱 Comprehensive mobile UX polish. Long-requested improvements for people who use the portal through the Android APK or on a phone browser. Every change is scoped inside mobile breakpoints (≤ 768px phone, 601–1024px tablet) — desktop users see zero difference. Implemented as two isolated blocks (v5.22 — COMPREHENSIVE MOBILE UX POLISH CSS + mobile-enhance-v522-js script) both at the end of the admin HTML. Rollback is a two-block delete — no existing rules modified.
  • 🧭 Bottom navigation bar for phone. Fixed 5-item nav at the bottom of the viewport on phone widths: 📊 Home · 👥 Contacts · 🎯 Leads · ✅ Tasks · ≡ More. First four are one-tap access; "More" opens the existing hamburger drawer so every section stays reachable. Active section gets a coloured top indicator. Auto-hides when the soft keyboard opens so it never covers form fields. Respects iOS safe-area insets for home-indicator clearance.
  • 👆 Consistent 44px tap targets everywhere. Apple HIG and Google Material both require 44×44 px minimum for touch interactive elements. Portal previously had a mix of 36, 40, and unstyled targets. v5.22 enforces 44×44 minimum on: buttons, action pills, table row actions, pager buttons, tab buttons, modal close ✕, sidebar items, and topbar icons. Fewer fat-finger mistakes, fewer accidental wrong taps.
  • ☑️ Larger checkbox / radio hit areas. Checkboxes and radios get 20px visual size (up from browser default 13px). The label next to them becomes a full 44px-tall tap target with the input — so you can tap anywhere on the label text to toggle instead of having to aim at the tiny box. Huge win on forms with many checkbox-driven options.
  • 📝 Form inputs sized for thumbs. Every text input, select, date picker, textarea gets 16px font size on phone (prevents iOS Safari's auto-zoom-on-focus — one of the most annoying iOS UX bugs), 44px minimum height, 11px vertical padding, and 8px border radius. Textareas default to 88px minimum height so you can actually see what you're typing.
  • 🔒 Sticky modal footer action rows on phone. Long modals (Add Contact, Edit Invoice, Sync dialogs) now have their Save/Cancel row stick to the bottom of the sheet with safe-area padding. Action buttons stretch to fill the row equally so you never reach for a tiny corner button. Works with the existing full-screen mobile modal rules from the v5.12 pass.
  • 🍎 Full iOS safe-area handling. Complete support for: iPhone notch (safe-area-inset-top on sidebar drawer), home indicator (safe-area-inset-bottom on modals, bottom nav, content area), and dynamic island. Content area gets 70px + safe-inset bottom padding so the last row isn't hidden behind the bottom nav. No more iPhone-specific "last row cut off" reports.
  • 🎨 Polished touches — bigger hamburger, better stats, better toasts. Hamburger icon is now 44×44 with bigger glyph. Topbar alerts/email/chat icons get 42×42 with 4px spacing. Stat cards on narrow screens bump their padding and numeric font. Toast notifications position above the bottom nav (never hidden) and stretch full-width on phone for better visibility. Extra-narrow phones (≤ 380px like older iPhone SE) get tighter spacing tweaks so labels don't truncate awkwardly.
  • ⌨️ Soft-keyboard detection via visualViewport API. When the mobile keyboard opens, the visual viewport shrinks but the window doesn't — this listener catches that and adds body.kbd-open, hiding the bottom nav so it can't cover the form field you're filling in. Re-shows when keyboard dismisses. Graceful: no-op on older browsers that don't support window.visualViewport.
  • 🎈 FAB positioning fix — real FAB IDs are matched now. Initial v5.22 draft had a selector bug where the "lift FABs above bottom nav" rule targeted .fab and #fab, but the actual portal uses named FAB IDs (#chatFab, #emailFab, #calcFab, #quickNotesFab). The generic selector never matched, so on phone the bottom nav would cover the chat FAB. Fixed: all four FAB IDs now each get their own bottom override with safe-area-inset-bottom, stacked above the 56px nav bar in the same vertical order as desktop.
  • 📖 New help topic: "📱 Mobile & App UX (v5.22)". Full documentation of every change with rollback instructions, scope notes (desktop users see nothing different), and specifics on what's NOT changed (no PHP endpoints touched, no data handling altered). Lives in Settings → Help & User Guide.
v5.21 — What's New
  • 🌓 CRITICAL FIX — "random dark mode" diagnosed and resolved. Root cause: the il_theme preference was being synced server-side as if it were global data. When any device toggled dark mode, it published il_theme="dark" to the server; every other device then pulled that down on its next auto-sync (every 60s), flipping the UI. Desktop admin wanted light, phone wanted dark, so they fought each other silently every minute — the "random" flip was actually sync-triggered and perfectly regular once seen. Diagnosed with live MutationObserver instrumentation that caught 17 writes in 3 minutes all tracing back to autoLoadFromServer.
  • 🛡️ Three-layer fix so it can't come back. (1) Added il_theme and il_ticker_speed to _neverSend — device-local preferences no longer publish to server on any device. (2) Added the same keys to _neverOverwrite — even if a stale value is still on the server, incoming syncs can't overwrite the local preference. (3) Hardened setTheme() with a no-op short-circuit: if the new theme equals the current theme AND the stored value, returns immediately before triggering MutationObservers, toast notifications, or storage writes. No more cascades from any source.
  • 🧹 One-time server-side cleanup migration. The stale il_theme value sitting on the server from before the fix needs to be removed, otherwise every device would still see the value (harmless after the pull guard, but wasteful bandwidth). v5.21 adds a narrow delete_key PHP endpoint (strictly allowlisted to il_theme and il_ticker_speed only — not a general-purpose data-wipe tool) and a one-time client migration that calls it on first v5.21 load per device. Guarded by localStorage.il_v521_cleanup_done flag so it runs once, not repeatedly.
  • 🔒 delete_key endpoint is narrowly scoped. Hard-coded allowlist of exactly two keys (il_theme, il_ticker_speed). Any other key returns key_not_in_allowlist immediately. Authentication required via auth_session_or_key (same gate as merge/replace_key). Future device-local preferences that get mis-synced can be added to the allowlist with a narrow code change — intentional design, not an oversight.
  • 🖼️ Photo cleanup that actually works — bundled in from v5.15 through v5.20. Production site is still on v5.14 (none of v5.15 through v5.20 were ever deployed). Because v5.21 includes everything from those releases in accumulation: the recursive photo scan that walks every record in every data key (not just il_users), diagnostic reporting that explains "0 deleted" instead of silently doing nothing, and all the other fixes. Deploy v5.21 and the photo cleanup tool works correctly the same day.
  • 📦 Cumulative deployment — v5.15 through v5.20 included. Nine previously-packaged releases ship with v5.21 in a single upload. FCM push notifications (v5.13+), parent inquiry emails (v5.14), lead de-duplication (v5.15), communication audit log + QBO Phase 1 (v5.16), QBO OAuth + invoice push (v5.17), QBO payments pull (v5.18), QBO items + customers bidirectional sync (v5.19), QBO scheduled cron + email notifications (v5.20). The live site had been stacking releases undeployed for weeks; v5.21 brings production current in one step.
v5.20 — What's New
  • QuickBooks Phase 4 — scheduled automated sync is LIVE. Set-and-forget your QBO integration with a cPanel cron job. One cron endpoint (qbo_cron_sync) runs every enabled sync task in sequence: 🧾 invoice push, 💰 payments pull, 📦 items drift-push, 👥 customers drift-push. Schedule config persists in il_quickbooks_config.schedule. 5 new PHP action handlers: qbo_schedule_get, qbo_schedule_save, qbo_cron_sync, qbo_run_now, qbo_sync_history. The cron endpoint authenticates via cron_key GET param (matched against DB_SECRET) OR an active session cookie — so the same endpoint powers both automated cron runs and the portal's ▶️ Run Now button.
  • 🛡️ Safety-first automation — scheduled sync does ONLY safe, idempotent operations. Invoice push (idempotent via SyncToken), payments pull (always safe — only receives data), and drift-push on already-linked items/customers. Scheduled sync does NOT: discover new QBO records and auto-link them, auto-match near-duplicates, or create new records from unmapped QBO data. Those stay manual (Sync Items / Sync Customers buttons with preview dialogs) because human judgment on near-matches is what prevents data corruption. Automation without the surprise-duplicate risk.
  • 📦 Drift-update mechanism — only push what actually changed. The drift-update logic examines every already-linked item and customer, compares their portal-side updated / modified timestamp against the last-sync-push timestamp stored in drift_sync.[type]_last_push, and pushes ONLY records that have been edited locally since their last sync. No wasted API calls on records that haven't changed. First-time enablement pushes everything, then subsequent runs stay quiet. Push to QBO uses sparse SyncToken updates (fetches current SyncToken, pushes only the changed fields).
  • 📧 Email notifications on sync events. Three configurable modes: On failures only (recommended — silent success, detailed failure alert), Every run (noisy but complete audit), Never. Recipient choice: All Admins (default), Super Admins only, or a single custom email. Email uses the standard _styledBrandHTML branded wrapper; body shows every task's status with ✓/✗ icon, one-line summary, and per-task error details so you can diagnose without opening the portal. Only sends if there are actual recipients and _pub_internal_mail is available.
  • 📊 Sync history viewer — last 50 runs retained. Every scheduled and manual run writes a history record: start/finish timestamps, elapsed ms, trigger source (cron/manual), has_failures flag, per-task results with counts and errors. New 📊 View Sync History button in the QBO card opens a modal showing the full history table with compact task summaries (e.g. inv:5/0 · pmt:3+2 · cust:1/0). Rolled at 50 entries so config stays lean.
  • 🎛️ Schedule configuration UI in the QBO card. New Scheduled Sync panel appears at the bottom of the card when connected, with: 4 task enable/disable checkboxes (auto-saving on change), notification mode selector, recipient selector with "Custom email…" option for sending to an accounting mailbox, auto-generated Cron URL with 📋 Copy button for cPanel, ▶️ Run Scheduled Sync Now button for instant testing, 📊 View Sync History button. Last-run indicator in the panel header shows ✓ or ⚠️ plus the timestamp so status is visible at a glance.
  • 🔐 Cron endpoint security hardened. The cron_key GET param must match DB_SECRET exactly or the endpoint returns the auth-required branch (session cookie instead). Config data including tokens, client_secret, schedule settings, drift timestamps, and sync history ALL live in il_quickbooks_config which remains on _sdNoPublishKeys — the entire surface continues to be server-only, never round-trips to any browser. No new secrets introduced by Phase 4.
  • 📖 Help guide updated — QuickBooks topic now covers Phase 4 automation. Full cron setup walkthrough (developer.intuit.com prep → credentials → OAuth → schedule config → cPanel cron setup), what scheduled sync does vs doesn't do, drift-update mechanics, notification mode comparison, sync history reading key, role gating. Marks the original four-phase QuickBooks roadmap as complete.
v5.19 — What's New
  • 📦 QuickBooks Items Sync — bidirectional. New "📦 Sync Items" button in the QBO card. Preview modal reconciles portal il_inventory with QBO Items/Services/Products across four buckets: already-linked (via id_map.item), name matches (same name in both — auto-checked to link), push candidates (portal-only, auto-checked), and pull candidates (QBO-only, auto-checked). Every item is a checkbox — review and uncheck anything you don't want before clicking Apply. Push creates QBO Items of type NonInventory mapped to a default Sales income account; pull creates portal inventory records with category "Other", status "In Stock", and _qbo_origin: true metadata. All mappings recorded in id_map.item for future updates.
  • 👥 QuickBooks Customers Sync — bidirectional, merge-safe. New "👥 Sync Customers" button. Two-way sync between portal Parent contacts and QBO Customers (Providers intentionally excluded — they're service providers, not customers). Preview modal categorizes every pair by match confidence: ✉ Email match (auto-linked, safe), 📞 Phone match (auto-linked, safe, normalized digit-only comparison), 🔎 Name match (UNCHECKED by default, showing email/phone differences inline so user can judge), plus push/pull lists for portal-only / QBO-only records. If you Apply with unchecked name-matches while also pushing those same portal contacts, the UI warns first to prevent silent duplicate creation. Pull writes portal contacts with type=Parent, _qbo_origin: true, and full address from QBO's BillAddr. Push creates QBO Customers with name auto-split into GivenName/FamilyName, structured billing address, email, phone.
  • 🛡️ No silent merges — preview-first for every sync direction. Items sync, customers sync, payments pull, and invoice push all now follow the same pattern: preview modal showing exactly what would change → user reviews with checkboxes → only explicit confirmation applies changes. Matches the existing v5.18 payments-pull philosophy. Users always see the full impact before anything lands in either system.
  • 🗺️ id_map extended with item section. Joins the existing invoice, customer, and payment sections to cover all four entity types. Future re-runs use the map to distinguish already-linked records from unmatched ones — no duplicates, no re-imports, no re-pushes. Stored in il_quickbooks_config (server-only, not synced to clients).
  • 🏷️ QBO origin tracking on all imported records. Every pulled entity carries _qbo_origin: true, _qbo_[entity]_id (for the QBO-side reference), and _qbo_imported_at (ISO timestamp). Consistent across parent_payments (v5.18), contacts (v5.19), and inventory (v5.19). Lets the portal always trace where an imported record originated and when.
  • 📊 Last-sync summary now tracks all four entity types. il_quickbooks_config.last_sync records timestamp + counts separately for invoices, payments, items, and customers. Each records pushed / pulled / linked / failed counts as applicable. Visible in the QBO status card details panel after every operation.
  • 📖 Help guide — QuickBooks entry fully updated to Phase 3b complete. Documents the full integration (all four sync directions), the preview-first philosophy, match rules for customer sync (email → phone → name with decreasing confidence), why providers are excluded from customer sync, and explains the origin-metadata fields for troubleshooting imported records.
v5.18 — What's New
  • 💰 QuickBooks Phase 3a — Payment pull is LIVE. QuickBooks payments now flow back into the portal as parent_payments records automatically. Both directions of the accounting loop are wired: portal invoices go out (Phase 2), QBO payments come in (Phase 3a). Two new PHP action handlers: qbo_preview_payments (dry-run showing exactly what would be imported) and qbo_pull_payments (actually imports new + updates existing). Each imported payment inherits the QBO amount, transaction date, payment method, reference number, and private note; auto-links to the portal contact via id_map.customer reverse lookup; auto-links to the portal invoice via id_map.invoice reverse lookup from the QBO Payment's LinkedTxn array.
  • 🖥️ Preview-then-confirm UX for pull. Clicking "💰 Pull Payments from QuickBooks" in the Settings → QuickBooks card first fetches and previews — a modal shows found-count, would-import, would-update, skipped counts, and a detailed table of every payment to be processed with the QBO date / customer / amount / method / ref / matched-invoice badges (green 📎 for portal-linked, amber ⚠ for unmatched). Only after you click "✅ Import N Payment(s)" does anything actually land in the portal. No surprise data landings.
  • ♻️ Incremental sync via cursor — re-pulls stay fast. Every pull records the latest QBO MetaData.LastUpdatedTime it saw as the sync cursor in il_quickbooks_config.last_sync.payments.last_cursor. Subsequent pulls filter the QBO query to MetaData.LastUpdatedTime > last_cursor, so a company with thousands of historical payments only fetches the handful that actually changed since the last run. Default first-run window is 90 days back. Full backfill option (📦 button) bypasses the cursor and fetches all payments (up to 200) for first-time setup after the portal was already in use.
  • 🔗 Idempotent pulls via id_map.payment. Every imported payment records its qbo_payment_id → portal_payment_id in a new section of id_map. Re-pulling a payment you've already imported triggers an UPDATE (amount, date, notes refresh) not a duplicate. Local edits to receiptSent, receiptNum, and the local created timestamp are preserved across updates — only QBO-sourced fields refresh. If you change a payment amount in QBO after import, running pull again syncs the new amount without overwriting your receipt state.
  • 📊 Invoice ↔ Payment linkage via QBO's LinkedTxn chain. Each QBO Payment carries a Line[].LinkedTxn[] array describing which invoices it applies to. The import walks that chain and for every TxnType=Invoice entry, reverse-looks-up the TxnId against id_map.invoice. Matches are stored on the portal payment as _qbo_matched_invoices: [portal_ids]; QBO invoice IDs that don't map (QBO-origin invoices that were never pushed from portal) go into _qbo_unmatched_invoices so the relationship is preserved for later reconciliation even when the portal doesn't know about the QBO invoice yet.
  • 🏷️ QBO origin badge on Parent Payments table. Every payment that originated from a QBO import shows a green QBO pill next to its row in the Parent Payments table, so at a glance you can see which entries came from QuickBooks vs manually recorded in the portal. The badge is added client-side by a lightweight polling routine that runs every 2 seconds and gates on a _v518Badged sentinel so it's cheap and idempotent.
  • 🛡️ Security + audit unchanged. All QBO tokens and secrets continue to live server-side only via _sdNoPublishKeys. Every import action writes to the PHP error log and returns an audit_lines array with one human-readable line per import/update, shown in the results modal and available for compliance review. Role-gated on manage_quickbooks (Super Admin + Admin by default).
  • 📖 Help guide updated — QuickBooks topic rewritten for Phase 3a. Adds full documentation of the pull flow, cursor-based incremental sync, idempotency guarantees, preservation rules on re-import, invoice-matching logic via LinkedTxn, full-backfill when to use, and what's coming in Phase 3b (items sync, two-way customer reconciliation).
v5.17 — What's New
  • 📚 QuickBooks Online integration — Phase 2 is LIVE. Real OAuth2 connect, automatic token refresh, and first entity sync (portal invoices → QuickBooks) are fully wired end-to-end. Click Connect in Settings → Integrations → QuickBooks → bounce to Intuit consent → pick your Company → bounce back to the portal connected. Access tokens refresh automatically under 5 minutes before expiry; refresh tokens rotate on every use per Intuit policy; 401 errors trigger an auto-refresh-and-retry. 11 new PHP action handlers: qbo_save_credentials, qbo_start_oauth, qbo_oauth_callback, qbo_status, qbo_disconnect, qbo_test_connection, qbo_list_customers, qbo_push_invoice, qbo_push_all_invoices, qbo_set_customer_map, qbo_save_options. All live ABOVE the GET/POST method split (same rule as FCM handlers from v5.13b) so they're reachable regardless of HTTP method.
  • 🧾 Invoice push — per-invoice and bulk. Per-invoice: open any invoice, click 📚 Push to QuickBooks (button appears when QBO is connected). If the invoice has no linked QBO customer, a picker loads your active QBO customers (up to 100) and lets you select one; the selection is remembered so future pushes of the same portal contact skip the picker. Bulk: Settings → QuickBooks card → 🧾 Push All Invoices fires a server-side batch loop over every non-Draft invoice, pushes what can be pushed, skips what can't (missing customer mapping, zero amount, etc.), reports pushed / skipped / failed counts with per-invoice failure reasons so you can fix and retry.
  • 🔄 Idempotent pushes via ID mapping + SyncToken. Every pushed invoice records its portal-ID → QBO-ID in the server-side id_map. Pushing the same invoice a second time does a sparse update (not a duplicate) via QuickBooks' SyncToken optimistic concurrency — change the invoice total, push again, the existing QBO invoice's amount updates. If the QBO invoice was deleted externally between pushes, the server detects that (fetch 404), clears the stale mapping, and creates a fresh record. Customer mappings persist through disconnect/reconnect cycles to the same company, so you don't lose your links.
  • 🛡️ Security — tokens + secret live server-side only. Client Secret, access token, refresh token, and realm_id are all stored in il_quickbooks_config which is now on the _sdNoPublishKeys list (both prefixed and unprefixed forms) so no client ever sees them. OAuth state tokens are one-shot, CSRF-protected, 16-byte random hex with 15-minute expiry and automatic garbage-collection of expired entries. Disconnect actively calls Intuit's token revoke endpoint before clearing local state.
  • 🧪 Test Connection button. Reads CompanyInfo from QBO and shows your company name, legal name, country, and fiscal year start month. One-click verification that the whole pipeline (auth → token use → live API call → response parse) is healthy. Fails visibly with the specific error if anything's off (bad realm_id, revoked token, expired refresh token, network issue).
  • 📊 Live status dashboard in the QuickBooks card. Pill shows 🟡 Not configured / 🔵 Configured-awaiting-connect / 🟢 Connected. Details panel shows environment, realm_id, who connected when, live access token TTL in minutes, refresh token TTL in days, invoice-map count, and last-sync summary with pushed/failed counts and timestamp. Refreshes automatically when you open Settings.
  • 📖 Help guide updated — QuickBooks entry rewritten for Phase 2 live state. Explains the security model, OAuth flow, per-invoice vs bulk push, idempotent-push semantics, sparse update mechanism via SyncToken, what happens on reconnect to same vs different company, and what Phase 3 will add (items sync, two-way customer reconciliation, payments pull). Replaces the v5.16 "Phase 1 scaffold" entry.
  • ⏭️ Deferred to Phase 3 (next release). 📦 Items sync — two-way reconciliation between portal inventory and QBO Items/Services/Products so invoice line items pick up specific SKUs and prices instead of the current generic "Services" placeholder. 👥 Two-way customer sync — push portal contacts to QBO Customers and pull QBO Customers to portal Contacts with a merge UI for near-duplicates. 💰 Payments pull — QBO Payment records land as portal Parent Payments / revenue entries with automatic matching to the referenced portal invoice. These are substantial each on their own and earn their own phase.
v5.16 — What's New
  • 📋 Communication Audit Log for compliance + oversight. New Settings card (Integrations → Communication Audit Log) showing every email campaign ever sent and every social post ever published, with per-recipient delivery detail: sender name/email/role, start + complete timestamps, subject line, audience spec, total recipients, per-recipient status (✅ sent or ❌ failed) with individual timestamps. Searchable by campaign name / subject / recipient email / sender / post text. Filterable by type (campaigns only / social only / all). Click "▼ N recipients" on any campaign row to expand the full recipient table. 📥 Export CSV downloads the complete audit (every event + every recipient) as a spreadsheet. Retention: 50 send events per campaign, 20 per social post — old entries dropped silently to keep DB lean. Every send also writes to the permanent Audit Trail page as a one-line entry. Role-gated on view_comm_audit (Super Admin + Admin + Manager by default).
  • 📚 Intuit QuickBooks Online integration — Phase 1 (configuration scaffold). New Settings card (Integrations → QuickBooks Online Integration) delivering the foundation for two-way sync with QuickBooks: configure Intuit app Client ID + Client Secret, choose Sandbox vs Production environment, auto-display the OAuth redirect URI to whitelist at developer.intuit.com. Phase 1 ships the config pipeline so Safia can get Intuit app approval done now (long-lead item). Phase 2 (next release) will add: OAuth2 authorization code flow → access + refresh token exchange → automatic token refresh → entity sync for 🧾 invoices (portal → QB), 💰 payments (QB → portal), 👥 customers (two-way reconciliation), and 📦 inventory items (two-way). Sync-option checkboxes are already visible but disabled with "Phase 2" tooltips so the UI is honest about current state. Role-gated on manage_quickbooks.
  • 🧹 Orphan photo cleanup — fixed to scan ALL record types. Previously the cleanup scanned only il_users[*].photo — meaning photos referenced by contacts, leads, providers, suppliers, testimonials, children, spouses, and agency logo were invisible to the scan. That caused real problems: (1) some contact photos were flagged as "orphans" and deleted even though their records still pointed to them, and (2) real orphans sitting in non-user fields went unnoticed. v5.16 now recursively walks every record in every data key, collects every /repo/photos/ reference it finds anywhere, and builds a complete referenced-set. Future-proof: new photo fields added in later releases are automatically protected — no code change here needed.
  • 🔬 Photo cleanup diagnostic reporting. Preview and cleanup actions now return + display a diagnostic block: total files on disk, count still referenced (kept), count identified as orphans. When "0 deleted" happens, the alert explains exactly why — "all N files on disk are still referenced (nothing to clean)" vs "/repo/photos/ is empty" vs actual orphan list shown. Previously "0 deleted" was silent and misread as the tool not working. Also surfaces any permission errors per-file so stuck orphans can be fixed at the cPanel level.
  • 🎯 Campaign + social post send-time audit instrumentation. sendCampaign() now captures per-recipient delivery status throughout dispatch (not just aggregated counts), stores a sendHistory[] array on each campaign record, and writes a permanent logAudit entry. sendDraft() for social posts captures sender/platform/timestamp metadata and also writes to logAudit. These feed the new Audit view — and also help debug delivery failures. Old sends (before v5.16) show a "No per-recipient detail available (before v5.16)" note; all sends from v5.16 onward have full detail.
  • 🛡️ Roles & Responsibilities matrix updated. Two new sensitive-action entries: view_comm_audit (📋 View communication audit — campaign + social send history) and manage_quickbooks (📚 Manage QuickBooks integration + sync). Super Admin + Admin get both by default. Manager additionally gets view_comm_audit since Managers can send campaigns and should be able to audit their own sends. Other roles require explicit grant via Settings → Roles & Responsibilities matrix. Matrix section count goes from 28 → 28 (unchanged — new gates are action-level, not section-level), action count goes from 16 → 18.
  • 📖 Help & User Guide expanded with 3 new topics. Communication Audit Log (v5.16) — full usage guide including the CSV export format and retention rules. QuickBooks Online Integration (v5.16 — Phase 1) — complete setup walkthrough for developer.intuit.com + what Phase 2 will add. Orphan Photo Cleanup (v5.15 fix) — explains the old bug, the new scan scope, the diagnostic block, and how scheduled cron cleanup benefits from the fix automatically.
v5.15 — What's New
  • 🧬 Lead duplication bug fixed + self-healing dedupe sweep. Moving a lead to Won (or any stage change via the edit modal) could occasionally create a duplicate because _leadEditId was never cleared when the Add/Edit Lead modal was dismissed via Cancel, ✕, or click-outside — so a subsequent "+ Add Lead" click opened the modal in edit-mode for the previously-opened lead, and a user entering new details would either overwrite the prior lead or, in the right timing, produce a second record in the same stage. Root-cause fix: closeM('mAddLead') now clears _leadEditId (mirrors the long-standing mAddContact pattern); the top-page "+ Add Lead" button and the empty-state CTA both route through a new openAddLead() helper that resets every form field + _leadEditId before opening. Bonus: a one-time dedupe sweep runs 4s after page load, scans existing leads for duplicates (matching by id OR name+email+stage), keeps the older record, removes the newer, and toasts a "🧹 Cleaned up N duplicate leads" confirmation — so any accumulated dupes from past sessions auto-heal on the next page load.
  • 🎯 saveLead hardening — idempotency + single push + robust id matching. Three related fixes inside saveLead(): (1) If nothing meaningfully changed between the existing lead and the form state (structural equality on 17 user-controlled fields), the save is skipped entirely — protects against rapid double-click on Save and reduces sync-layer noise; (2) Removed the redundant publishToServer(true) call that ran immediately after pushKeyToServer('il_leads', data) — the targeted merge is sufficient for leads, the full publish only created sync races where stale server state could echo back; (3) All four findIndex(l => l.id === id) call sites (in saveLead, moveLead, and convertLeadToContact) now coerce both sides to strings for matching — eliminates the theoretical-but-real edge case where a JSON round-trip turns a numeric id into a string id and the strict === match fails silently, falling through to an unwanted data.push(lead).
  • 🛡️ Last-line double-submit defence. saveLead now checks for id collision even on the "new lead" path — if Date.now() somehow produces the same millisecond twice (rapid clicks, replay of a cached action), the second save updates the first in-place rather than pushing a duplicate. Also logs a console warning when the edit-fallback path fires so genuine stale-editId bugs surface visibly instead of silently creating records.
  • 👤 Confirmed: Won-by-save does NOT create a contact. Audited saveLead and all kanban/table stage-change paths — none of them touch il_contacts. Contact creation only happens via the 👤 "Convert to contact" button (which calls convertLeadToContact), and that function has its own dedup check that refuses to create a contact when one with the matching email already exists. Stage changes are purely to the lead record itself.
v5.14 — What's New
  • 📎 Parent inquiry confirmation email now includes the attached file. Previously when a parent submitted an inquiry from the website with a file attachment (resume, doc, screenshot, etc.), the file was uploaded to /repo/applications/ and saved on the lead record, but the acknowledgement email they received was the plain PHP _pub_internal_mail message from public_append_lead — which uses basic PHP mail() and has no attachment support. Provider applications already had a branded M365 Graph API path via sendProviderApplicationEmail that DID include the attachment. Parent inquiries now get symmetric treatment: new sendParentInquiryEmail function sends a branded letterhead email via Graph API directly from the visitor's browser using the admin's M365 token, with the file attached as a fileAttachment (base64 bytes, Graph-native format). Admin notification emails (from agency settings) are CC'd so the office receives the same attachment-inclusive copy. Letterhead matches the provider one: gradient header, logo, structured inquiry details table, phone and contact CTAs, footer with licensing disclosure.
  • 🤝 Graceful fallback preserved. If the admin's M365 token isn't in localStorage (visitor isn't an admin, or token not yet configured), sendParentInquiryEmail returns silently — no broken-looking failure, no user-visible error. The existing PHP _pub_internal_mail ack still fires unconditionally as the safety net, so every parent gets at least the plain confirmation either way. This matches the provider flow's existing fallback behaviour (both rails co-exist).
  • 🎨 Parent letterhead copy tuned for inquiries rather than applications. Subject: "We received your inquiry — iLearn HCC" (vs provider's "Your iLearn Provider Application — [date]"). Body addresses the parent warmly, echoes back their submission details (name, email, phone, topic of interest, message), notes the attached file inline in the table when present, and sets expectation of a response within one business day. No change to provider wording — providers continue to see their existing letterhead unchanged.
v5.13 — What's New
  • 🔔 Push notifications end-to-end — Firebase Cloud Messaging wired up. Android app users now receive WhatsApp-style push notifications even when the app is closed: new direct chat messages push to the recipient's registered devices; new website booking requests and new leads push to all admin users; notification tap deep-links back to the relevant portal page. Uses FCM HTTP v1 API (Google retired the legacy server-key API in June 2024) with OAuth2 JWT-bearer tokens minted from a service account JSON, cached on disk for 55 minutes to amortize the handshake across sends. Invalidated tokens (404/UNREGISTERED) auto-pruned from the registry. New PHP helpers: il_fcm_mint_access_token(), il_fcm_send_to_token(), il_fcm_push_to_user(), il_fcm_push_to_admins().
  • 🎛️ Settings → Integrations → Firebase Cloud Messaging card. Paste the service account JSON from Firebase Console → Project Settings → Service accounts → Generate new private key — the portal auto-extracts project_id, client_email, and private_key. Individual toggles control which event types trigger pushes: 💬 direct chat messages, 📅 new booking requests, 📥 new leads, ✅ task assignments (default all ON). Status pill shows Configured · N devices with per-user breakdown listing every registered Android device by email. Test push button fires a dummy notification to your own registered devices for verification.
  • 🤝 Android app registers itself automatically. The iLearnHCCAdmin Android app already calls register_fcm_token on every launch and on token rotation with the device's FCM token, user email, platform, model, and app version. The new PHP endpoint deduplicates by token, records registered_at / last_used timestamps, and auto-prunes any token that hasn't been seen in 60 days. No APK rebuild is required to enable push — just add google-services.json to the APK once and the existing registration code picks up.
  • 🛡️ Protected keys + sync-loop safeguard. Both prefixed (il_fcm_config, il_fcm_tokens) and unprefixed (fcm_config, fcm_tokens) forms added to _sdNoPublishKeys per the established sync rule — prevents the infinite publish-sync-pull loop that bit previous server-only keys. Private key stays server-side at all times (flat JSON DB is blocked from web access via existing .htaccess); clients never see the private key, only the masked "•••" indicator after save.
  • 🔧 Graceful degradation when FCM not configured. All FCM calls are prefixed with @ (suppress errors) and the helper returns early if il_fcm_config isn't set — so the portal runs absolutely normally without Firebase, and push functionality activates the moment an admin pastes a service account JSON into Settings. No flags to toggle, no restart, no impact on existing installs. Graceful return on individual token failure too — one dead token doesn't block delivery to the rest of a user's devices.
v5.12 — What's New
  • 📱 Mobile readability overhaul — data tables now render as stacked cards on phones. The existing mobile CSS was forcing tables to 520px with horizontal scroll AND truncating every cell at 150px with "…" ellipsis — the worst of both worlds, rows were unreadable. v5.12 transforms every .table-wrap table into a card-style list on phones ≤ 600px: each row becomes a rounded card with column values stacked vertically, labelled by their column header above each value. No horizontal scroll, no truncation, all content visible. Labels auto-populate from <th> text via a MutationObserver-backed JS shim (_mobileEnhanceV512Installed) so every table re-render picks up labels automatically — zero changes required to the 23 existing table render functions. Sort-arrow characters (▲ ▼ ↕) and pure-emoji headers are stripped from labels so they stay clean.
  • 📏 Tablet portrait (601-1024px) gets lighter-touch improvements. Tables keep their grid layout but lose the cell truncation so content flows naturally; horizontal scroll on .table-wrap gets momentum-scrolling on iOS; stat grids use 2 columns; form grids collapse from 3-4 cols to 2 cols. Desktop users (> 1024px) see absolutely no change — all new rules are scoped inside @media (max-width: 1024px).
  • 🎯 Other phone-layout fixes. Filter bars above tables stack vertically with full-width inputs. Stat grids collapse to 1 col on very narrow phones (< 380px — iPhone SE, older Pixels). Form grids (.fgrid.g2/g3/g4) all collapse to single column. Modals become true full-screen sheets with sticky headers and safe-area-aware bottom padding. Lead kanban columns snap-scroll at 82vw width. Body font bumps from browser default to 14px for comfortable reading distance; form inputs enforce 16px min to prevent iOS auto-zoom on focus.
  • 🚨 Architecture note — rollback is a single-block delete. All v5.12 mobile rules live in one isolated <style id="mobile-enhance-v512"> block plus a matching <script id="mobile-enhance-v512-js">, both inserted just before </body>. If any mobile rule causes regression, delete those two elements and everything reverts to v5.11 behaviour exactly. No existing CSS was modified — these are cascade-winning overrides scoped to mobile breakpoints only.
v5.11 — What's New
  • 📅 Dashboard calendar widget now shows tasks, bookings, AND meetings. Previously only tasks (red dot) and bookings (green dot) were indicated. v5.11 adds meetings as a third category (blue dot matching the 🎥 purple-blue Teams accent used everywhere else). Each day cell can show up to 3 dots; on "today" the dots render in white for contrast against the violet background. Clicking any day with events opens the detail modal with three sections — Tasks Due, Appointments, and Meetings — each card carrying the most actionable info for that type (tasks: assignee + priority; bookings: time + topic; meetings: attendee + duration + 🎥 Join Teams button when a join URL exists).
  • 📆 Calendar Settings mini calendar rewritten to match. The mini calendar on the Calendar page used to indicate bookings only — no tasks, no meetings. v5.11 shows all three categories with the same dot-color scheme as the dashboard widget (tasks red · bookings green · meetings blue · blocked red background). Total-count badge in the corner now counts all three types together. A dynamic legend renders below the grid showing the color-to-category mapping so admins never have to guess what a dot means.
  • 🎨 Expanded day-detail modal on Calendar Settings. Clicking any day in the mini calendar now opens a 520px-wide modal with three sections (Tasks / Bookings / Meetings), each with an accent-coloured heading, count badge, and cards sized to the information they carry. Booking cards gained two new pieces: a 🎥 Teams-approve button alongside the existing ✅ Confirm and ✕ Decline (matching the main Bookings table from v5.07), and a "🎥 Teams — Join meeting →" row when the booking already has a teamsJoinUrl persisted. Meeting cards show attendee, email, time, scheduled-by, agenda, and the Teams join link if present.
  • 🎥 Branded confirmation emails now include the ACTUAL Teams join URL. Pre-v5.11 the email fired immediately on booking approval / meeting schedule and told the attendee "you'll receive a calendar invite with the join link shortly" — a placeholder rather than the real URL, because Microsoft Graph hadn't responded yet. v5.11 restructures both flows to pass an onComplete callback into createM365CalendarEvent and _createM365MeetingEvent. When Graph returns successfully with onlineMeeting.joinUrl, the callback fires _sendBookingConfirmationEmail or _sendMeetingConfirmationEmail with the actual URL — the email includes a prominent purple "🎥 Join Teams meeting" CTA button plus the raw URL as fallback text for email clients that strip buttons. Three outcomes handled: Teams requested + URL returned renders the CTA button; Teams requested but Graph failed renders an amber "we'll send separately if missing" notice; no Teams renders no Teams block at all.
  • 🛡️ Idempotency + fallback timer so the email always sends exactly once. Both email builders stamp a _confirmationEmailSent:true flag on the booking/meeting record when they fire, and both are guarded against double-fire. A 2.5-second fallback timer also runs in parallel — if M365 isn't configured at all (no onComplete ever fires) or the Graph request hangs beyond the timer, the email still goes out with the "Teams couldn't be created" fallback notice. Attendee always gets confirmation; admin always has an audit trail entry; no duplicates.
  • 💡 Microsoft Outlook auto-injects the Teams join section into the calendar invite. Separate from the branded confirmation email, when isOnlineMeeting:true + onlineMeetingProvider:'teamsForBusiness' is set on the event, Microsoft Graph automatically adds the "Microsoft Teams meeting" section with a big purple Join button into the event body HTML. That's baked in on Microsoft's side — every attendee who receives the Outlook calendar invite sees the Join button without iLearn doing anything extra. The branded email from iLearn is the CRM confirmation; the Outlook invite is the calendar entry. Both now carry the Teams link.
v5.10 — What's New
  • 💾 Per-section save buttons on every Notes sub-section. Each notes section on the Contact Edit modal now has its own dedicated save button below the textarea: "💾 Save General Notes" (violet), "💾 Save Private Notes" (rose), and "💾 Save Follow-up Actions" (amber). Each fires saveContactNotesQuick({section:, toast:true}) which persists the notes immediately, pushes to the server, and shows a confirmation toast like "💾 Private / Admin Notes saved". No more wondering whether your notes were captured — you see an explicit save confirmation.
  • Auto-save now covers every notes textarea on blur. Pre-v5.10 only the General Notes textarea had onblur="saveContactNotesQuick()". If you typed something in Private/Admin Notes or Follow-up Actions and switched tabs or closed the modal without hitting the main Save button, the text was silently lost. v5.10 adds the same onblur handler to both Private and Follow-up textareas — so even if you forget to click the explicit save button, moving focus elsewhere triggers a silent auto-save. Belt AND suspenders.
  • 🔧 New-contact ("not yet saved") edge case handled gracefully. If you click a Notes save button while adding a brand-new contact (before the main Add Contact button has fired), you now get a helpful toast: "ℹ️ Save the contact first, then notes persist automatically." Prior behaviour was to silently do nothing, which made it look broken.
  • 📝 saveContactNotesQuick now saves ALL notes metadata. Pre-v5.10 the auto-save only covered the three textareas + follow-up date. It didn't capture Priority (Normal/High/Low/VIP), Referred By, or How-Did-They-Hear-About-Us fields. Now all seven notesData fields are written on every save so nothing's dropped between the auto-save and the main save-contact path. When called from an explicit save button the save also publishes to the server immediately so the data is durable across devices.
  • 📍 Address-save QA summary. User report: "adding address to contact doesn't save and window disappears". Ran end-to-end QA on live v5.08: address DOES save on both new-contact and edit-contact flows (verified address, city, province/prov, postal, country all persist). "Window disappears" is normal modal-close-on-save behaviour. The modal has two lat/lng field pairs (cf_lat/cf_lng main + cf12/cf13 provider-specific) which look confusing but both are now cross-restored on edit and the save path reads from both as a defensive measure. If you still see the issue, please share the specific contact type (Parent/Provider/Child/Lead) and whether you used the address autocomplete dropdown vs typed manually — would help me pin down any remaining edge case.
📋 Recent release history (v4.62 → v5.10)
v5.09 — Hotfix for v5.08: Scheduled Meetings card was hidden because I accidentally added data-setgroup="agency" (a Settings-page sub-nav marker) which combined with the global CSS rule [data-setgroup]:not(.setg-visible) { display:none !important } hid the card document-wide. Removed the stray attribute so the card renders unconditionally on the Calendar page. Full v5.07+v5.08 QA passed on live production: banner hides on valid token, Meetings modal validation refuses empty title + malformed email, save fires M365 event + branded email + publishToServer, edit pre-populates with editId, cancel keeps row with red badge, delete removes + tombstones, sort is Scheduled-by-date-ascending then Cancelled-last, search filter narrows correctly, Teams join pill renders when teamsJoinUrl is set.
v5.03 — Critical fix for contact edit: the Settings Release Notes card had accumulated unclosed HTML tags across six releases of incremental "What's New" edits (5 <details> opens vs 1 close, 3 unclosed <div>s). HTML parser couldn't close #pg-settings, so every modal including mAddContact ended up nested inside display:none. Clicking ✏️ populated fields correctly but modal rendered at 0×0. Fixed by rebuilding the Release Notes card with balanced tags and adding a process note to always replace full What's New blocks.
v5.01 — Email client multi-select + bulk actions (Delete, Mark Read, Mark Unread, Clear Selection) with floating bottom-centre bar; fixed M365-specific delete bug where ecM365DeleteMsg was called but never defined; Contacts table gained new Address column between Phone and Type; sample-data placeholders ("Jane", "(416) 555-0100", "L9W 0A1", "123 Main St") swept out of every form in favour of generic purpose labels and format hints; introduced the address autocomplete helper (with the DOM-wrapping issue fixed in v5.02).
v5.00 — Fixed misleading "Welcome email failed — check Settings → Email" toast (real cause was server-side rejection of marketing email to an unsubscribed recipient; resend handler now pre-flights the unsubscribe check and shows the actual compliance reason). Team Presence widget rewritten with CSS grid layout, proper min-width:0 on the text column so long names ellipsize, status dot anchored in avatar corner, status label as coloured pill, trailing 💬 dropped. No more detached status rail or horizontal scrollbar.
v4.99 — Fixed duplicate unbranded welcome email on subscribe. Website form was calling both the server-side branded path AND a client-side sendWelcomeEmail that made a direct M365 Graph API call with hardcoded unbranded HTML. Gutted sendWelcomeEmail to a no-op stub, removed both call sites (subscriber + provider), strengthened PHP subscribe body to mirror the admin-portal welcome_subscriber template's PIPEDA/CASL privacy block exactly.
v4.98 — Topbar cleanup: dropped redundant time-of-day emoji, first-name-only greeting, rounded pill corners 8→20px. Four-piece sync cluster (Unpublished + Publish + Sync + Synced) consolidated into a single state-aware pill with four states (Synced / Dirty / Syncing / Error), context-aware click (dirty → publish, clean → pull), right-click or caret mini-menu. Legacy #unsavedIndicator kept hidden for backward compat.
v4.97 — Every outbound email now has real brand styling via the shared _styledBrandHTML wrapper (violet→fuchsia gradient header matching the butterfly branding, agency logo in rounded inset or butterfly fallback, subject sub-banner, body card with drop-shadow, Ministry of Education + HCCAO trust line in footer); fixed silent correctness trap where send-time colours were read from live DOM inputs; logo fallback chain expanded to include localStorage['il_agency_logo']; new per-contact email audit trail — every email dispatch logged on the recipient's contact record via _appendEmailToContactAudit hooked into logEmail (matches against email/parentEmail/providerEmail, shown on the 📜 History tab with status-coloured borders, capped at 500 per contact); welcome-subscriber template now carries a prominent PIPEDA/CASL privacy block covering data collection, non-sharing policy, guidance against sending sensitive info by email, subscriber rights, and one-click unsubscribe.
v4.96 — Performance pass with 5 fixes + 1 bonus: gzip enabled in .htaccess (~75% bundle transfer reduction); dropped JSON_PRETTY_PRINT from save_db (~40% DB file size reduction); debounced search across 15 tables via _dsearch(fnName) (150ms coalesce — dramatically smoother search at N=10,000); per-tick parse cache for gd() (4× faster renderContacts, 12× faster dashboard refresh at scale); aligned tombstone TTL constants to 48h consistently (fixes "reanimation" of records deleted between 24-48h ago). Bonus: caught invSearch ID collision between Inventory and Invoices (Inventory renamed to invySearch, invoice search now actually works).
v4.95 — Fixed campaign emails never actually sending (the sendCamp function was a 4-line simulation that flipped status and toasted but made no network calls — now a real mass-sender that resolves recipient lists, wraps body in brand template, personalises placeholders, dispatches through sendPortalEmail() → Gmail/M365, and throttles concurrency to 10); audited all other email functions (all correctly route through the shared helper); inventory assignees can now be users OR provider contacts (grouped in <optgroup>, prefixed values for type-aware rendering); provider map gained a 🔍 drill-down button on every row that flies the map to the coordinates, opens the marker popup, and reveals a detail panel with Google Maps deep-link.
v4.94 — New "Coming Soon" provider state (3-state status: Accepting/Full/Coming Soon with cyan markers on admin + website maps); fixed admin content (FAQs, ticker, testimonials) not updating on live site by removing if(length) empty-array guards in initLiveData and apply* handlers (same fix pattern v4.92 applied to providers); fixed saveFaq immediate publish + corrected misleading "Click Publish" toast; fixed profile photo cross-device sync by raising the _stripLargeInlineMedia threshold 100KB → 1MB, using a __IL_PHOTO_TOO_LARGE__ sentinel for genuinely oversized photos instead of empty string, and adding a PHP $perKeyPreserve rule for il_users / il_contacts photo fields so incoming empties / sentinels never wipe out server copies.
v4.93 — New 📦 Inventory & Assets module (sidebar entry, full CRUD page with 6-metric stats bar, 10-column sortable table, check-out / check-in / service workflow with per-record movement history, CSV import/export, dashboard "Inventory Overview" widget with low-stock + warranty-expiring alerts); full sync-layer wiring (DB_KEYS, _tombstonedKeys, _injectBulkBars, nav() map, titles map, website-poll sectionMap); PHP additions to SECTION_SCHEMA + $protectedArrayKeys.
v4.92 — Compliance audit upgrade: _contactAuditDiff now tracks 6 array-based groups (children, additionalParents, emergencyContacts, siblings, grandparents, assignedProviders) by id/label — detecting add, remove, and item-level field edits; edit path now re-gathers array fields so adding a child to an existing contact no longer drops silently; notes now store ISO ts + author + authorEmail and soft-delete only (marked deletedAt/deletedBy, still visible in audit trail); provider map clears live markers on empty arrays (website side) and honours onMap=false/Inactive (admin side); PDF/CSV compliance exports include all nested groups + banking (redacted) with distinct add/remove/delete icons.
v4.91 — Contact avatar label now matches type (Provider contacts showed "Parent" under the circle) + avatar enlarged 56→88px with shadow + new compliance audit trail on every contact (21 flat fields + 3 nested groups diffed on every save, capped at 500 entries) + new 📜 History tab showing merged audit + notes timeline + 📊 Export CSV and 📄 Export PDF buttons (browser-native print to PDF, no library needed).
v4.90 — Mid-session photo refresh: new central _refreshSessionPhoto() helper updates session cache + both topbar avatar flavours + re-renders open chat feed; wired into onboarding wizard, full-setup wizard, and My Profile save. Notification feed now shows profile photos for real users (system rows keep 🌐).
v4.89 — Profile photos carry into Teams chat for every sender (lookup from users roster by userId/userName) + session photo attached on login + per-user Microsoft 365 accounts with multi-account support stored as il_m365_accts_{userEmail} arrays; back-compat mirror via il_outlook so all 25+ callers keep working; ➕ Add M365 Account + Set Active + Remove inside the email client's Settings modal.
v4.88 — Contact edit-save was silently dropping address/spouse/medical/notes data; fixed field-ID mismatches (cf_addr vs cf8, cf_lat/cf_lng vs cf12/cf13) and moved address block out of the Provider-only guard so all contact types save properly; edit path now covers 35+ fields via Object.assign merges; saveSupplier switched from replace to merge so linked invoices survive edits.
v4.87 — All 16 outgoing emails now drive from the template customizer (10 hardcoded send sites migrated through a new renderTemplate() helper) + 5 new system templates (t4_slip, invoice, supplier_general, report, payment_reminder) + 📋 Duplicate button + 🧪 Send Test button + 3 Settings cards given data-setgroup attributes + duplicate Session Timeout card removed.
v4.86 — Email Template Customizer purpose card (fires-when, triggers, clickable placeholders) + Assign-to-email-type routing for custom templates (new il_template_overrides storage + central resolveTemplate lookup) + Live vs Preview banner + "⚡ (using custom)" selector badges.
v4.85 — FAQ Manager visibility toggle → 👁️/🙈 icon + parent-inquiry attachment renders as clean file card (website sets fileUrl/attachName/hasAttachment; admin falls back to files[0] so existing leads render without re-push) + 📎 indicator badge next to lead name in table/kanban + provider application timestamp root cause (re-push was using toLocaleDateString stripping the time) fixed by re-using closured nowFullStr.
v4.84 — Child care business name field on provider contacts (primary heading on Provider Map, personal name as subtitle) + Provider Map row click opens contact edit modal directly (editContact(id) instead of scroll/highlight) + checkbox column width audit normalized colgroup percentages across 6 tables to 100% + pre-existing editContact bug fixed (cf12/cf13/cf14/cf15/cf16/cf17 now restored on modal re-open).
v4.83 — Demo data auto-cleanup (clears _demo:true social account flags on first load) + publishToServer 500 fallback for legacy ilearn-db.php (v4.64 X-Portal-Build handler fatal) + provider row button style unification (icon-only 👤).
v4.82 — System events store separation (Track C #12): il_system_events split from il_chat_messages, SSE router classifies incoming messages, one-time migration moves legacy records.
v4.81 — Configurable session timeout with warning countdown + Stay logged in / Log out now.
v4.80 — Twilio server-side relay (SMS + Voice). Fixed CORS-blocked direct browser→Twilio calls. Added Send Test SMS button.
v4.79 — Invoice save validation + PDF AI extraction (PDF.js) + provider flow consolidation (contacts-only entry path).
v4.78 — Track C roadmap: case-insensitive lead stage setting (setLeadStage), audit trail archive-to-CSV, per-contact GDPR data export.
v4.77 — Chat FAB badge fix (excludes system broadcasts — the phantom "17 unread" resolves correctly to 0 when no real chat messages exist).
v4.76 — autoLoadFromServer force=true edit guard fix + publish-before-pull heartbeat divergence handler (closes the last gap in the subscriber revert saga).
v4.75 — 5 ungrouped Settings cards fixed (dynamic card annotation via ID + title matching); comprehensive post-deploy QA verified 17/17 CRUD cycles and website→admin integration.
v4.74 — Zombie logged-in bug fixed (doLogin wrapper now awaits async + checks session); delete actions work again; doLogout wrapper async-fixed.
v4.73 — Subscriber unsubscribe revert fixed (per-key edit guard + immediate publish on 9 toggle functions); server merge confirmed correct; regression test suite added.
v4.72 — Sidebar Settings subsections (expandable group with 7 children: Portal, Account, Agency, Data, Email, Integrations, Business); main Settings page cleaner (in-page tab bar hidden).
v4.71 — Settings tabbed into 7 subsections (v4.71 had them as in-page tabs); login/logout toasts wired up; user idle time accurate via real activity tracking (90s threshold, matches Slack/Teams/Meet).
v4.70 — Roles & Responsibilities matrix (27 sections × 8 roles × 12 sensitive actions, editable from Settings); nav guard blocks role-denied sections; sidebar auto-hides restricted sections; live role preview in Add User modal; role changes take effect without re-login.
v4.69 — Version badges finally sync on every release (fixed _PORTAL_BUILD constant); "Apri 2026" typo fixed; dashboard topbar gap closed.
v4.68 — Lead stage normalizer; 3 previously-invisible leads (Carlos Mendez "Reviewing", STRESS_…_L9 & L3 "Proposal") now appear correctly; one-time data repair; defensive render-time normalization.
v4.67 — Widget layout corruption loop broken (server-sync exclusion for il_widget_layout_*); drag/resize no longer permanently freeze all 14 widgets; new Reset Dashboard Layout button in Settings.
v4.66 — Removed redundant User Mgmt Status column; widget drag pointer-offset fix; leads stats bar visibly sticky below breadcrumb.
v4.65 — Breadcrumb sidebar offset (240→248); #leadStats moved above table; consolidated User Mgmt presence cell; three optional widgets.
v4.64 — CRITICAL lead rate-limit fix (5→30/hr); new subscribers → Alerts; sticky top-stats bars.
v4.63 — Team Presence clickable for chat; Settings gear in topbar; auto-assign Employee #.
v4.62 — Website alerts → 🔔 Alerts; Team Presence lastSeen server-side; breadcrumb position:fixed fix.
Built for iLearn Home Child Care Agency · Dufferin County, Ontario · ilearnhcc.com

🔐 Roles & Responsibilities

v4.70 · NEW
⏳ Loading role matrix…

🏢 Agency Info

📱 Social API Keys

Enter your live API credentials to enable real social media posting. Stored securely in this browser.

🤖 Google reCAPTCHA v2

Enables spam protection on the main website contact form. Get keys at google.com/recaptcha — choose reCAPTCHA v2 (checkbox).

📌 After saving, keys are auto-published to the database so the website picks them up immediately. No manual file editing needed.

🔐 Change Password

🔑 Biometric Login

Checking…

Enable Face ID, Touch ID, Windows Hello, or your device's fingerprint as a faster login option. Your password keeps working exactly as before — biometric is an additive alternative, not a replacement. Enroll multiple devices (phone, laptop, tablet) and each can unlock your account independently.

📌 Passkeys are stored on your user record (u.passkeys[]) so they sync to every device you're logged in on. You still need to enroll each physical device once — the enrollment is tied to that device's secure element / TPM.

⏱️ Session Timeout

Loading…

Automatically log out idle admin sessions for security. A warning modal appears before logout so you can stay signed in. Disable for shared/kiosk workstations where long sessions are expected.

🧹 Clear Demo Data

Scan for and disconnect any social media accounts or other records that appear to be seeded demo data (no real OAuth tokens, duplicated handles across platforms). Real connections with valid credentials are preserved and untouched. Useful if the Social Status widget shows platforms as "connected" when they aren't, or if test data from initial setup is still showing on the dashboard.

🔄 Reset Dashboard Layout

If dashboard widgets appear overlapping, off-screen, or stuck in wrong positions, click the button below to clear your saved layout. Widgets will return to their default grid positions. Your widget visibility settings (from the 🧩 Dashboard Widgets picker) are preserved.

📌 Only clears the layout on this device for this user. Other team members' layouts aren't affected.

🌐 Server Connection

⬤ Not configured

Connect the admin portal directly to your cPanel server. Once configured, any Save & Publish action will push data live to your website instantly — no manual file uploads needed.

🔒 Database Backup & Restore

✅ Server-side cron supported
🕐 Server-Side Automatic Backup — no login required Add this cron command to your server (cPanel → Cron Jobs) to back up the database automatically:
Loading — open Settings to see your cron URL Recommended schedule: daily at 2:00 AM
Saves to: repo/dbbackup/ on the web server (last 30 backups kept automatically)

Save to Server stores the backup in repo/dbbackup/ on your web server — accessible from any device. Download Only saves to your local computer.

🖥️ Server Backups (repo/dbbackup/)
Loading server backups…
⏰ Scheduled Daily Backup

Daily backup runs automatically in the browser when the admin portal is open at the scheduled time. Downloads a timestamped JSON file you can store safely.

📋 Backup History

🗄️ Local Database — cPanel File

Save all admin data as a single JSON file for your web hosting

Every change you make in this portal can be saved as ilearn-database.json — upload this file to your cPanel public_html folder alongside ilearn-db.php. The main website will read provider/ticker data from it automatically.

✉️ Email Template Customizer

Header Design
Footer Design
Available Variables
{{parent_name}} {{child_name}} {{amount}} {{receipt_num}} {{date}} {{period}} {{provider_name}} {{agency_name}} {{agency_phone}} {{payment_type}} {{payment_method}}
📧 Email Preview

📧 Email Delivery Settings

Choose which provider sends all portal emails

When M365 is connected, it can handle all email functions — password resets, welcome emails, notifications, and calendar invites — sent directly from your organisation's mailbox.

📧 Google / Gmail Email Settings

Not configured

Connect Gmail (or any Google Workspace account) to send emails directly from the admin portal. Use an App Password (not your regular password) — requires 2FA enabled on your Google account.

📋 Setup Steps:
1. Go to myaccount.google.com/security → Enable 2-Step Verification
2. Go to myaccount.google.com/apppasswords → Create App Password → Select "Mail"
3. Copy the 16-character password and paste it below
4. Emails will be sent via smtp.gmail.com:587 using TLS encryption
Click "Send Test Email" to verify your Gmail connection.
✉️ What Gmail enables in this portal:
✓ Password reset temp passwords emailed to users
✓ Payment receipt emails to parents
✓ Welcome emails to new contacts and subscribers
✓ T4 slip delivery to providers
✓ Task assignment alerts
✓ Auto-dialer email notifications
✓ All email templates in Settings → Email Templates

📧 User Welcome Email

Configure CC/BCC for welcome emails sent when new users are created.

📅 Google Calendar

Connect Google Calendar to sync tasks and reminders. Tasks are created with a [iLearn Task] prefix.

Status: Not connected

📅 Google Calendar Integration

Create a project at console.cloud.google.com, enable the Calendar API, then paste your OAuth credentials below.

🔷 Microsoft 365 Integration

Not connected
Step 1: Register at portal.azure.com → Azure AD → App Registrations.
Step 2: Add a Single-page Application redirect URI pointing to this page.
Step 3: Grant Graph permissions: Mail.Send and Calendars.ReadWrite.
Step 4: Paste your IDs below and click Save Settings, then Connect & Authorise.
🎥 Test Teams Meeting Capability
Click to verify this mailbox can actually create Microsoft Teams meetings via Graph. Creates + deletes a throwaway event — no attendees receive invites.
📋 Microsoft 365 Activity Log 0 entries
Log is empty. Actions on this panel (Save Settings, Connect, Run Diagnostics, Send Test Email) will be recorded here with full details.
🔷 When Microsoft 365 is set as the active email provider:
✓ Password reset emails (new users)
✓ Welcome emails to new contacts
✓ Welcome emails to new subscribers
✓ Invoice & payment receipt emails
✓ T4 slip delivery to providers
✓ Task assignment & reminder alerts
✓ Broadcast / newsletter campaigns
✓ Email Client inbox (Microsoft 365 mailbox)
✓ Email Client compose & send
✓ Email Client reply & forward
✓ Calendar events in Outlook Calendar
✓ Auto-dialer email notifications
📧 All emails sent directly from info@ilearnhcc.com via Microsoft Graph API — no SMTP server required

🤖 Claude AI — Website Chatbot

Get your key at console.anthropic.com. The key is stored in the server database and used by ilearn-db.php to proxy chatbot requests — it is never exposed to website visitors. Publish to server after saving.

📱 SMS Configuration (Twilio)

Not configured
Requires a Twilio account at twilio.com

💳 Payment Gateway Integration

Attach payment links to invoices

🔔 Push Notifications (Firebase Cloud Messaging)

Not configured
Wire up WhatsApp-style push notifications. When configured, the iLearn Android app will receive push notifications on new direct chat messages, website bookings, and new leads — even when the app is closed. Create a Firebase project, then download the service account JSON from Project Settings → Service accounts → Generate new private key.
🎚️ Notification types — toggle which events trigger a push

📄 Purchase Order Defaults

🔔 Task & Reminder Alert Settings

🏷️ Version & Release Info

Website:  |  Admin Portal:  |  Updated:
Loading…

Connect Account

Authorize iLearn Admin to post on your behalf.

Skip to main content