📈 Contact Growth
Last 6 months🔥 Activity
📦 Inventory Overview
View all →👥 Types
📱 Social Status
⚡ Quick Actions
👥 Team Presence
—✅ Tasks & Assignments
📓 Quick Notes
💰 Revenue & Expenses
📊 Pipeline Forecast
🏆 Top Performers
Last 7 days📌 Pinned Links
| Name | Phone | Address | Type | Date Added | Status | Providers | Actions |
|---|
🏢 Suppliers
Manage vendors, contractors and service providers.
| Company | Contact | Category | Phone | 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 |
|---|
📦 Add Asset
🔄 Check Out / In
🏢 Add Supplier
🎯 Lead Generation
Track prospects, manage the pipeline and convert leads into clients.
🎯 Add Lead
⚙️ Email Account Settings
imap-mail.outlook.com port 993.
✏️ New Message
| Name | Phone | Date | Status | Actions |
|---|
🔗 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.
✏️ Compose & Broadcast
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.
📋 Post History
| Content | Platforms | Date | Status | Providers | Actions |
|---|
| Name | Subject | Recipients | Date | Status | Actions |
|---|
📢 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.
🗺️ Provider Map Manager
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.
| Title + Preview | Category | Date | Status | Actions |
|---|
| Author | Testimonial | Rating | Status | Actions |
|---|
✅ Add Task
FAQ Items
💡 Tips
• 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. 🦋 💰 🛡️ 📍 🕐 🚀)
+ Add FAQ
🔍 Audit Trail
📋 Activity Log shows admin actions only. 💬 Team Notifications shows website form submissions and chat alerts.
📋 Activity Log
| Time ↕ | User ↕ | Role ↕ | Action ↕ | Details | IP / Location | Session ↕ |
|---|
💳 Parent Payments & Attendance
Track enrollment payments, attendance, and send automated receipts.
🔗 Payment Links & Reminders
Included in all payment reminder emails
📧 Receipt Settings
| Date | Parent / Child | Amount | Type | Method | Period | Receipt | Actions |
|---|
+ Record Payment
+ Add Attendance
+ Add Enrollment
💵 Payroll & T4 Management
Track provider pay periods, upload T4 slips, and manage payroll records.
📅 Pay Period Records
| Provider | Pay Period | Start | End | Gross Pay | Deductions | Net Pay | Status | Actions |
|---|
🧾 T4 Slips
Upload T4 slips for each provider. Files are stored securely and can be sent directly to providers via email.
+ Add Pay Period
⬆ Upload T4 Slip
📄 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
✓ Included as attachment in provider welcome emails
✓ Download link appears on main website automatically
📋 Child Registration Form
✓ 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.
📁 Upload History
📝 Provider Notes
✅ Tasks & Reminders
| Task | Assigned To | Priority | Due Date | Category | Status | Alerts | Actions |
|---|
🧾 Invoices & Expenses
| Invoice # | Supplier | Date | Due | Category | Amount | Status | Actions |
|---|
🧾 New Invoice
🧾 Invoice Preview
🤖 AI Bulk Invoice Import
Paste multiple receipts, invoice text, or upload a CSV. The AI reads each one and creates invoice records automatically.
📣 Auto Dialer & Notifications
Send bulk email and SMS notifications to parents, providers, or all contacts based on pre-built scenarios.
📋 Scenarios
🚸 Child Absent Alert
📊 Reports & Analytics
Comprehensive financial and operational summaries — exportable and emailable.
💰 Revenue vs Expenses
🥧 Expense Breakdown
📈 Payment Trend
💚 Revenue Breakdown
🔴 Expense Breakdown
💳 Parent Payment Transactions
| Date | Parent | Amount | Type | Receipt # | Status |
|---|
⚠️ Outstanding / Follow-up Required
No outstanding items tracked yet.
📁 File Repository
Shared file storage — policies, training, reports, templates and more.
📎 File Details
❓ Help & User Guide
Complete reference for the iLearn Admin Portal and public website.
📋 Contents
🚫 Blocked Dates
Blocked dates appear as unavailable on the main website booking calendar.
📅 Scheduled Meetings v5.08 · NEW
| Title | Attendee | Date | Time | Duration | Status | Actions |
|---|
📋 Booking Requests
| Name | Date | Time | Topic | Status | Actions |
|---|
🚫 Block a Date
📅 Reschedule Booking
📅 Schedule a Meeting
OnlineMeetings.ReadWrite permission. The join URL is auto-generated and included in the calendar invite sent to the attendee.| Name | Role | Employee # | Responsibilities | Status | Created | Last Login | Actions |
|---|
🔒 Role Permissions
📄 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
- 🎯 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),
#v440BreadcrumbHostisposition:fixed; top:62px; height:37px(bottom at y=91 on mobile), and in-page tab bars like.social-tabsareposition:staticby default. At any small scrollY, the static tab bar slides behind the fixed breadcrumb and gets half-obscured. v5.65'sscroll-padding-top: 72pxwas 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"]) becomesposition:sticky; top:91px; z-index:5on screens ≤768px — rides along right below the breadcrumb with a subtlebackdrop-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 withscroll-snapfor 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,scrollIntoViewcalls) now land targets 110px from the top = 54px topbar + 37px breadcrumb + 19px visual buffer.scroll-margin-top: 110pxapplied 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
.cardin each.pagegetsmargin-top: 14px(was 8px from v5.65)..pagetop 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 wastop: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.
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.php—PORTAL_BUILD→ v5.67.
- Upload 11 files → purge Cloudflare → hard-reload. Title bar reads v5.67 · Apr 2026.
- 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.
- 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.
- Settings: the horizontal tab bar (Portal / Account / etc.) stays anchored at the top on mobile while the card content below scrolls.
- 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.
- 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.
- Desktop unchanged: resize to ≥1024px — tab bars revert to
position:static, scroll offsets are 0, first-card margins return to defaults. No regressions.
iLearnHCC_v5_66.zip.
- 🐛 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'swrapSetThemephone guard (line 10563–10576) wrote'dark'tolocalStorage.il_themewheneversetTheme('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 accidentalShift+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()readil_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, becausesetTheme()didn't uselogAct. - 🔒 Fix 1 — wrapSetTheme no longer persists 'dark' on phone. It's now a true no-op: if
setTheme('dark')fires whilewindow.innerWidth ≤ 768, nothing is written to localStorage,data-themestays'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 newv566Remediate()runs: ifil_theme='dark'ANDil_v566_dark_remediatedflag 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 bothil_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()orsetTheme('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 markeril_v566_dark_remediatedin localStorage is a tell — if missing, the user somehow cleared their localStorage and the remediation will re-run next load.
ilearnhcc-admin-v2.html—wrapSetTheme(inside v5.23 mobile-polish block at line 10559): removedlocalStorage.setItem('il_theme','dark'), toast text updated, guard flag renamed to_v566PhoneGuard(keeps_v523PhoneGuardas alias for back-compat). Newv566Remediate()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.php—PORTAL_BUILD→ v5.66.
- Upload 11 files → purge Cloudflare → hard-reload on desktop. Title reads v5.67 · Apr 2026.
- 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. - Verify marker: DevTools → Application → Local Storage → confirm
il_v566_dark_remediated='1'exists. - 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. - Phone guard behaves: resize browser to ≤768px, Shift+D → UI stays light, toast "☀️ Dark mode is only available on desktop",
il_themevalue in localStorage UNCHANGED. Resize back to ≥1024px → UI still matches whatever desktop was set to before the phone-width foray. - 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.
iLearnHCC_v5_65.zip. Note: the leaked il_theme='dark' in storage will re-activate on rollback unless you also clear the key manually.
- 🔘 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:
.btnpadding drops from8px 18px→7px 12px, font-size.82rem→.76rem, border-radius 50px → 40px to match the shorter height. Full-width variants (Quick Actions, Settings action buttons) getpadding: 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 to6px 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: 72pxonhtml, 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: 72pxon 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).contenttop padding reduced from 24px (desktop) to 12px (mobile),.pagegets a fresh 6px top pad, and the first.cardin each page getsmargin-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: smoothonhtml, bodyat the mobile breakpoint so nav events glide to their target instead of snapping (most jarring when combined with the 72px offset). Works with existingnav('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.3on mobile — visibly shorter while staying readable and tap-friendly (select arrow + native touch area preserved).
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)(+≤420pxsub-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.php—PORTAL_BUILD→ v5.65.
- Upload 11 files → purge Cloudflare → hard-reload. Title bar reads v5.67 · Apr 2026.
- Quick Actions widget on mobile dashboard: buttons should now be noticeably shorter. 8 action buttons should fit where 6 used to fit before scrolling.
- 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.
- 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).
- 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.
- 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.
iLearnHCC_v5_64.zip.
- 🔁 "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 calledsd('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)pushKeyToServerMERGES 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 callspushDeleteToServer('il_leads', id). Tombstones instruct the server's merge logic to treat the id as intentionally-deleted; subsequentautoLoadFromServerpulls 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 keyil_v564_last_dedupe_toaststores{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()callspushLeadToServer(_lead)with a freshDate.now()+1id 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.
ilearn-admin.js— lead dedupe sweep (around line 37377-37400): replacedpushKeyToServer('il_leads', keepers)with per-recordtombstoneAdd('leads', id)+pushDeleteToServer('il_leads', id)+ trailingpublishToServer(true). Added localStorage-backed toast gate (il_v564_last_dedupe_toast)._PORTAL_BUILD→ v5.64.ilearn-db.php—PORTAL_BUILD→ v5.64. No handler changes.ilearnhcc-admin-v2.html— v5.64 release-notes card; title/sidebar/readme version bumps.
- Upload 11 files → purge Cloudflare → hard-reload. Title reads v5.67 · Apr 2026.
- 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_toastshould 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. - 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.
- Reload again after 5 minutes. Still no toast, still no dupes. That's the persistence fix working.
- 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.
iLearnHCC_v5_63.zip.
- 📋 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:0with 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 buttonselectors — 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:98vwand adisplay:flexphoto row with 88px avatar + child photos + two upload buttons side-by-side — completely broken on a 390px phone. Fixes: (a) Every modal width locked tocalc(100vw - 16px)with 8px margin, max-heightcalc(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 withscroll-snap-type: x proximityso one tab is always centered after a swipe. (f) Modal footer buttons wrap to full-width withflex: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.modalvia 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.
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≤420pxsub-query for small-screen extra compaction. Zero desktop impact.ilearn-admin.js—_PORTAL_BUILD→ v5.63. No logic changes.ilearn-db.php—PORTAL_BUILD→ v5.63. No handler changes.
- Upload 11 files → purge Cloudflare → hard-reload. Title bar reads v5.67 · Apr 2026.
- 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.
- All other tables: Suppliers/Bookings/Subscribers/Campaigns/Leads/Blog/Testimonials/Inventory/Audit/Meetings/Users/Tasks all show 3 useful columns + compact action icons.
- 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.
- Topbar gear: no overlap at 390px. Labels "Alerts/Email/Chat" hidden, icons visible, badges render normally.
- Desktop unchanged: resize Chrome window to ≥1024px → all 10 columns visible, full-size modals with 1060px max-width, action buttons at original size.
iLearnHCC_v5_62.zip.
- 📋 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,#_notifPillwith "Alerts" label,#topbarEmailBtnwith "Email" label,#topbarChatBtnwith "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-rnow hasmax-width: calc(100vw - 110px),overflow: hidden, andflex-wrap: nowrapso 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:noneon#_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-wordapplied so long names/emails wrap rather than overflow. Cellmax-widthconstraints removed so content flows naturally.
ilearnhcc-admin-v2.html— new<style id="v562-css">block at end of file, scoped inside@media (max-width: 768px)(+ a≤420pxsub-query). Reverses v5.61'sdisplay:block; overflow-x:autotable rules and replaces withdisplay:table; min-width:0. ~20 per-tbody column-hiding rules. Topbar.tb-rconstraints + 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.php—PORTAL_BUILD→ v5.62. No handler changes.
- Upload all 11 files → purge Cloudflare → hard-reload.
- Title bar reads v5.67 · Apr 2026;
db_pingreturnsbuild: 'v5.62'. - 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.
- 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.
- 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.
- Desktop unchanged: open the same pages on a desktop or ≥1024px browser window — all 10 columns visible exactly as before. No regression.
iLearnHCC_v5_61.zip (last validated deployed build).
- 🎥 "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/eventswithisOnlineMeeting: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 withisOnlineMeeting: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 ofonlineMeetingblock) and flags the mismatch asteamsSilentlyStripped. When detected: (a) stampsbooking.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-scrolltable getsdisplay: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) isposition:sticky; left:0so the primary key stays visible as the admin scrolls sideways. (b) All modals getmax-width:calc(100vw - 20px); width:calc(100vw - 20px)with!importantto override inline styles — no more modal close buttons escaping off-screen. (c) All.page > *children capped atmax-width:100%; box-sizing:border-boxwith page-leveloverflow-x:hidden— no more 1618px-wide phantom elements dragging the viewport sideways. (d) Card headers useflex-wrap:wrapwithrow-gap:8pxso multi-part headers (title + badge + button) stack cleanly on narrow screens instead of clipping. (e) All text/email/number/date inputs forced tofont-size:16pxto 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 isdisplay:noneoutside the mobile media query, so desktop users still find biometrics in the existing Settings → Account → Biometric Login card.
ilearn-admin.js—createM365CalendarEventdetects silent Teams strip, stamps booking withteamsDiagnosticmarker, writes explicit audit/m365 log entries;renderBookingsrenders 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">containingtestTeamsCapability()handler + mobile biometrics tile injector (wrapsnav('settings')); v5.61 release-notes card; title/sidebar/readme version bumps.ilearn-db.php—PORTAL_BUILD→ v5.61. No handler changes required (all fixes are client-side).
- Upload all 11 files → purge Cloudflare → hard-reload.
- Title bar reads v5.67 · Apr 2026;
db_pingreturnsbuild: 'v5.61'. - 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". - 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.
- 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.
- 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.
- 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. - 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.
iLearnHCC_v5_60.zip (last validated deployed build).
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.
- 📧 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._confirmationEmailSentwas being wiped byautoLoadFromServerbetween the onComplete email send and the fallback timer fire._sendBookingConfirmationEmailsent the email viacreateM365CalendarEvent'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._sendBookingConfirmationEmailnow (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. Legacybooking._confirmationEmailSentkept for compat and also now triggerspublishToServer(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_actpath 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 JSsetBookingStatusre-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 secondPATCHright after the initial event POST: once Graph returns theonlineMeeting.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. - 📇
eventIdnow always saved on Graph success. Previouslybooking.eventIdwas only persisted inside theif (wantsTeams && onlineMeeting.joinUrl)branch — so every plain ✓-approved booking (no Teams) had a nulleventId, which silently broke reschedule-after-accept for those bookings (updateCalendarEventat HTML line 20020 skips early when!booking.eventId). v5.60 moves theeventIdsave 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.
ilearn-admin.js— new_v560WasConfirmationEmailSent/_v560MarkConfirmationEmailSenthelpers (localStorageil_v560_confirm_sent_idsset);_sendBookingConfirmationEmailmarks-before-send and gates on the new set;setBookingStatusadds re-accept guard with friendly toast;createM365CalendarEventreworked: event body now carries{{TEAMS_BLOCK}}placeholder → substituted via second PATCH when Teams URL returns → resolves to empty string for non-Teams bookings;eventIdsave moved outside Teams conditional; audit-log text distinguishes Teams outcomes;_PORTAL_BUILD→ v5.60.ilearn-db.php—PORTAL_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.
- Upload all 11 files → confirm
.htaccesshas the leading dot → purge Cloudflare → hard-reload. - Title bar reads v5.67 · Apr 2026;
db_pingreturnsbuild: 'v5.60'. - 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. - Re-accept guard: on the already-accepted booking, click ✓ again. Toast reads "ℹ️ Booking already accepted — confirmation email already sent." — zero new emails fire.
- 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.
- eventId coverage: inspect any v5.60-era booking in DevTools after Accept —
eventIdshould be set regardless of whether ✓ or 🎥 was used. Reschedule a ✓-accepted (non-Teams) booking — the Outlook event should move to the new time.
iLearnHCC_v5_59.zip (the last validated deployed build).
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.
- 🏷️ 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 — wrappedautoLoadFromServerso 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 byil_v559_recordno_providers_migrated, stamps every existing provider once per device, inheriting from the linked lead's recordNo viaconvertedFromLeadwhen one exists. Idempotent and self-disabling after first run. - 📋 Audit reports now include Record #.
logAct(icon, msg)andlogAudit(icon, category, details, user, role)both now accept an optional trailingrecordNoargument. When not passed, they auto-extract aREC-YYYY-NNNNtoken from the message/details if one's embedded (common for save-triggered audit entries). Every audit entry now carries arecordNofield — empty string when unknown, which is fine for system-level events like logins. TheexportAuditCSV gains a Record # column (header is nowTime, User, Role, Category, Record #, Details), andarchiveAuditexports 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:pushSubscriberToServernow returns a Promise resolving to the parsed response.nlSubawaits it and shows "✉️ You're already subscribed to our newsletter!" for dupes. Also gates thepublic_notifyadmin-alert fetch onadded===trueso 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_slotsreturns 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 eachopenBooking()so a returning visitor never sees stale availability. (c)confirmBookingdoes 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 insidepublic_append_booking'satomic_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 fromil_cal_settings.businessHoursif 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.
ilearn-admin.js— added'providers'to_v546SweepMissingkeys +saveProviderssave wrapper +autoLoadFromServerpost-sync sweep hook + one-time provider recordNo backfill (gated byil_v559_recordno_providers_migrated);logAct+logAuditaccept optionalrecordNo+ auto-extractREC-YYYY-NNNN;exportAudit+archiveAuditCSVs gain Record # column;_PORTAL_BUILD→ v5.59.ilearn-db.php— newpublic_get_booked_slotspublic endpoint (rate-limited 60/hr, zero PII); server-side double-book guard inpublic_append_booking's atomic update with HTTP 409 response;PORTAL_BUILD→ v5.59.ilearnhcc-website.html—pushSubscriberToServerpromisified;nlSubawaits and toasts the right message;openBookingflushes slot cache;selectDatefetches taken slots and disables taken time-slot buttons;confirmBookingdoes final availability re-check + 409 handling; business-hours config helper readsil_cal_settings.businessHours.ilearnhcc-admin-v2.html— v5.59 release-notes card; title/sidebar/readme version bumps.
- Upload all 11 files → confirm
.htaccesshas the leading dot → purge Cloudflare → hard-reload. - Title bar reads v5.67 · Apr 2026;
db_pingreturnsbuild: 'v5.59'. - Provider recordNo: open Providers page — all 13 existing providers now show a Record # pill. Console should log
[iLearn v5.59] providers recordNo backfill completeonce. Add a new test provider manually → its recordNo pill appears immediately on save, not on next reload. - 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.
- 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.
- 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.
iLearnHCC_v5_58.zip (the last validated deployed build).
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.
- 📅 Booking reschedule now includes duration + email + calendar stay in sync. Three separate bugs were resolved together. (1)
updateCalendarEvent(fires on reschedule) had a hardcoded60*60*1000for 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 asbooking.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 thereschedHistoryaudit trail, and swaps both hardcoded60*60*1000sites for(booking.duration || 30) * 60 * 1000. - ⏱ New bookings default to 30 minutes — admin can extend. Website booking calendar at
ilearnhcc-website.htmlwas defaulting to 60 min and reading any duration fromil_cal_settingswithout 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 tosendPortalEmail/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 fromag.template_header_img→ag.logo_data→localStorage.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
_doGlobalSearchwith: (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 just12345now matchesrecordNoacross contacts, leads, providers, and suppliers. Fills the "search by record number — deferred" gap noted in the v5.50 release notes.
- 📦 Archived Leads kanban filter fix — kanban cards now carry
data-lead-idattribute 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 +
oniceconnectionstatechangelistener +pagehidehangup + globalwindow._v557ResetCallState()escape hatch prevent_statefrom getting stuck at'connecting'and auto-declining every subsequent offer as busy.
- 📥 Chat restore banner is one-time + actually works —
il_v556_restore_decisionlocalStorage flag plus newchat_file_writeserver action that writes to bothilearn-chat.jsonAND 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
setBookingStatusand 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.
ilearn-db.php—chat_file_repair+chat_file_writeactions (bundled from v5.55/v5.56),chat_sendsignal age-out + main-DB-mirror filter,chat_pollstale-signal filter,PORTAL_BUILD→ v5.58.ilearnhcc-admin-v2.html— v529 script block: timestamp-staleness check, localStorage-persisted dedup,chat_file_repairIIFE + one-time restore banner with new decision flag,oniceconnectionstatechangelistener,_v557ConnectingWatchdog,_v557ResetCallState,pagehidehangup. 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.renderLeadKanbancard gainsdata-lead-id.setBookingStatusfallback 2500 → 15000 ms (+ same for meetings flow).createM365CalendarEventrespectsbooking.duration. Auto-brand wrappers insendPortalEmail+sendEmailAny. Global search feature catalog + record-no lookup._PORTAL_BUILD→ v5.58.ilearnhcc-website.html— booking duration default 60 → 30, cap at 30 min.
- Upload all 11 files → confirm
.htaccesshas the leading dot → purge Cloudflare → hard-reload. - Title bar shows v5.67 · Apr 2026;
db_pingreturnsbuild: 'v5.58'. - 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.
- Leads archived view: Leads → click 📦 Archived Leads → kanban filters to archived-only; click again → returns to all active leads.
- 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.
- 30-min default: open website booking calendar → inspect payload —
durationfield is 30. Admin Meetings form → Duration dropdown shows "30 min" selected by default. - 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.
- Global search: type "reschedule" / "archived" / "reset call" / "voice memo" → clickable results appear. Type a record-number like
#1234or1234→ matches any contact/lead/provider/supplier with that recordNo. - Calls: two-tab test. If a call ever shows bogus "peer in another call," run
_v557ResetCallState()in DevTools.
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:renderLeadKanbaninilearn-admin.js(~line 25683) wrote kanban cards withonclick="editLead(id)"but NOdata-lead-idattribute. 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 addsdata-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
_statecould get stuck at'connecting'after a call attempt where ICE negotiation silently stalled (common on symmetric NAT / cellular networks without TURN configured). Neitheronconnectionstatechangenoroniceconnectionstatechangefires in that limbo on some browsers, so_statenever returns to'idle'. Every subsequent incoming offer then hit theif (_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 thev529-voicevideo-jsblock:- New
oniceconnectionstatechangelistener. Some browsers surface ICE failures via this event but NOT viaonconnectionstatechange. We listen on both and reset on'failed'. - 45-second
_v557ConnectingWatchdogstarts 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_resetCalland a user-visible "⚠️ Call connection timed out" toast. Cleared on successful transition to'in-call'and in_resetCall. pagehidelistener sends a best-effort hangup signal when the tab is closed mid-call, so the peer's_pctransitions to'disconnected'(which DOES reset them) instead of being orphaned in'connecting'.- Global escape hatch
window._v557ResetCallState()— callable from DevTools console to force_stateback to'idle'without a page reload. If a user reports stuck "busy" state in the field, they can run this to unstick calling immediately.
- New
- 📌 Scope. Two files touched:
ilearn-admin.js—renderLeadKanbancard div gainsdata-lead-idattribute (1 line);_PORTAL_BUILDbumpedilearnhcc-admin-v2.html— v529 script block:oniceconnectionstatechangehandler,_v557StartConnectingWatchdog/_v557ClearConnectingWatchdog, watchdog started in 2 places (caller_handleAnswerand callee_v529AcceptIncoming), cleared in_resetCalland on successful connection,window._v557ResetCallStateescape hatch,pagehidelistener with best-effort hangup
PORTAL_BUILDbumped 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.
- 📥 Restore decision flag (one-time semantics, Yes/Dismiss both stamp it)
- 💾
chat_file_writeaction writes to bothilearn-chat.jsonand 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)
- 📦 Archived Leads button appeared to do nothing — root-caused and fixed. Two separate archived-view filters exist (v5.30's
_applyArchivedOnlyFilterat HTML line ~14498, and v5.32'sapplyArchivedOnlyFilterat line ~15853). Both callboard.querySelectorAll('[data-lead-id]')to find kanban cards that should be hidden when not archived. Problem: the kanban card HTML generator inrenderLeadKanban()(ilearn-admin.js line 25683) never wrote that attribute — it only addedonclick="editLead(id)". Result:querySelectorAllreturned 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 inrenderLeadKanban— the outer card div now hasdata-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
_state→outgoing-ringing; peer accepts → both sides go toconnecting. Inconnecting,_pc.onconnectionstatechangeor_pc.oniceconnectionstatechangeis supposed to move us forward toin-call(on success) oridle(on failure via_resetCall). On some NAT/firewall combos, neither event ever fires when ICE silently stalls —_pcsits inconnectinglimbo indefinitely, so_statestays stuck atconnecting. Every subsequent incoming offer then hits theif (_state !== 'idle')branch in_handleIncomingOfferand auto-declines withreason: 'busy'. The caller sees "peer in another call" even though the callee isn't. v5.57 adds FOUR defenses: (1)_pc.oniceconnectionstatechangelistener — catches ICE failures thatconnectionstatechangemisses. (2)_v557ConnectingWatchdog— 45-second hard cap on anyconnectingtransition. If we haven't reachedin-callby then, force_resetCalland toast "Call connection timed out." (3)pagehidelistener — 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 torenderLeadKanbancard HTML:data-lead-id="'+l.id+'";_PORTAL_BUILDbumpedilearnhcc-admin-v2.html— v5.29 script block: new_pc.oniceconnectionstatechangelistener;_v557ConnectingWatchdogtimer started on bothconnectingtransitions (caller-received-answer + callee-accepted); watchdog cleared in_resetCall,onconnectionstatechange, andoniceconnectionstatechangeon success;pagehideevent listener;window._v557ResetCallStateescape hatch;PORTAL_BUILDbumped
PORTAL_BUILDbumped 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, resettingafter a stuck call, or[iLearn call v5.57] ICE failed — resettingon 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-idis 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.
- 📥 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)
- 📥 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_pollreadsilearn-chat.jsonfirst and only falls back to the main DB'sil_chat_messagesif the file doesn't exist. v5.55's restore wrote to main DB only viareplace_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 actionchat_file_writethat accepts{messages:[...]}and overwrites BOTHilearn-chat.jsonANDil_chat_messagesin the main DB (with defensive_callSignalfiltering and a 500-item cap to matchchat_send's non-signal cap). The client restore now callschat_file_writeinstead ofreplace_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_notificationsis 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-secondsetTimeoutfallback fired_sendBookingConfirmationEmailwithteamsJoinUrl:nullBEFORE Microsoft Graph finished creating the Teams meeting (Graph commonly takes 3–5 s). The fallback stamped_confirmationEmailSent:trueas 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 becausecreateM365CalendarEventALWAYS firesonCompletesynchronously in every code path including the no-M365-configured case, so a legit no-M365 booking still emails in ~0 ms via theonCompletepath; the 15 s timer just never finds anything to do. Same two-line fix applied to the meetings flow (_createM365MeetingEventcaller) which had an identical race. - 📌 Scope. Three files touched:
ilearn-db.php— newchat_file_writeaction that writes to bothilearn-chat.jsonANDil_chat_messages;PORTAL_BUILDbumpedilearnhcc-admin-v2.html— IIFE'sdoRestoreswitched fromreplace_keytochat_file_write;maybeOfferRestoregated on newil_v556_restore_decisionflag; banner DOM rewritten with clearer copy explaining notifications can't be restored; Yes/Dismiss handlers stamp the decision flagilearn-admin.js—setBookingStatusfallback bumped 2500 → 15000 ms; same bump in the meetings-flow fallback;_PORTAL_BUILDbumped
PORTAL_BUILDbumped 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).
- 👻 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
- 👻 "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.jsonkept call signals in a 400-item ring buffer — hours of retention in real use. On every fresh reload,appendChatMessagesreplayed historical offer signals through_handleIncomingOfferbefore 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 —_handleIncomingOffernow rejects any offer whosemsg.timestampis 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 —_v554HandledSessionsnow reads/writes tolocalStorage.il_v555_handled_call_sessionsso decline/accept state survives refresh. (3) Server-side AGE-OUT —chat_sendfilters signals > 120 s old fromilearn-chat.jsonon every write;chat_pollfilters 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) toil_chat_messages, which was why the main DB grew to 400 signals + 1 real message. Now only non-signal messages are mirrored;il_chat_messageswill always contain only real text + voice memos. - 🧹 One-time chat-file scrub. New server action
chat_file_repairreadsilearn-chat.json, strips every_callSignalentry regardless of age, rewrites the file, and syncs the cleaned array toil_chat_messages. The client fires this automatically once per browser on first load of v5.55 (gated bylocalStorage.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
/repoand callschat_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_messageshas fewer than 5 real text messages. If so, it offers a toast button "📥 Restore 165 text messages from April 13 backup?" — clicking it fetchesbackup-2026-04-13_215846.jsonvia the existingrestore_backupendpoint, merges the 165 real messages with any current voice memos (dedupes byid), and pushes viareplace_key. Non-destructive default; requires one explicit click from you. Skip it and nothing changes. - ℹ️ Notifications — scope note.
il_notificationsis 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 foril_notificationsis out of scope for v5.55 to keep this release tight — flagging as a v5.56 candidate. - 📌 Scope. Three files touched:
ilearn-db.php—chat_sendadds pre-append signal age-out (120 s) and main-DB-mirror signal-filter;chat_pollresponse-filter for stale signals; newchat_file_repairaction;PORTAL_BUILDbumpedilearnhcc-admin-v2.html— v5.29 script block:_handleIncomingOffertimestamp staleness check (_V555_STALE_OFFER_MS = 60 s);_v554HandledSessionspersisted tolocalStorage.il_v555_handled_call_sessions; new on-load IIFEil_v555_chat_cleanup_and_restorethat callschat_file_repairand offers the opt-in restore toastilearn-admin.js— v5.52 voice-memo block refactored:_stopAndSendreplaced by_stopToPreview+_sendPreview; inline preview bubble UI (▶️ ✕ ➤) injected into chat composer;window._v552ToggleVoiceRecordnow handles start/stop/discard-preview states;_PORTAL_BUILDbumped
PORTAL_BUILDbumped 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 fileshould 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).
- 💾 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)
- 💾 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.dataproperty. A 20-second webm/opus memo is ~250KB base64-encoded. Every voice memo sent was synced toil_chat_messagesin the main database viasave_db(). After several memos,il_chat_messagesballooned to multi-megabytes; the full DB hit PHP's memory limit during load/save;save_db()silently failed or truncated; the nextautoLoadFromServerpulled 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 viaupload_fileto/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 IIFEil_v554_voicememo_repairruns 5s after load: scansil_chat_messages, strips embedded_voiceMemo.datafrom 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:
_handleIncomingOfferhad no sessionId deduplication, so every poll cycle that replayed the same offer fromilearn-chat.jsonre-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 viahttps://graph.microsoft.com/v1.0/users/{email}/messages/{msg}/attachments/{att}with the active access token, decodescontentBytes, 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 readsurlwithdatafallback for legacy messages; v5.54 repair IIFE for existing messages; M365 attachment pills + download handler appendedilearnhcc-admin-v2.html— v5.29 script block:_v554HandledSessionsdedup map,_v554MarkSessionHandled/_v554IsSessionHandledhelpers,_v554ForceDismissIncomingescape hatch, ESC keydown listener, 45s ringtone-auto-dismiss wrap around_startRingtone/_stopRingtone
PORTAL_BUILDbumped 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.
- 🛠 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
_sendSignalfix (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)
- 🎯 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
_sendSignalfix (swapping the undefinedgetCfg()forgetSrvCfg()), signals finally started reachingchat_sendon the server — and that's when the SERVER started returning 500. The v5.29chat_sendhandler was doing four expensive things for every message, including ephemeral WebRTC call signals: (1) lock and writeilearn-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 ajson_encode, throw a fatal in FCM'sopenssl_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
_callSignalon the incoming message and writes it ONLY toilearn-chat.json. Not to the main DB.chat_pollalready reads fromilearn-chat.jsonfirst 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), andopenssl_signor 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_logat every failure path. Any failure now returns a structured JSON error with adetailfield AND writes a[iLearn chat_send v5.53] …entry to cPanel → Errors so you can diagnose exactly which step blew up (failedfopen, failedflock,json_encoderefused 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— thechat_sendaction block (~45 lines) rewritten in place (~95 lines with try/catch + branches). Zero changes to JS, HTML, manifest, SW, or database schema.PORTAL_BUILDbumped 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:NNNthat 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.
- 🎤 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
_sendSignalfix (client now usesgetSrvCfg— 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)
- 🎤 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 asaudio/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_voiceMemoproperty holding{ mime, duration, data }. On the receiver side, a wrap aroundbuildChatBubblereplaces 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_pingheartbeat detects any server-hash change and triggersautoLoadFromServer→renderContacts→ the v5.46 render wrap fires_v546DecorateAllvia a 50mssetTimeout. 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 —
_v546DecorateAllnow 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.
- Render throttle — if a specific render function (
- ⏱ 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 (
_sendSignalwas using undefinedgetCfg()instead ofgetSrvCfg()), 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) andilearnhcc-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_BUILDbumped 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.
- 🌐 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_configas{"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.
- 🐛 Root cause of "calls not reaching the other party": a one-liner bug in v5.29's signal-send path.
_sendSignalresolved the server config viawindow.getCfg()— butgetCfgdoesn't exist in this build; the real helper isgetSrvCfg. So every call signal (offer, answer, ICE, decline, hangup) hit theif (!cfg.url) returnguard 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 viagetSrvCfg()gets HTTP 200 + message id back from the server, whereas the v5.29 path short-circuited at the config check. Fix:_v551GetCfg()wrapper triesgetSrvCfg, falls back togetCfgif it's ever defined, then falls back to parsinglocalStorage.il_server_cfgdirectly. 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_DEBUGset in console. v5.51 unconditionally logs each failure asconsole.errorand 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.
- Caller ringback — 425Hz tone, 2 seconds on / 2 seconds off, while
- 🔓 AudioContext auto-unlock on first user interaction. Chrome's autoplay policy blocks
AudioContextcreation 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-fireclick/touchstart/keydownlistener 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_sharedAudioCtxthat 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.htmlinside 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,_sendSignalis fixed + instrumented,_startOutgoingCall/_handleAnswer/_handleDecline/_handleHangup/_v529AcceptIncoming/_v529DeclineIncoming/_v529Hangupeach get a chime + console log,_resetCallalso stops the ringback timer, and the public debug object gains_v551DiagCall.PORTAL_BUILDbumped 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_configas{"urls":"turn:…","username":"…","credential":"…"}. Log message on page load now includes "(no TURN — cross-network calls may fail)" to make the implication visible.
- 🔍 Global search now matches Record IDs. Type
REC-2026-0042— or even just a fragment like0042— 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-NNNNbadge now sits right next to the modal title (e.g. "✏️ Edit ContactREC-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 oneditContact,openM_sup,editInvoice,editLead,editPO, plus a belt-and-bracesopenM()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 everyautoLoadFromServersuccess path, right afteril_last_sync_tsis 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.jsat ~line 21213 insideautoLoadFromServerfor 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_BUILDbumped 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 like0007— 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 containsREC-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".
- 📎 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.
- 📐 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: stickywithz-index: 5and 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 hadoverflow:autobut neither hadmax-height, so they'd grow to fit the entire table content, pushing everything below the fold. v5.49 adds a single CSS rule that setsmax-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 becausemax-heightdoesn't force growth — only tables that exceed the frame get the contained-scroll behaviour. - 🧱 Border rendering preserved. Sticky thead requires
border-collapse: separateto render correctly across Chrome and Safari, which would otherwise break the existing border-bottom on body rows (border-collapse: collapsewas the old default). v5.49 restores the visual by re-adding per-cell bottom borders via explicitborder-bottom: 1px solid var(--b)ontbody tdand 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: touchon.twis 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.twrule). Zero changes to JS, zero changes to PHP, zero render-function changes.PORTAL_BUILDbumped 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.
- 🔎 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: 0on 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.
- 📇 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-NNNNas 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
#invTbodyexists 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,_v548InjectRowCellswalks 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 likeeditContact,editSupplier,editInvoice,editPO. Rows already containing atd.v548-recno-cellare 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_BUILDbumped 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.
- 🎯 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-NNNNnumbers, lead→contact inheritance at 7/7, counters persisted toil_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,#invoiceTbodyas 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 havedata-*-idattributes 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 trmarkup, 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: walksleads,contacts,suppliers,invoices, andpurchase_orders, calls the already-idempotent_v546EnsureRecordNoon any row without a recordNo, and persists viasd()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_v546DecorateAllreplaced 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_BUILDbumped 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-NNNNpill 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 leadsif 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.
- 🏷️ 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 whereverconvertedFromLead/convertedToContactIdlinkage already existed. - 🔧 How the number gets attached — five save-path wraps + one conversion wrap.
saveLead,saveContact,saveSupplier,saveInvoice, andsavePOare each wrapped with a stamp-after-save helper that calls the idempotent_v546EnsureRecordNoon 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.__v546Wrappedflags 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 letautoLoadFromServerfinish, then walks records in four passes: (1) leads sorted by id ascending get sequential numbers; if a lead hasconvertedToContactId, the matching contact inherits the same number; (2) remaining contacts are stamped, inheriting fromconvertedFromLeadwhen possible; (3) suppliers get independent numbers; (4) invoices and POs inherit from their matched supplier, or mint fresh if unmatched. The counter storeil_record_countersis persisted viasd()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, andrenderPOs. After each native render completes, the decorator walks the visible rows, looks up the corresponding record bydata-*-idattribute (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 storeds.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_v546helper functions insideopenM_sup(~line 25333) andeditInvoice(~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_BUILDbumped in both JS and PHP. Zero changes torenderBookings,renderLeads, or any other render function (the decorator wraps them externally). Zero schema changes —recordNois just a new string field added to existing records on save. Rollback = delete the IIFE + revert the two category-fix edits + clearlocalStorage.il_v546_recordno_migratedon 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 violetREC-YYYY-NNNNpill 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.
- 📧 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
sendPortalEmailand 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
globalSearchand 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.
- 📅 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 atilearn-db.php~line 6216 read only the canonicaldateandtimefields with no fallback to the human-readabledateLabel/timeLabel, so any row missing canonical data showed "TBD"; (2) thepublic_append_bookinghandler at ~line 5942 set$dateonly if the payload matched a strictYYYY-MM-DDregex and set$dateLabelonly from the payload'sdateLabelfield — never derived one from the other, so any pre-v5.38 website client that sent onlydate: "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, andpublic_append_bookingnow 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', theupdateBookingStatus()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_donein localStorage (v5.21 / v5.43 pattern). Walks every row inil_bookingsand: (a) rewritesstatus: 'Confirmed'→status: 'Accepted'; (b) back-fills missingdatefrom a parseabledateLabel; (c) back-fills missingdateLabelfrom a valid ISOdate; (d) recovers both when they're empty but the booking'snotesfield contains the canonical "Date: Apr 18, 2026 at 1:00 PM" pattern thatconfirmBooking()stamps into every new record. Any row that's changed triggers asd('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 invokescreateM365CalendarEventwithisOnlineMeeting: true+onlineMeetingProvider: 'teamsForBusiness'. On Graph success theonlineMeeting.joinUrlis captured, stamped onto the booking record asteamsJoinUrl, 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:setBookingStatusnormaliser (~line 8095),renderBookingsstatus normaliser + button labels (~lines 8063, 8080-8082), calendar-mini badge (~line 3046), calendar-day modal badge + buttons (~lines 17409, 17421-17423),updateBookingStatushelper (~line 23810), v5.45 one-time migration block (~line 21079).ilearn-db.php:public_append_bookingreverse-derivation (~line 5951), magic-link email label-fallback (~line 6215), magic-link handler writes 'Accepted' (~line 6196).PORTAL_BUILDbumped in both. Zero schema changes. Rollback = revert diffs + setlocalStorage.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.
- 🎥 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=approvein 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.
- 📦 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()atilearnhcc-admin-v2.html~line 13592 only injects the#v530-archived-bannerelement 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 onDOMContentLoadedAND whenevernav('leads')fires (window.navis wrapped) — it checksil_v530_archived_viewand 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_BUILDbumped 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.
- ⚙️ Teams link auto-generation on bookings — would require server-side M365 Graph
/me/eventsPOST withisOnlineMeeting: 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.
- 🎛️ 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.setItemcaught the exact culprit on the first try:autoLoadFromServer()atilearn-admin.js~line 21060 iterates everyil_*key in the server DB response and writes it back tolocalStorage. 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 → nextautoLoadFromServer(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
_neverSendinbuildDatabaseso future sessions stop pushing them to the server. (2) Added them to_neverOverwriteinautoLoadFromServerso 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 byil_v543_leads_cleanup_done) that fires?action=delete_keyfor each of the three keys on the server the first time each device loads v5.43, scrubbing the stale server record. (4) FixedresetAllLeadFilters()(in the v5.38 reset block) to also removeil_v530_archived_viewand 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_applyArchivedOnlyFilterwould 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 toresetAllLeadFilters()(one foril_v530_archived_viewremoval, three for banner removal).PORTAL_BUILDbumped to v5.43 in both JS and PHP. Zero schema changes. Rollback = revert the diffs, revert the build constants, optionally clearlocalStorage.il_v543_leads_cleanup_doneon any device that ran the cleanup. - 🔍 Diagnosis methodology worth calling out. Root cause was found by monkey-patching
Storage.prototype.setItemwith a stack-capturing wrapper, clearing the three keys, callingrenderLeads(), and reading back the captured writes. The stack trace pointed directly atautoLoadFromServerline 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.
- 👥 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 everyDOMContentLoadedby a v4.89-era hook atilearn-admin.jsline 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).#topbarAvatarCircleremains the sole topbar avatar surface.updateTopbarPhoto()and the#topbarUserPhotobranches inside_refreshSessionPhoto()silently no-op because their target element no longer exists. No other call sites depend on#topbarUserPhotobeing in the DOM. - 📌 Scope. One surgical edit to
ilearn-admin.js: lines 17684–17694 (the injection hook) commented out and annotated.PORTAL_BUILDbumped 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),
#srvDotis 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.
- ⚙️ Teams link auto-generation on bookings — would require server-side M365 Graph
/me/eventsPOST withisOnlineMeeting: 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.
- 👤 Topbar profile photo — the other half of the bug. v5.40 fixed the login-handler branch that was unconditionally hiding
#topbarAvatarImgand 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 nophotofield, yet the users roster (gd('users')) clearly contained Anthony's photo URL athttps://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 fromsessionStorageon 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'suser.photois empty; (2) the session-resumesetTimeoutatilearn-admin.js~line 22884 now calls_refreshSessionPhoto()with no arguments — that function already has the roster lookup built in, it writes the photo back tosessionStorage, and refreshes every surface that displays the user's photo (topbar avatar pair,#topbarUserPhotoslot, 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 thePORTAL_BUILDbump for the x-ilearn-build header. Zero schema changes._PORTAL_BUILDbumped 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.
- 👤 Topbar profile photo no longer resets to an initial on every login. Root cause: the login-success branch in
ilearn-admin.jsaround line 2313 unconditionally set#topbarAvatarImg.style.display='none'and populated#topbarAvatarInitialwith the first letter of the user's name, regardless of whethersessionUser.photoexisted. The User Management row rendered photos correctly (via therenderUsersoverride at line 17148) and the "Save Profile" modal flow handled photos correctly (viampSaveProfileat 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 thempSaveProfileconditional 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_messagesfor that period was either a_notifyrecord (booking requests, parent inquiries) or haduserId==='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_messagescontained 122 orphaned website-origin entries even thoughil_v482_events_migrated=1andil_v464_chat_cleaned=1were both set — those migrations marked themselves done but theil_system_eventsstore was empty. v5.40 adds_v540RepairChatStore(), a one-time idempotent cleanup gated bylocalStorage.il_v540_chat_store_repairthat runs 1.5s after chat init: scansil_chat_messages, moves every record matching_notify/userId==='website'/userName==='Website'/isSystem===true/userName.indexOf('🌐')!==-1intoil_system_events(dedup by id, capped at 1000), persists the trimmed chat store, and zeros outil_chat_unread_per_thread.allso the stale counter clears on next load. Also hardens the v5.24 filter (both_filterForActiveinrenderChatMessagesand the unread-bumper inappendChatMessages) 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.htmlconfirmBooking()at line 2968: a single meeting booking fired bothPOST ?action=public_append_lead(viapushLeadToServer(_lead)) ANDPOST ?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 inpublic_append_lead(ilearn-db.php~line 5731): when the incomingsourcefield is "Website Booking" (case-insensitive), skip the entire email-and-FCM block. The lead record is still written toil_leadsso 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/eventsPOST withisOnlineMeeting: trueand 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.htmlline 662: the mobile CSS block (@media (max-width: 768px)) had a selectorbutton, input[type="button"], input[type="submit"], [onclick], .clickable, .btn, .act, .cb, .ec-folderwithmin-width:44px !important; min-height:44px !important. The bare[onclick]term matches ANY element with an onclick handler — including the#srvDotspan in the sidebar header (a 10×10 px decorative server-status indicator next to "iLearn CRM" that hasonclick="testServerConnectionFull()"). On phone, the 44px touch-target minimum inflated that 10×10 span to 44×44, and because the inline style setborder-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 thediv[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. Thedata-no-touch-targetattribute 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 +#srvDotclamp + v5.24 filter hardening +_v540RepairChatStoreIIFE member.ilearn-db.php:public_append_leademail-skip guard.PORTAL_BUILDbumped 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, clearlocalStorage.il_v540_chat_store_repairon any client that ran the cleanup if you want it to re-run post-rollback.
- ⚙️ Teams link auto-generation on bookings — would require server-side M365 Graph
/me/eventsPOST withisOnlineMeeting: 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.
- 🔁 Reschedule modal — "booking not found" bug killed, plus system-wide
window.gdaudit. The v5.38 reschedule block looked up the booking viawindow.gd('bookings'), butgdis declared at the top ofilearn-admin.jsasconst gd = function(k){…}.constat the top level does not attach towindow— sowindow.gdwasundefined, 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'buttypeof window.gd === 'undefined'— onlygdhas this issue;sd,toast,openM,renderLeads,renderBookings,publishToServerare all declared asfunctionstatements so they stay onwindow. 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 showswindow.gdas 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
ecOpenMessagewas never fetching the full body at all. The list fetch at line 26429 only requestsbodyPreview(Graph caps that at ~100 chars), and the open handler then renderedmsg.body || msg.preview—msg.bodyis never populated, so every M365 email was displayed as its first 100 chars and then cut. v5.39 rewrites the M365 branch to fire aGET /v1.0/users/{email}/messages/{id}?$select=body,replyTo,toRecipients,hasAttachmentson open, render the fullbody.contentas HTML or pre-formatted text based onbody.contentType, and pull the attachments list via the separate/messages/{id}/attachmentsendpoint whenhasAttachmentsis 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.
ecOpenMessageline 27022 andecToggleReadline 27112 both calledecM365MarkRead(msgId, readState), but the function literally did not exist inilearn-admin.js. It threw a silentReferenceError(the call was inside an async path so it was swallowed),msg.seenwas only flipped locally, and the next folder refresh pulled the unread state back from Graph. v5.39 adds the helper using the exact samePATCH /messages/{id} {isRead:true}pattern thatecBulkMarkRead(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", butpublic_append_bookingin PHP validated with/^\d{2}:\d{2}$/which only accepts 24-hourHH:MM. Every 12-hour submission failed the regex,$timestayed empty, and the booking email showed "TBD" under Time even when the user had picked 1:00 PM. v5.39 widens the regex to acceptH:MM AM/HH:MM PM(optional space, optional periods) and converts to canonical 24-hour fortime. A newtimeLabelfield stores the original human label — mirrors howdateLabelalready works. The branded email template now preferstimeLabelovertime, 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.jsedits (PORTAL_BUILD, PORTAL_BUILD_DATE, ecM365MarkRead function added, ecOpenMessage M365 branch rewritten). Threeilearnhcc-admin-v2.htmledits (title/sidebar/readme version bumps, one div-removal on Calendar page, 8window.gd→gdreplacements inside v5.38 IIFE). Twoilearn-db.phpedits (time format parsing + timeLabel field, PORTAL_BUILD bump). Onesw.jsedit (CACHE_NAME bump). Zero schema changes (timeLabel is an additive field on new records, existing records without it are untouched). Zero changes toilearnhcc-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.pngreturns 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_mailto 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.
- 📧 Email body truncation — root-caused recursive MIME walker in PHP. The
email_bodyendpoint's MIME walker only iterated top-level multipart sections. Modern emails (forwards, system notifications, Gmail/M365 bodies) use nested structures likemultipart/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 newplain_legacyfield 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 resettingtr.style.display/col.style.displayback 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'sdatefield. 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 bothdate: _selIso(canonical YYYY-MM-DD) anddateLabel: _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
reschedHistoryarray (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
sendEmailAnypath +_styledBrandHTMLtemplate. 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 noeventIdexists (booking was confirmed without a calendar event), the branded email is the only notification.
- Part A — Correct date format on submit. The website was sending
- 🧪 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_bodyrecursive walker), one PHP record field added (dateLabel), one website JS edit (ISO date).PORTAL_BUILDbumped tov5.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.
- ✉️ 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 — firesecM365MarkRead) and IMAP. The IMAP path only setmsg.seen=truelocally 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, anemail_flagPOST withop:'read'fires to the backend so the seen state persists. Uses the same endpointecToggleReadhas 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()andsaveCalSettings()inilearn-admin.jsstill read#calDuration/#calBuffer/#calStart/#calEnd/#calMon-Fridirectly — 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
tbodyid 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_typesand 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; HTMLselectedattributes 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 toecOpenMessagefor item 1. Zero PHP changes. Zero schema changes. Zero changes toilearnhcc-website.html.PORTAL_BUILDbumped tov5.37.
- 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:380pxand 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 (addsbottom: calc(56px + safe-area)), usesflex-wrap:wrapso long action lists flow, and shrinks button padding to stay tappable. - 🔘 H2 — Modal footer buttons no longer escape the viewport. All
.mftfooters getflex-wrap:wrap; gap:8pxat ≤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, inlinegrid-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
+ Moretoggle 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.
- 🎯 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
- 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 stampstype="search"+inputmode="search"+autocomplete="off"on everyinput[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
.lblsingle-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-*-idattribute 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 toilearnhcc-website.html.PORTAL_BUILDbumped tov5.36. Rollback = delete the v5.36 block + revertPORTAL_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.
- 📱 Mobile table views — half of the tables were never getting the card layout. The v5.12 mobile-enhance shim only targeted
.table-wrap tablein 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 forcedmin-width: 820pxon phones for 18 months. v5.35 extends the shim to cover.tw tableas well — same card layout, same auto-generated column labels, same action-row footer. Also overrides inlinemin-widthandtable-layout:autostyles on the child<table>with!importantso the v5.35 CSS actually wins the cascade (without this override, horizontal scroll would still happen even withdisplay: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_notificationssystem — 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()atilearn-admin.js:1735-1752that predates the v4.62 notifications architecture. It bypassed the_isSystemMessageclassifier because it stamped messages withuserId:'system'+userName:'🌐 Website'(with an emoji prefix) — the classifier checks foruserId==='website'/userName==='Website'(no emoji). It also usedisSystem:trueinstead ofsystem:true. v5.35 wraps_checkWebsiteNotificationsby interceptingappendChatMessages()and_chatUnreadfor 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🌐 Websitesystem messages fromil_chat_messageson 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 (publishToServerpost-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-campaignsrecipients) is inside.twbut 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 toilearnhcc-website.html.PORTAL_BUILDbumped tov5.35for the x-ilearn-build header. Rollback = delete the v5.35 block + revertPORTAL_BUILD. Validation backlog now at 11 changesets; I'll say it one more time: the next session should be validation, not new builds.
- 🧹 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
_confirmationEmailSentguard 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_leadendpoint was append-only — it ignored any client-suppliedidand minted a fresh server-side id every call, so the second push created a duplicate instead of updating the first. Fix:public_append_leadnow upserts by client-supplied id when present (merging new fields likefileUrlonto the existing record, preserving admin-modified fields likestage,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 callingrenderLeads()which did a fullboard.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.scrollYand kanban columnscrollLeftare preserved across the rebuild viarequestAnimationFrame. 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
#topbarWelcomeon 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 toMore ≡. Result: no more overflow, user identity always visible. - 📌 Scope. One
<style id="v534-css">+<script id="v534-js">pair, an upsert-by-id patch topublic_append_leadinilearn-db.php, and the removal of the Availability Settings<div class="card">frompg-calendar.PORTAL_BUILDbumped tov5.34. Zero schema changes (theidfield 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.
- 🎯 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_settingsstore 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_leadsandil_bookingsso 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 includesbookingId + action + expiresAtsigned 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 newbooking_token_actendpoint inilearn-db.php, a 40-line addition toilearnhcc-website.html(push toil_bookings), a new email templatebooking_request_admin, and sidebar + settings-tab HTML edits.PORTAL_BUILDbumped tov5.33. Zero schema migrations —il_bookingsalready exists,il_cal_settingsunchanged. 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.
- 📦 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 subsequentrenderLeads()call — pagination, sort, stage change, drag-drop, auto-sync — re-rendered everything and silently washed the filter out. Fix: v5.32 wrapswindow.renderLeadsso 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#setWeatherLocexplicitly, callsswitchSettingsTab('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--°Cforever. 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)) hadoverflow: hiddenwith descendants inheritingoverflow-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 forcesoverflow-wrap: anywhere+word-break: break-wordon all reader-pane descendants, switches the pane tooverflow-y: auto, caps<img>/<table>/<video>atmax-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-cardinto#pg-settingsdynamically without settingdata-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 aMutationObserveron#pg-settingsthat stampsdata-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-linePORTAL_BUILDbump inilearn-db.php. Zero schema changes, zero changes toilearnhcc-website.html, zero PHP logic changes. All five fixes are additive on top of v5.31 — rollback = delete the v5.32 block + revertPORTAL_BUILDstring. v5.30 and v5.31 code untouched.
- 🔴 Red "New portal version available" banner — ROOT-CAUSED and fixed. The PHP
version_gte()regex inilearn-db.phpwas/^(\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 to0, and every publish got rejected asoutdated_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 includingv5.30.1 vs v4.22(was the broken case),v5.30.1 vs v5.30, andv10.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_BUILDon the PHP side bumped fromv5.22tov5.31. - 🏢 Supplier custom category — actually works now. v5.30 had a one-character logic bug: the modal-open hook checked
modal.style.display !== '', but whenopenM('mAddSupplier')opens the modal via CSS class,style.displaystays as'', so the hook never fired and thesupCatdropdown never got the + Add new supplier category… row. Fix: checkgetComputedStyle(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 usesclass="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.oninstead, and re-fires every v5.30 page-specific initializer on navigation. The audit filter was also broadened to scancategory/action/details/msg/typefields (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 PHPversion_gtepatch + 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.
- 🧩 Manage Widgets pill no longer overlaps the topbar "+ Contact" button. The legacy
#v441AddWidgetBtnwas absolute-positioned attop:12px / right:16pxinside#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: nowrapon 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 toilearn-admin.jsapart from the version string. Zero PHP / schema changes. Rollback = delete the block.
- 🏷️ 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/Lostso all 26 places inilearn-admin.jsthat filter bystage === 'Won'keep working unchanged and zero data migration was needed. The rename is a pure display-layer wrapper onrenderLeads(). 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
convertedToContactIdstamp and no matching contact. v5.30 runs a one-time sweep on first load per device (guarded bylocalStorage.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_categoriesand 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 leftoveril_supplier_categories/il_archive_period_days/il_v530_*keys are harmless.
- 📞 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
_callSignalproperty. A wrap aroundappendChatMessagesandrenderChatMessagesfilters 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.
- 🧩 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 sametoggleChat()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-allso 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: hiddento.contentat ≤ 768 px,max-width: 100%+box-sizing: border-boxon all.cardelements, andmin-width: 0on.card-bd/.cardso 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.
- 🍞 Toast centering fix on phone. The v5.22 mobile override set
left:12pxandright:12pxon toasts but forgot to reset the base rule'stransform: 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 soleft/rightdo 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.
- 🧯 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#catParentOptsall appear twice in the DOM (once inside the Inventory modal, once inside the Invoice modal). v5.26 usedgetElementById('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
getElementByIdlookup in the v5.26 invoice-categories block fordocument.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.
- 🎯 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-jsscript block, revert the_LEAD_STAGESarray + normalizer aliases inilearn-admin.js, and revert the Category<select>in the HTML. Invoice categories users added will still exist inil_invoice_categorieson 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.
- 🔑 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=loginendpoint. 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 usingopenssl_verify. Every login is verified server-side: origin inclientDataJSONmust 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_okhelper so abuse protection matches the rest of the portal. - 🧹 Rollback is a two-file delete. Remove the
require_onceline inilearn-db.php, deleteilearn-webauthn.php, delete the<script id="v525-webauthn-js">block, and remove the#lBioBtnbutton + Biometric Login Settings card. Thepasskeysfield left on user records is harmless — ignored by any other code. Password login keeps working throughout.
- 💬 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/toUserIdalready 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-bottompadding 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
renderChatMessagesandappendChatMessageswith 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#chatPanelDOM 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)andmax-height:calc(100vh - 120px)so it never pushes off a small laptop screen.
- 📱 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 2CSS + 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-themeis forced tolight; (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) aMutationObserverwatchdog snaps the attribute back tolightif any stray code flips it on phone.localStorage.il_themeis 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#mobileBottomNavremains 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.
- 📱 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 POLISHCSS +mobile-enhance-v522-jsscript) 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 supportwindow.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
.faband#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 ownbottomoverride withsafe-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.
- 🌓 CRITICAL FIX — "random dark mode" diagnosed and resolved. Root cause: the
il_themepreference was being synced server-side as if it were global data. When any device toggled dark mode, it publishedil_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 toautoLoadFromServer. - 🛡️ Three-layer fix so it can't come back. (1) Added
il_themeandil_ticker_speedto_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) HardenedsetTheme()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_themevalue 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 narrowdelete_keyPHP endpoint (strictly allowlisted toil_themeandil_ticker_speedonly — not a general-purpose data-wipe tool) and a one-time client migration that calls it on first v5.21 load per device. Guarded bylocalStorage.il_v521_cleanup_doneflag 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 returnskey_not_in_allowlistimmediately. Authentication required viaauth_session_or_key(same gate asmerge/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.
- ⏱ 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 inil_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 viacron_keyGET param (matched againstDB_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/modifiedtimestamp against the last-sync-push timestamp stored indrift_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
_styledBrandHTMLbranded 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_mailis 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_keyGET param must matchDB_SECRETexactly 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 inil_quickbooks_configwhich 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.
- 📦 QuickBooks Items Sync — bidirectional. New "📦 Sync Items" button in the QBO card. Preview modal reconciles portal
il_inventorywith 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: truemetadata. All mappings recorded inid_map.itemfor 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
itemsection. Joins the existinginvoice,customer, andpaymentsections 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 inil_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_syncrecords 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.
- 💰 QuickBooks Phase 3a — Payment pull is LIVE. QuickBooks payments now flow back into the portal as
parent_paymentsrecords 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) andqbo_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 viaid_map.customerreverse lookup; auto-links to the portal invoice viaid_map.invoicereverse lookup from the QBO Payment'sLinkedTxnarray. - 🖥️ 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.LastUpdatedTimeit saw as the sync cursor inil_quickbooks_config.last_sync.payments.last_cursor. Subsequent pulls filter the QBO query toMetaData.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 itsqbo_payment_id → portal_payment_idin a new section ofid_map. Re-pulling a payment you've already imported triggers an UPDATE (amount, date, notes refresh) not a duplicate. Local edits toreceiptSent,receiptNum, and the localcreatedtimestamp 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 everyTxnType=Invoiceentry, reverse-looks-up theTxnIdagainstid_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_invoicesso 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
_v518Badgedsentinel 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 onmanage_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).
- 📚 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'SyncTokenoptimistic 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_configwhich is now on the_sdNoPublishKeyslist (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.
- 📋 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 asendHistory[]array on each campaign record, and writes a permanentlogAuditentry.sendDraft()for social posts captures sender/platform/timestamp metadata and also writes tologAudit. 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) andmanage_quickbooks(📚 Manage QuickBooks integration + sync). Super Admin + Admin get both by default. Manager additionally getsview_comm_auditsince 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.
- 🧬 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
_leadEditIdwas 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 newopenAddLead()helper that resets every form field +_leadEditIdbefore 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 redundantpublishToServer(true)call that ran immediately afterpushKeyToServer('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 fourfindIndex(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 unwanteddata.push(lead). - 🛡️ Last-line double-submit defence.
saveLeadnow checks for id collision even on the "new lead" path — ifDate.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
saveLeadand all kanban/table stage-change paths — none of them touchil_contacts. Contact creation only happens via the 👤 "Convert to contact" button (which callsconvertLeadToContact), 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.
- 📎 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_mailmessage frompublic_append_lead— which uses basic PHPmail()and has no attachment support. Provider applications already had a branded M365 Graph API path viasendProviderApplicationEmailthat DID include the attachment. Parent inquiries now get symmetric treatment: newsendParentInquiryEmailfunction 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 afileAttachment(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),sendParentInquiryEmailreturns silently — no broken-looking failure, no user-visible error. The existing PHP_pub_internal_mailack 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.
- 🔔 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 deviceswith 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_tokenon 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, recordsregistered_at/last_usedtimestamps, and auto-prunes any token that hasn't been seen in 60 days. No APK rebuild is required to enable push — just addgoogle-services.jsonto 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_sdNoPublishKeysper 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 ifil_fcm_configisn'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.
- 📱 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 tableinto 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-wrapgets 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.
- 📅 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
teamsJoinUrlpersisted. 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
onCompletecallback intocreateM365CalendarEventand_createM365MeetingEvent. When Graph returns successfully withonlineMeeting.joinUrl, the callback fires_sendBookingConfirmationEmailor_sendMeetingConfirmationEmailwith 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:trueflag 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 (noonCompleteever 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.
- 💾 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: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., toast:true}) - ⚡ 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,countryall persist). "Window disappears" is normal modal-close-on-save behaviour. The modal has two lat/lng field pairs (cf_lat/cf_lngmain +cf12/cf13provider-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)
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.<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.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).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.#unsavedIndicator kept hidden for backward compat._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..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).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.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.DB_KEYS, _tombstonedKeys, _injectBulkBars, nav() map, titles map, website-poll sectionMap); PHP additions to SECTION_SCHEMA + $protectedArrayKeys._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._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 🌐).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.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.il_template_overrides storage + central resolveTemplate lookup) + Live vs Preview banner + "⚡ (using custom)" selector badges.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.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)._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 👤).il_widget_layout_*); drag/resize no longer permanently freeze all 14 widgets; new Reset Dashboard Layout button in Settings.🔐 Roles & Responsibilities
v4.70 · NEW🏢 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).
🔐 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.
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.
🌐 Server Connection
⬤ Not configuredConnect 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 supportedLoading — open Settings to see your cron URL
Recommended schedule: daily at 2:00 AMSaves 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.
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.
🗄️ Local Database — cPanel File
Save all admin data as a single JSON file for your web hostingEvery 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
📧 Email Delivery Settings
Choose which provider sends all portal emailsWhen 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 configuredConnect 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.
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
✓ 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.
📅 Google Calendar Integration
🔷 Microsoft 365 Integration
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.
✓ Welcome emails to new contacts
✓ Welcome emails to new subscribers
✓ Invoice & payment receipt emails
✓ T4 slip delivery to providers
✓ Task assignment & reminder alerts
✓ Email Client inbox (Microsoft 365 mailbox)
✓ Email Client compose & send
✓ Email Client reply & forward
✓ Calendar events in Outlook Calendar
✓ Auto-dialer email notifications
🤖 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.