Skip to main content
LIVE It Trackers
📲 Connect Your Health Apps
Sync real data from Apple Health, Google Fit, Garmin, Fitbit, Whoop & more
Steps This Week
Sleep This Week
Heart Rate Today
62
Resting
74
Avg
142
Peak
Recovery & Readiness
Nutrition Today
1,840 / 2,200 kcal
Macronutrients
Daily Wellness Log
How are you feeling?
Water intake
8 / 8
Mindfulness
12 min
✓ Goal met (10 min)
`; const printWindow = window.open('', '_blank'); if (printWindow) { printWindow.document.write(html); printWindow.document.close(); toast('📄 PDF report opened — use Print > Save as PDF', '#16a34a'); } else { toast('Please allow popups to export PDF', '#dc2626'); } logExportAudit(type, 'pdf', rows.length); } // Parse comma-separated ingredient string into array function parseIngredients(str) { if(!str) return []; return str.split(/[,;]\s*/).map(s => s.trim()).filter(s => s.length > 0); } // Tab switcher function setWellTab(id, btn) { document.querySelectorAll('#tp-wellness .wp').forEach(p => { p.classList.remove('active-wp'); p.style.display='none'; }); document.querySelectorAll('#well-subnav .well-tb').forEach(b => b.classList.remove('active')); const panel = document.getElementById('wp-' + id); if(panel){ panel.classList.add('active-wp'); panel.style.display='block'; } if(btn) btn.classList.add('active'); // Defer chart inits so canvas has non-zero dimensions setTimeout(() => { if(id === 'workouts') { loadExercisesFromAPI(); } if(id === 'weight') initWeightTracker(); if(id === 'bmi') { initBMICalc(); renderBMIHistory(); } if(id === 'pain') initPainTracker(); if(id === 'screentime') initScreenTime(); if(id === 'treatment') initTreatmentTracker(); if(id === 'challenges') initChallenges(); if(id === 'nutrition') initNutrition(); if(id === 'reactions') initReactionTracker(); if(id === 'serveit') initServeItTracker(); }, 30); } // ══════════════════════════════════════════════════════════════════════════════ // ALL TRACKER JAVASCRIPT // ══════════════════════════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════════════════════════ // MYBEAUTY.HEALTH SAFETY ENGINE v1.0 // Ingredient Taxonomy + Family Matching + Risk Flags + Alternatives // ══════════════════════════════════════════════════════════════════════════════ // ── INGREDIENT FAMILY TAXONOMY ─────────────────────────────────────────────── // Each family has: name, risk_flag (null, PHOTOSENSITIVE, ALLERGEN, IRRITANT, // COMEDOGENIC, ENDOCRINE), description, and common ingredient names (lowercase) const INGREDIENT_FAMILIES = { AHA: { name: 'Alpha Hydroxy Acids (AHA)', risk_flag: 'PHOTOSENSITIVE', desc: 'Chemical exfoliants that increase sun sensitivity. Avoid high UV exposure.', members: ['glycolic acid','lactic acid','mandelic acid','tartaric acid','malic acid','citric acid', 'alpha-hydroxy acid','alpha hydroxy acid','hydroxyacetic acid','2-hydroxyoctanoic acid', 'citrus limon','citrus aurantium dulcis'] }, BHA: { name: 'Beta Hydroxy Acids (BHA)', risk_flag: 'PHOTOSENSITIVE', desc: 'Oil-soluble exfoliants. Increase sun sensitivity. Moisturize after use.', members: ['salicylic acid','betaine salicylate','beta hydroxy acid','beta-hydroxy acid', 'salix alba','willow bark extract','tropic acid'] }, RETINOID: { name: 'Retinoids (Vitamin A)', risk_flag: 'PHOTOSENSITIVE', desc: 'Powerful anti-aging. Highly photosensitive. Always pair with SPF.', members: ['retinol','retinal','retinaldehyde','tretinoin','adapalene','tazarotene', 'retinyl palmitate','retinyl acetate','retinyl linoleate','retinoic acid', 'hydroxypinacolone retinoate','granactive retinoid','bakuchiol'] }, VITAMIN_C: { name: 'Vitamin C (Ascorbic Acid Family)', risk_flag: 'PHOTOSENSITIVE', desc: 'Antioxidant that can oxidize in sunlight. Use with SPF for best results.', members: ['ascorbic acid','l-ascorbic acid','ascorbyl glucoside','sodium ascorbyl phosphate', 'ascorbyl palmitate','ascorbyl tetraisopalmitate','ethyl ascorbic acid', 'magnesium ascorbyl phosphate','3-o-ethyl ascorbic acid','tetrahexyldecyl ascorbate'] }, BENZOYL_PEROXIDE: { name: 'Benzoyl Peroxide', risk_flag: 'PHOTOSENSITIVE', desc: 'Acne treatment. Increases sun sensitivity and can bleach fabrics.', members: ['benzoyl peroxide','dibenzoyl peroxide'] }, FRAGRANCE: { name: 'Fragrances and Parfum', risk_flag: 'ALLERGEN', desc: 'Top cause of cosmetic contact dermatitis. Over 3,000 chemicals can hide under "fragrance."', members: ['fragrance','parfum','perfume','aroma','linalool','limonene','citronellol', 'geraniol','eugenol','coumarin','benzyl alcohol','benzyl benzoate','benzyl salicylate', 'cinnamal','cinnamyl alcohol','hydroxycitronellal','isoeugenol','amyl cinnamal', 'anise alcohol','farnesol','hexyl cinnamal','lilial','butylphenyl methylpropional', 'alpha-isomethyl ionone','musk','oakmoss','treemoss'] }, FORMALDEHYDE: { name: 'Formaldehyde Releasers', risk_flag: 'ALLERGEN', desc: 'Known allergen and suspected carcinogen. Releases formaldehyde over time.', members: ['formaldehyde','formalin','quaternium-15','dmdm hydantoin', 'imidazolidinyl urea','diazolidinyl urea','sodium hydroxymethylglycinate', 'bronopol','2-bromo-2-nitropropane-1,3-diol','methenamine','5-bromo-5-nitro-1,3-dioxane'] }, PRESERVATIVE_MI: { name: 'Isothiazolinone Preservatives', risk_flag: 'ALLERGEN', desc: 'Strong contact allergen. Banned in EU leave-on products above certain concentrations.', members: ['methylisothiazolinone','methylchloroisothiazolinone', 'benzisothiazolinone','octylisothiazolinone','mi','mci'] }, PARABEN: { name: 'Parabens', risk_flag: 'ENDOCRINE', desc: 'Preservatives with weak estrogen-mimicking activity. Widely debated safety profile.', members: ['methylparaben','ethylparaben','propylparaben','butylparaben','isobutylparaben', 'isopropylparaben','benzylparaben','paraben','parabens','4-hydroxybenzoic acid'] }, SULFATE: { name: 'Sulfates (Harsh Surfactants)', risk_flag: 'IRRITANT', desc: 'Strong cleansing agents that strip natural oils. Can irritate sensitive skin.', members: ['sodium lauryl sulfate','sodium laureth sulfate','sls','sles', 'ammonium lauryl sulfate','ammonium laureth sulfate','sodium coco sulfate', 'sodium myreth sulfate','tea-lauryl sulfate','sodium c14-16 olefin sulfonate'] }, CHEMICAL_SUNSCREEN: { name: 'Chemical UV Filters', risk_flag: 'ENDOCRINE', desc: 'Absorb UV but may disrupt hormones. Some are coral reef toxic.', members: ['oxybenzone','benzophenone-3','benzophenone','octinoxate','ethylhexyl methoxycinnamate', 'octisalate','ethylhexyl salicylate','homosalate','avobenzone','butyl methoxydibenzoylmethane', 'octocrylene','ensulizole','phenylbenzimidazole sulfonic acid','padimate o', 'ethylhexyl dimethyl paba','meradimate','menthyl anthranilate','sulisobenzone', 'benzophenone-4','4-methylbenzylidene camphor'] }, MINERAL_SUNSCREEN: { name: 'Mineral UV Filters', risk_flag: null, desc: 'Physical blockers. Generally considered safe for sensitive skin.', members: ['zinc oxide','titanium dioxide'] }, PHTHALATE: { name: 'Phthalates', risk_flag: 'ENDOCRINE', desc: 'Plasticizers linked to hormone disruption. Often hidden in "fragrance."', members: ['diethyl phthalate','dibutyl phthalate','dimethyl phthalate','dep','dbp', 'phthalate','phthalates','benzyl butyl phthalate','di-2-ethylhexyl phthalate'] }, SILICONE: { name: 'Silicones', risk_flag: null, desc: 'Create a smooth barrier. Non-toxic but can trap debris if not cleansed well.', members: ['dimethicone','cyclomethicone','cyclopentasiloxane','cyclohexasiloxane', 'dimethiconol','phenyl trimethicone','amodimethicone','trimethylsiloxysilicate', 'cetyl dimethicone','stearyl dimethicone','vinyl dimethicone'] }, NIACINAMIDE: { name: 'Niacinamide (Vitamin B3)', risk_flag: null, desc: 'Generally well-tolerated. Brightens, reduces pores, strengthens barrier.', members: ['niacinamide','nicotinamide','vitamin b3','niacin'] }, CERAMIDE: { name: 'Ceramides', risk_flag: null, desc: 'Skin-identical lipids that repair and strengthen the moisture barrier.', members: ['ceramide np','ceramide ap','ceramide eop','ceramide ns','ceramide as', 'ceramide ng','phytosphingosine','sphingolipids','cholesterol'] }, HYALURONIC: { name: 'Hyaluronic Acid Family', risk_flag: null, desc: 'Humectant that holds 1000x its weight in water. Great for hydration.', members: ['hyaluronic acid','sodium hyaluronate','hydrolyzed hyaluronic acid', 'sodium acetylated hyaluronate','hyaluronan','potassium hyaluronate'] }, PEPTIDE: { name: 'Peptides', risk_flag: null, desc: 'Signal skin to produce more collagen. Generally safe and effective.', members: ['palmitoyl tripeptide-1','palmitoyl tetrapeptide-7','palmitoyl pentapeptide-4', 'matrixyl','acetyl hexapeptide-3','argireline','copper peptide','ghk-cu', 'palmitoyl tripeptide-38','sh-oligopeptide-1','dipeptide diaminobutyroyl benzylamide diacetate'] }, ESSENTIAL_OIL: { name: 'Essential Oils', risk_flag: 'ALLERGEN', desc: 'Concentrated plant extracts. Common sensitizers, especially citrus oils.', members: ['tea tree oil','melaleuca alternifolia','lavender oil','lavandula angustifolia', 'peppermint oil','mentha piperita','eucalyptus oil','eucalyptus globulus', 'rosemary oil','rosmarinus officinalis','lemon oil','citrus limon oil', 'orange oil','citrus sinensis','bergamot oil','citrus bergamia', 'ylang ylang','cananga odorata','clove oil','cinnamon oil', 'thyme oil','oregano oil'] }, ALCOHOL_DRYING: { name: 'Drying Alcohols', risk_flag: 'IRRITANT', desc: 'Evaporate quickly. Can dry and irritate skin, especially in high concentrations.', members: ['alcohol denat','sd alcohol','alcohol','isopropyl alcohol','ethanol', 'denatured alcohol','sd alcohol 40-b','sd alcohol 40'] }, ALCOHOL_FATTY: { name: 'Fatty Alcohols', risk_flag: null, desc: 'Emollients and thickeners. NOT the same as drying alcohols. Generally beneficial.', members: ['cetyl alcohol','cetearyl alcohol','stearyl alcohol','behenyl alcohol', 'myristyl alcohol','lauryl alcohol','oleyl alcohol'] }, PPD: { name: 'Hair Dye Chemicals (PPD)', risk_flag: 'ALLERGEN', desc: 'Strong sensitizer in permanent hair dyes. Can cause severe allergic reactions.', members: ['p-phenylenediamine','ppd','p-aminophenol','toluene-2,5-diamine', 'resorcinol','1,3-benzenediol','m-aminophenol','4-aminophenol'] }, COAL_TAR: { name: 'Coal Tar Derivatives', risk_flag: 'PHOTOSENSITIVE', desc: 'Used in dandruff shampoos. Photosensitizer. Classified as possible carcinogen.', members: ['coal tar','coal tar solution','crude coal tar','lcd','liquor carbonis detergens'] }, HYDROQUINONE: { name: 'Hydroquinone (Skin Lightener)', risk_flag: 'PHOTOSENSITIVE', desc: 'Skin bleaching agent. Must use with SPF. Prescription-only in many countries.', members: ['hydroquinone','arbutin','alpha arbutin','beta arbutin','deoxyarbutin'] }, COCAMIDOPROPYL: { name: 'Cocamidopropyl Betaine', risk_flag: 'ALLERGEN', desc: 'Common surfactant. Named ACDS Allergen of the Year 2004. Can cause dermatitis.', members: ['cocamidopropyl betaine','capb','coco betaine','cocamidopropyl hydroxysultaine'] }, LANOLIN: { name: 'Lanolin', risk_flag: 'ALLERGEN', desc: 'Sheep wool wax. Excellent emollient but a known contact allergen for some.', members: ['lanolin','lanolin alcohol','lanolin oil','laneth-5','acetylated lanolin', 'lanolin acid','peg-75 lanolin','hydroxylated lanolin'] }, PROPYLENE_GLYCOL: { name: 'Propylene Glycol', risk_flag: 'IRRITANT', desc: 'Humectant and penetration enhancer. Can irritate sensitive or eczema-prone skin.', members: ['propylene glycol','butylene glycol','pentylene glycol', 'hexylene glycol','caprylyl glycol','dipropylene glycol'] }, NICKEL: { name: 'Nickel (in cosmetics)', risk_flag: 'ALLERGEN', desc: 'Most common metal allergen. Can be a contaminant in cosmetics.', members: ['nickel','nickel sulfate','ci 77520'] }, TOCOPHEROL: { name: 'Vitamin E (Tocopherol)', risk_flag: null, desc: 'Antioxidant. Occasionally causes contact dermatitis in sensitive individuals.', members: ['tocopherol','tocopheryl acetate','dl-alpha tocopherol','tocotrienol', 'mixed tocopherols','vitamin e'] }, ALOE: { name: 'Aloe Vera', risk_flag: null, desc: 'Soothing and hydrating. Very rarely causes allergic reactions.', members: ['aloe barbadensis leaf juice','aloe vera','aloe barbadensis','aloe extract', 'aloe barbadensis leaf extract'] }, ZINC_COMPOUND: { name: 'Zinc Compounds', risk_flag: null, desc: 'Anti-inflammatory, antimicrobial. Used in dandruff shampoos and diaper cream.', members: ['zinc pyrithione','zinc pca','zinc gluconate','zinc sulfate','calamine'] } }; // Build fast lookup: ingredient name (lowercase) -> family key const _ingredientToFamily = {}; Object.entries(INGREDIENT_FAMILIES).forEach(([key, fam]) => { fam.members.forEach(m => { _ingredientToFamily[m] = key; }); }); // ── ALTERNATIVE PRODUCT SUGGESTIONS ────────────────────────────────────────── // When a RED/YELLOW flag fires, suggest swap-outs by category + family const PRODUCT_ALTERNATIVES = { FRAGRANCE: [ { name:'Vanicream Gentle Facial Cleanser', brand:'Vanicream', why:'Fragrance-free, dermatologist recommended' }, { name:'CeraVe Hydrating Cleanser', brand:'CeraVe', why:'No fragrance, ceramide-rich formula' }, { name:'La Roche-Posay Toleriane', brand:'La Roche-Posay', why:'Formulated for ultra-sensitive skin' }, ], SULFATE: [ { name:'SheaMoisture Coconut & Hibiscus Shampoo', brand:'SheaMoisture', why:'Sulfate-free, gentle cleansing' }, { name:'Vanicream Free & Clear Shampoo', brand:'Vanicream', why:'No SLS/SLES, hypoallergenic' }, { name:'Native Sensitive Body Wash', brand:'Native', why:'Sulfate-free, no harsh detergents' }, ], PARABEN: [ { name:'Cetaphil Gentle Skin Cleanser', brand:'Cetaphil', why:'Paraben-free, pH balanced' }, { name:'Burt\u2019s Bees Sensitive Moisturizer', brand:'Burt\u2019s Bees', why:'99% natural, no parabens' }, { name:'Aveeno Daily Moisturizing Lotion', brand:'Aveeno', why:'Paraben-free, colloidal oatmeal' }, ], RETINOID: [ { name:'The Ordinary Bakuchiol', brand:'The Ordinary', why:'Plant-based retinol alternative, no photosensitivity' }, { name:'Herbivore Bakuchiol Serum', brand:'Herbivore', why:'Gentle, vegan retinol alternative' }, ], CHEMICAL_SUNSCREEN: [ { name:'EltaMD UV Clear SPF 46', brand:'EltaMD', why:'Zinc oxide only, no chemical filters' }, { name:'Blue Lizard Sensitive SPF 50+', brand:'Blue Lizard', why:'100% mineral, reef-safe' }, { name:'CeraVe Hydrating Mineral Sunscreen', brand:'CeraVe', why:'Zinc + titanium dioxide, fragrance-free' }, ], AHA: [ { name:'CeraVe SA Cleanser', brand:'CeraVe', why:'Uses BHA instead of AHA, gentler option' }, { name:'PHA toners (Neostrata)', brand:'Neostrata', why:'PHAs are gentler than AHAs, less photosensitizing' }, ], BHA: [ { name:'Azelaic acid 10% (The Ordinary)', brand:'The Ordinary', why:'Similar pore benefits, less irritating' }, ], FORMALDEHYDE: [ { name:'Phenoxyethanol-preserved alternatives', brand:'Various', why:'Modern preservative system without formaldehyde releasers' }, ], PRESERVATIVE_MI: [ { name:'Vanicream Moisturizing Cream', brand:'Vanicream', why:'Free of MI/MCI preservatives' }, ], ESSENTIAL_OIL: [ { name:'Neutrogena Hydro Boost (Fragrance-Free)', brand:'Neutrogena', why:'No essential oils, pure hydration' }, { name:'CeraVe PM Moisturizer', brand:'CeraVe', why:'No essential oils, niacinamide-based' }, ], ALCOHOL_DRYING: [ { name:'First Aid Beauty Ultra Repair Cream', brand:'First Aid Beauty', why:'Alcohol-free, colloidal oatmeal' }, ], COCAMIDOPROPYL: [ { name:'Vanicream Free & Clear Liquid Cleanser', brand:'Vanicream', why:'No cocamidopropyl betaine' }, ], PPD: [ { name:'Madison Reed Hair Color', brand:'Madison Reed', why:'PPD-free permanent hair color' }, { name:'Naturtint Permanent Hair Color', brand:'Naturtint', why:'Plant-based, no PPD' }, ], PHTHALATE: [ { name:'Pacifica body care line', brand:'Pacifica', why:'Phthalate-free, vegan formulas' }, ], LANOLIN: [ { name:'Aquaphor Healing Ointment', brand:'Aquaphor', why:'Lanolin-free barrier repair' }, { name:'CeraVe Healing Ointment', brand:'CeraVe', why:'Petrolatum-based, no lanolin' }, ], }; // ── PERSISTENT STORAGE (IndexedDB) ─────────────────────────────────────────── const MBHDB_NAME = 'MyBeautyHealthDB'; const MBHDB_VERSION = 1; let _mbhDB = null; function openMBHDatabase() { return new Promise((resolve, reject) => { if(_mbhDB) { resolve(_mbhDB); return; } const req = indexedDB.open(MBHDB_NAME, MBHDB_VERSION); req.onupgradeneeded = (e) => { const db = e.target.result; // Store all user products (baseline + reaction) if(!db.objectStoreNames.contains('products')) { const ps = db.createObjectStore('products', { keyPath:'id' }); ps.createIndex('type','type',{unique:false}); ps.createIndex('name','name',{unique:false}); } // Store user profile (allergies, skin type, etc.) if(!db.objectStoreNames.contains('profile')) { db.createObjectStore('profile', { keyPath:'key' }); } // Store product experiences (ratings, reactions over time) if(!db.objectStoreNames.contains('experiences')) { const ex = db.createObjectStore('experiences', { keyPath:'id', autoIncrement:true }); ex.createIndex('productId','productId',{unique:false}); ex.createIndex('date','date',{unique:false}); } }; req.onsuccess = (e) => { _mbhDB = e.target.result; resolve(_mbhDB); }; req.onerror = (e) => { console.warn('IndexedDB error:', e); reject(e); }; }); } async function mbhSaveProduct(product) { const db = await openMBHDatabase(); return new Promise((resolve, reject) => { const tx = db.transaction('products','readwrite'); tx.objectStore('products').put(product); tx.oncomplete = () => resolve(); tx.onerror = (e) => reject(e); }); } async function mbhGetAllProducts(type) { const db = await openMBHDatabase(); return new Promise((resolve, reject) => { const tx = db.transaction('products','readonly'); const store = tx.objectStore('products'); if(type) { const idx = store.index('type'); const req = idx.getAll(type); req.onsuccess = () => resolve(req.result || []); req.onerror = (e) => reject(e); } else { const req = store.getAll(); req.onsuccess = () => resolve(req.result || []); req.onerror = (e) => reject(e); } }); } async function mbhDeleteProduct(id) { const db = await openMBHDatabase(); return new Promise((resolve, reject) => { const tx = db.transaction('products','readwrite'); tx.objectStore('products').delete(id); tx.oncomplete = () => resolve(); tx.onerror = (e) => reject(e); }); } async function mbhSaveProfile(profileData) { const db = await openMBHDatabase(); return new Promise((resolve, reject) => { const tx = db.transaction('profile','readwrite'); tx.objectStore('profile').put({ key:'user_profile', ...profileData }); tx.oncomplete = () => resolve(); tx.onerror = (e) => reject(e); }); } async function mbhGetProfile() { const db = await openMBHDatabase(); return new Promise((resolve, reject) => { const tx = db.transaction('profile','readonly'); const req = tx.objectStore('profile').get('user_profile'); req.onsuccess = () => resolve(req.result || null); req.onerror = (e) => reject(e); }); } async function mbhSaveExperience(exp) { const db = await openMBHDatabase(); return new Promise((resolve, reject) => { const tx = db.transaction('experiences','readwrite'); tx.objectStore('experiences').put(exp); tx.oncomplete = () => resolve(); tx.onerror = (e) => reject(e); }); } async function mbhGetExperiences(productId) { const db = await openMBHDatabase(); return new Promise((resolve, reject) => { const tx = db.transaction('experiences','readonly'); const idx = tx.objectStore('experiences').index('productId'); const req = productId ? idx.getAll(productId) : tx.objectStore('experiences').getAll(); req.onsuccess = () => resolve(req.result || []); req.onerror = (e) => reject(e); }); } // ── ENHANCED SAFETY ENGINE ─────────────────────────────────────────────────── // Replaces the basic rxSafetyCheck with family-aware, context-aware analysis function analyzeSafety(ingredients, userReactionIngredients, uvIndex) { const results = { level:'GREEN', color:'#16a34a', bg:'#f0fdf4', icon:'\u2705', label:'LOOKS SAFE', detail:'', flags:[], alternatives:[], familyHits:[] }; const ingLower = ingredients.map(i => i.toLowerCase().trim()).filter(Boolean); const userWatchLower = new Set([...(userReactionIngredients||[])].map(i => i.toLowerCase().trim())); // Phase 1: Direct ingredient match against user reaction history (RED CARD) const directHits = []; ingLower.forEach(ing => { if(userWatchLower.has(ing)) { directHits.push(ing); } }); // Phase 2: Family match - if user reacted to ingredient X, and new product has // ingredient Y from the SAME family, that is a Yellow/Red flag const userFamilies = new Set(); userWatchLower.forEach(ing => { const fam = _ingredientToFamily[ing]; if(fam) userFamilies.add(fam); }); const familyCrossMatches = []; ingLower.forEach(ing => { const fam = _ingredientToFamily[ing]; if(fam && userFamilies.has(fam) && !directHits.includes(ing)) { familyCrossMatches.push({ ingredient:ing, family:fam, familyName:INGREDIENT_FAMILIES[fam].name }); } }); // Phase 3: Risk flag check (photosensitive ingredients + UV context) const photoFlags = []; const allergenFlags = []; const irritantFlags = []; const endocrineFlags = []; ingLower.forEach(ing => { const fam = _ingredientToFamily[ing]; if(!fam) return; const family = INGREDIENT_FAMILIES[fam]; if(family.risk_flag === 'PHOTOSENSITIVE') photoFlags.push({ ingredient:ing, family:fam }); if(family.risk_flag === 'ALLERGEN') allergenFlags.push({ ingredient:ing, family:fam }); if(family.risk_flag === 'IRRITANT') irritantFlags.push({ ingredient:ing, family:fam }); if(family.risk_flag === 'ENDOCRINE') endocrineFlags.push({ ingredient:ing, family:fam }); }); // ── BUILD RESULT ── const allAlts = new Set(); // RED: Direct ingredient match from personal reaction history if(directHits.length > 0) { results.level = 'RED'; results.color = '#ef4444'; results.bg = '#fee2e2'; results.icon = '\uD83D\uDEA8'; results.label = 'DO NOT BUY'; results.detail = `Contains ${directHits.length} ingredient(s) you have reacted to: ${directHits.slice(0,4).join(', ')}${directHits.length>4?'\u2026':''}`; results.flags.push({ type:'RED', reason:'Personal reaction history match', items:directHits }); directHits.forEach(h => { const f = _ingredientToFamily[h]; if(f) allAlts.add(f); }); } // RED: Family cross-match (same chemical family as a reaction ingredient) if(familyCrossMatches.length > 0 && results.level !== 'RED') { results.level = 'RED'; results.color = '#ef4444'; results.bg = '#fee2e2'; results.icon = '\uD83D\uDEA8'; results.label = 'HIGH RISK'; results.detail = `Contains ingredient(s) from the same family as your reaction triggers: ${familyCrossMatches.slice(0,3).map(m=>m.ingredient+' ('+m.familyName+')').join('; ')}`; results.flags.push({ type:'RED', reason:'Same-family match', items:familyCrossMatches.map(m=>m.ingredient) }); } else if(familyCrossMatches.length > 0) { // Already RED from direct hit, add as additional flag results.flags.push({ type:'RED', reason:'Same-family match', items:familyCrossMatches.map(m=>m.ingredient) }); } familyCrossMatches.forEach(m => allAlts.add(m.family)); // YELLOW: Photosensitive + high UV const uv = uvIndex || 0; if(photoFlags.length > 0 && uv >= 5 && results.level !== 'RED') { results.level = 'YELLOW'; results.color = '#b45309'; results.bg = '#fef9c3'; results.icon = '\u26A0\uFE0F'; results.label = 'UV CAUTION'; results.detail = `Contains ${photoFlags.length} photosensitive ingredient(s) and UV is ${uv.toFixed(1)} (${uv>=7?'High':'Moderate'}). Apply SPF 50+ or use at night.`; results.flags.push({ type:'YELLOW', reason:'Photosensitive + High UV', items:photoFlags.map(p=>p.ingredient) }); photoFlags.forEach(p => allAlts.add(p.family)); } else if(photoFlags.length > 0) { results.flags.push({ type:'INFO', reason:'Contains photosensitive ingredients (pair with SPF)', items:photoFlags.map(p=>p.ingredient) }); } // YELLOW: Known allergen families (even without personal history) if(allergenFlags.length > 0 && results.level === 'GREEN') { results.level = 'YELLOW'; results.color = '#b45309'; results.bg = '#fef9c3'; results.icon = '\u26A0\uFE0F'; results.label = 'USE CAUTION'; results.detail = `Contains ${allergenFlags.length} common allergen(s): ${allergenFlags.slice(0,3).map(a=>a.ingredient).join(', ')}. Patch test recommended.`; results.flags.push({ type:'YELLOW', reason:'Common allergen', items:allergenFlags.map(a=>a.ingredient) }); allergenFlags.forEach(a => allAlts.add(a.family)); } // INFO: Irritants if(irritantFlags.length > 0) { results.flags.push({ type:'INFO', reason:'Contains potential irritants', items:irritantFlags.map(i=>i.ingredient) }); if(results.level === 'GREEN') { results.detail = `Contains ${irritantFlags.length} potential irritant(s). Generally safe but may cause sensitivity.`; } } // INFO: Endocrine disruptors if(endocrineFlags.length > 0) { results.flags.push({ type:'INFO', reason:'Contains ingredient(s) with endocrine activity concerns', items:endocrineFlags.map(e=>e.ingredient) }); } // If still green if(results.level === 'GREEN') { results.detail = results.detail || 'No flagged ingredients. This product appears safe for your profile.'; } // Collect alternatives allAlts.forEach(famKey => { if(PRODUCT_ALTERNATIVES[famKey]) { results.alternatives.push(...PRODUCT_ALTERNATIVES[famKey].map(a => ({ ...a, forFamily:INGREDIENT_FAMILIES[famKey].name }))); } }); // Collect family hits for UI const famSeen = new Set(); ingLower.forEach(ing => { const fam = _ingredientToFamily[ing]; if(fam && !famSeen.has(fam)) { famSeen.add(fam); results.familyHits.push({ key:fam, ...INGREDIENT_FAMILIES[fam], matchedIngredient:ing }); } }); return results; } // ── SAFETY CARD UI RENDERER ────────────────────────────────────────────────── function renderSafetyCard(analysis) { if(!analysis) return ''; const flagBadges = analysis.flags.map(f => { const c = f.type==='RED' ? '#ef4444' : f.type==='YELLOW' ? '#b45309' : '#6b7a99'; const bg = f.type==='RED' ? '#fee2e2' : f.type==='YELLOW' ? '#fef9c3' : '#f1f5f9'; return `
${f.type==='RED'?'\uD83D\uDEA8':f.type==='YELLOW'?'\u26A0\uFE0F':'\u2139\uFE0F'} ${f.reason}: ${f.items.slice(0,3).join(', ')}${f.items.length>3?'\u2026':''}
`; }).join(''); const familyTags = analysis.familyHits.slice(0,6).map(fh => { const c = fh.risk_flag === 'PHOTOSENSITIVE' ? '#f59e0b' : fh.risk_flag === 'ALLERGEN' ? '#ef4444' : fh.risk_flag === 'IRRITANT' ? '#f97316' : fh.risk_flag === 'ENDOCRINE' ? '#8b5cf6' : '#22c55e'; return `${fh.name}${fh.risk_flag?' \u2022 '+fh.risk_flag:''}`; }).join(''); const altCards = analysis.alternatives.length > 0 ? `
\uD83D\uDD04 Safer Alternatives
${analysis.alternatives.slice(0,3).map(a => `
\u2713 ${a.name} (${a.brand})
${a.why}
`).join('')}
` : ''; return `
${analysis.icon}
${analysis.label}
${analysis.detail}
${flagBadges ? '
'+flagBadges+'
' : ''} ${familyTags ? '
'+familyTags+'
' : ''} ${altCards}
`; } // ── INITIALIZATION ─────────────────────────────────────────────────────────── async function initSafetyEngine() { try { await openMBHDatabase(); console.log('[MyBeauty.Health] Safety Engine v1.0 loaded. ' + Object.keys(INGREDIENT_FAMILIES).length + ' families, ' + Object.keys(_ingredientToFamily).length + ' ingredients mapped.'); } catch(e) { console.warn('[MyBeauty.Health] IndexedDB unavailable, using in-memory storage only.', e); } } // ══════════════════════════════════════════════════════════════════════════════ // REACTION TRACKER // ══════════════════════════════════════════════════════════════════════════════ // Known irritant/allergen flags for quick highlighting const RX_KNOWN_IRRITANTS = [ 'fragrance','parfum','alcohol denat','methylisothiazolinone','methylchloroisothiazolinone', 'formaldehyde','parabens','methylparaben','propylparaben','butylparaben','ethylparaben', 'sodium lauryl sulfate','sodium laureth sulfate','cocamidopropyl betaine', 'benzophenone','oxybenzone','octinoxate','retinyl palmitate','bha','bht', 'phthalates','dibutyl phthalate','triclosan','toluene','chemical sunscreen', 'p-phenylenediamine','resorcinol','ammonia','persulfates','coal tar' ]; const UV_LEVELS = [ { max:2, label:'Low', color:'#22c55e', icon:'🌤️', advice:'Minimal protection needed.', spf:'' }, { max:5, label:'Moderate', color:'#f59e0b', icon:'⛅', advice:'Wear SPF 30+ if outside >30 min.', spf:'SPF 30+ recommended' }, { max:7, label:'High', color:'#f97316', icon:'☀️', advice:'SPF 30-50, seek shade 10AM–4PM.', spf:'SPF 30–50 required' }, { max:10, label:'Very High', color:'#ef4444', icon:'🔆', advice:'SPF 50+, minimize sun exposure.', spf:'SPF 50+ essential' }, { max:99, label:'Extreme', color:'#7c3aed', icon:'🌡️', advice:'Avoid outdoor exposure if possible.', spf:'SPF 50+ — stay inside' }, ]; let rxBaselineProducts = []; let rxReactionProducts = []; let rxWatchIngredients = new Set(); let rxScannerMode = 'baseline'; let rxInited = false; // seed some demo products async function rxLoadFromDB() { try { const dbProducts = await mbhGetAllProducts(); if(dbProducts && dbProducts.length > 0) { rxBaselineProducts = dbProducts.filter(p => p.type === 'baseline').map(p => { const {type,...rest} = p; return rest; }); rxReactionProducts = dbProducts.filter(p => p.type === 'reaction').map(p => { const {type,...rest} = p; return rest; }); rxWatchIngredients = new Set(); rxReactionProducts.forEach(p => p.ingredients.forEach(i => rxWatchIngredients.add(i.toLowerCase().trim()))); renderRxLists(); console.log('[MyBeauty.Health] Loaded', dbProducts.length, 'products from IndexedDB'); return true; } } catch(e) { console.warn('IndexedDB load:', e); } return false; } function rxSeedData() { rxBaselineProducts = [ { id:'b1', name:'CeraVe Moisturizing Cream', brand:'CeraVe', category:'Moisturizer', ingredients:['water','glycerin','cetearyl alcohol','caprylic/capric triglyceride','behentrimonium methosulfate','ceramide np','ceramide ap','ceramide eop','carbomer','cetyl alcohol','dimethicone','hyaluronic acid','sodium lauroyl lactylate','xanthan gum','sodium hydroxide','phenoxyethanol','ethylhexylglycerin'], rating:5, notes:'Morning & night. Works great.', image:'🧴' }, { id:'b2', name:'Neutrogena Hydro Boost', brand:'Neutrogena', category:'Serum', ingredients:['water','glycerin','dimethicone','phenoxyethanol','carbomer','sodium hyaluronate','dimethiconol','sodium hydroxide','ethylhexylglycerin'], rating:4, notes:'Love for dry days', image:'💧' }, ]; rxReactionProducts = [ { id:'r1', name:'Old Spice Body Wash', brand:'Old Spice', category:'Body Wash', ingredients:['water','sodium laureth sulfate','cocamidopropyl betaine','fragrance','parfum','sodium chloride','sodium benzoate','citric acid','methylisothiazolinone'], reaction:'Hives on arms and chest within 1 hour', severity:'high', date:'2026-01-15', image:'🚿' }, ]; rxWatchIngredients = new Set(); rxReactionProducts.forEach(p => p.ingredients.forEach(i => rxWatchIngredients.add(i.toLowerCase().trim()))); } // ── UV Index ───────────────────────────────────────────────────────────────── async function fetchUVIndex() { try { const pos = await new Promise((res, rej) => navigator.geolocation.getCurrentPosition(res, rej, { timeout:5000 })); const { latitude: lat, longitude: lon } = pos.coords; const r = await fetch(`https://currentuvindex.com/api/v1/uvi?latitude=${lat}&longitude=${lon}`); const data = await r.json(); if(!data.ok) throw new Error('UV fetch failed'); const uvi = data.now.uvi; renderUVBanner(uvi, `${lat.toFixed(2)}°, ${lon.toFixed(2)}°`); } catch(e) { // fallback: use Casas Adobes AZ approximate try { const r = await fetch('https://currentuvindex.com/api/v1/uvi?latitude=32.37&longitude=-110.97'); const data = await r.json(); renderUVBanner(data.ok ? data.now.uvi : 5, 'Casas Adobes, AZ (approx)'); } catch(e2) { renderUVBanner(5, 'Location unavailable'); } } } function renderUVBanner(uvi, locationLbl) { _rxCurrentUV = uvi; // Feed UV to Safety Engine const level = UV_LEVELS.find(l => uvi <= l.max) || UV_LEVELS[4]; const pct = Math.min(100, (uvi / 11) * 100); const setText = (id, v) => { const el = document.getElementById(id); if(el) el.textContent = v; }; setText('rx-uv-val', uvi.toFixed(1)); setText('rx-uv-label', level.label + ' UV'); setText('rx-uv-advice', level.advice); setText('rx-uv-location', locationLbl); const icon = document.getElementById('rx-uv-icon'); if(icon) icon.textContent = level.icon; const bar = document.getElementById('rx-uv-bar'); if(bar) { bar.style.width = pct + '%'; bar.style.background = level.color; } const spfEl = document.getElementById('rx-uv-spf'); if(spfEl) { spfEl.style.display = level.spf ? '' : 'none'; spfEl.textContent = '☀️ ' + level.spf; } const banner = document.getElementById('rx-uv-banner'); if(banner) banner.style.background = `linear-gradient(135deg,${level.color},${level.color}cc)`; } // ── Render product lists ────────────────────────────────────────────────────── function renderRxLists() { renderRxBaseline(); renderRxReactions(); renderRxWatchlist(); } function renderRxBaseline() { const el = document.getElementById('rx-baseline-list'); const ct = document.getElementById('rx-baseline-count'); if(!el) return; if(ct) ct.textContent = rxBaselineProducts.length + ' products'; el.innerHTML = rxBaselineProducts.map(p => `
${p.image||'🧴'}
${p.name}
${p.brand} · ${p.category}
${'⭐'.repeat(p.rating||0)}
${p.notes ? `
"${p.notes}"
` : ''}
View Ingredients (${p.ingredients.length})
${p.ingredients.map(i => { const isWatch = rxWatchIngredients.has(i.toLowerCase().trim()); const isIrrit = RX_KNOWN_IRRITANTS.some(ir => i.toLowerCase().includes(ir)); const bg = isWatch ? '#fee2e2' : isIrrit ? '#fef9c3' : '#f4f6fa'; const col = isWatch ? '#ef4444' : isIrrit ? '#b45309' : '#6b7a99'; return `${i}`; }).join('')}
`).join('') || '
No products added yet. Scan or search to add.
'; } function renderRxReactions() { const el = document.getElementById('rx-reaction-list'); const ct = document.getElementById('rx-reaction-count'); if(!el) return; if(ct) ct.textContent = rxReactionProducts.length + ' products'; const sev = { high:'#ef4444', medium:'#f59e0b', low:'#22c55e' }; el.innerHTML = rxReactionProducts.map(p => `
${p.image||'⚠️'}
${p.name}
${(p.severity||'').toUpperCase()}
${p.brand} · ${p.date}
⚠️ ${p.reaction}
Flagged Ingredients (${p.ingredients.length})
${p.ingredients.map(i => `${i}`).join('')}
`).join('') || '
No reaction products logged yet.
'; } function renderRxWatchlist() { const el = document.getElementById('rx-watchlist-tags'); if(!el) return; const all = [...rxWatchIngredients]; if(!all.length) { el.innerHTML = '
Add a reaction product above to build your watchlist.
'; return; } el.innerHTML = all.map(i => `⚠️ ${i}`).join(''); } function removeRxProduct(list, id) { if(list === 'baseline') rxBaselineProducts = rxBaselineProducts.filter(p => p.id !== id); else { rxReactionProducts = rxReactionProducts.filter(p => p.id !== id); rebuildWatchlist(); } renderRxLists(); } function rebuildWatchlist() { rxWatchIngredients = new Set(); rxReactionProducts.forEach(p => p.ingredients.forEach(i => rxWatchIngredients.add(i.toLowerCase().trim()))); } // ── Camera + Claude Vision — Ingredient Extraction ─────────────────────────── let rxCapturedImageB64 = null; async function handleIngredientPhoto(input) { const file = input.files[0]; if(!file) return; const reader = new FileReader(); reader.onload = async (e) => { const dataUrl = e.target.result; rxCapturedImageB64 = dataUrl.split(',')[1]; // Show preview image const prev = document.getElementById('rx-photo-preview'); const img = document.getElementById('rx-photo-img'); if(prev && img) { img.src = dataUrl; prev.style.display = ''; } // Hide fallback textarea, show status const noPhoto = document.getElementById('rx-no-photo-ings'); if(noPhoto) noPhoto.style.display = 'none'; const status = document.getElementById('rx-scan-status'); if(status) { status.style.display = ''; status.textContent = '\uD83D\uDD0D Reading label with OCR... (first scan may take a moment)'; } try { // Use Tesseract.js for client-side OCR (no API key needed) const result = await Tesseract.recognize(dataUrl, 'eng', { logger: m => { if(m.status === 'recognizing text' && status) { const pct = Math.round((m.progress || 0) * 100); status.textContent = '\uD83D\uDD0D Scanning label... ' + pct + '%'; } } }); const rawText = result.data.text || ''; if(!rawText.trim()) { if(status) status.textContent = '\u26A0\uFE0F Could not read any text. Try a clearer, well-lit photo.'; if(noPhoto) noPhoto.style.display = ''; return; } // Smart ingredient parsing from OCR text const parsed = parseIngredientsFromOCR(rawText); const ings = parsed.ingredients; if(!ings.length) { if(status) status.textContent = '\u26A0\uFE0F Text found but no ingredient list detected. Try a closer photo of just the ingredients section.'; if(noPhoto) noPhoto.style.display = ''; // Show raw text so user can manually fix const fbField = document.getElementById('rx-modal-manual-ings-fallback'); if(fbField) fbField.value = rawText.replace(/\n+/g, ' ').trim(); return; } // Auto-fill product name / brand if found const nameInp = document.getElementById('rx-modal-manual-name'); const brandInp = document.getElementById('rx-modal-manual-brand'); if(nameInp && !nameInp.value && parsed.product_name) nameInp.value = parsed.product_name; if(brandInp && !brandInp.value && parsed.brand) brandInp.value = parsed.brand; // Populate extracted tags + textarea const ingText = ings.join(', '); const ingField = document.getElementById('rx-modal-manual-ings'); const fbField = document.getElementById('rx-modal-manual-ings-fallback'); if(ingField) ingField.value = ingText; if(fbField) fbField.value = ingText; renderExtractedTags(ings); if(status) status.textContent = '\u2705 ' + ings.length + ' ingredients extracted! Review and edit below.'; const wrap = document.getElementById('rx-extracted-wrap'); if(wrap) wrap.style.display = ''; } catch(err) { console.error('OCR error:', err); if(status) status.textContent = '\u274C Scan failed \u2014 try a clearer photo of the ingredient label.'; if(noPhoto) noPhoto.style.display = ''; } }; reader.readAsDataURL(file); } /* --- Smart ingredient parser from raw OCR text --- */ function parseIngredientsFromOCR(rawText) { let text = rawText.replace(/\r/g, ''); let productName = ''; let brand = ''; let ingredientText = ''; // Try to find "Active ingredients:" or "Inactive ingredients:" or "Ingredients:" section const ingRegex = /(?:inactive\s+ingredients?|active\s+ingredients?|ingredients?)\s*[:\-\.]\s*([\s\S]*?)(?=directions|warnings|other\s+info|see\s+carton|storage|questions|dist|mfg|made\s+in|\*no\s+paraben|$)/i; const match = text.match(ingRegex); if(match) { ingredientText = match[1]; } else { // Fall back: look for comma-separated chemical-sounding terms const lines = text.split('\n'); for(let i = 0; i < lines.length; i++) { const line = lines[i].trim(); // A line with multiple commas and chemical-sounding words if((line.match(/,/g) || []).length >= 3 && /(?:water|aqua|glycerin|acid|sodium|extract|oil|dimethicone|cetyl|stearyl|tocopherol)/i.test(line)) { ingredientText = lines.slice(i).join(' '); break; } } } if(!ingredientText.trim()) { // Last resort: if we detect multiple known ingredients anywhere const knownIngs = ['water','aqua','glycerin','dimethicone','sodium','cetyl','stearyl','tocopherol','niacinamide','hyaluronic','salicylic','retinol','titanium dioxide','zinc oxide','avobenzone','octocrylene','octisalate','homosalate','oxybenzone']; const lower = text.toLowerCase(); const found = knownIngs.filter(k => lower.includes(k)); if(found.length >= 2) { ingredientText = text; } } // Clean and split ingredients let cleaned = ingredientText .replace(/\n/g, ' ') .replace(/\s+/g, ' ') .replace(/\.\s*$/, '') .trim(); // Remove trailing non-ingredient text cleaned = cleaned.replace(/\s*(directions|warnings|other info|see carton|do not use|for external|keep out|stop use|ask a doctor|storage|questions|use:|uses:)[\s\S]*/i, ''); // Clean up percentage suffixes that got merged with trailing text cleaned = cleaned.replace(/(\d+\.?\d*%)\.\s*/g, '$1, '); // Split on commas, semicolons, or bullet points let ings = cleaned.split(/[,;•·]\s*/) .map(s => s.trim().replace(/^\d+[\.\)]\s*/, '').replace(/\.$/, '').trim()) .filter(s => s.length > 1 && s.length < 80) .filter(s => !/^(and|or|with|the|for|use|may|see|ask)$/i.test(s)); // Remove duplicates const seen = new Set(); ings = ings.filter(i => { const key = i.toLowerCase(); if(seen.has(key)) return false; seen.add(key); return true; }); // Try to detect product name from early text const nameMatch = text.match(/^([\w\s&']+(?:Sunscreen|Lotion|Cream|Serum|Cleanser|Wash|Moisturizer|Shampoo|Conditioner|Oil|Gel|Spray|Balm|Mask|Toner|Scrub))/im); if(nameMatch) productName = nameMatch[1].trim(); // Try to detect brand const brandPatterns = ['Walgreens','CeraVe','Neutrogena','Olay','Aveeno','Dove','Nivea','Cetaphil','La Roche-Posay','Eucerin','Vanicream','EltaMD','Supergoop','Drunk Elephant','The Ordinary','Cocokind','Paula\'s Choice']; const lower = text.toLowerCase(); for(const b of brandPatterns) { if(lower.includes(b.toLowerCase())) { brand = b; break; } } return { ingredients: ings, product_name: productName, brand: brand }; } function renderExtractedTags(ings) { const wrap = document.getElementById('rx-extracted-tags'); if(!wrap) return; wrap.innerHTML = ings.map((ing, i) => { const isWatch = rxWatchIngredients.has(ing.toLowerCase().trim()); const isIrrit = RX_KNOWN_IRRITANTS.some(ir => ing.toLowerCase().includes(ir)); const bg = isWatch ? '#fee2e2' : isIrrit ? '#fef9c3' : '#e0f2fe'; const col = isWatch ? '#ef4444' : isIrrit ? '#b45309' : '#0369a1'; return `${ing} ✕`; }).join(''); } // ── Safety Preview in Add Modal ────────────────────────────────────────────── function renderModalSafetyPreview(ings) { const container = document.getElementById('rx-modal-safety-preview'); if(!container) return; if(!ings || ings.length === 0) { container.innerHTML = ''; return; } const analysis = analyzeSafety(ings, [...rxWatchIngredients], _rxCurrentUV); container.innerHTML = renderSafetyCard(analysis); // Also show alternatives if RED/YELLOW if(analysis.alternatives.length > 0) { const altHtml = '
' + '
\uD83D\uDD04 Try These Instead
' + analysis.alternatives.slice(0,4).map(a => '
' + '\u2713' + '
' + a.name + ' (' + a.brand + ')' + '
' + a.why + '
' ).join('') + '
'; container.insertAdjacentHTML('beforeend', altHtml); } } function removeExtractedTag(idx) { const field = document.getElementById('rx-modal-manual-ings'); if(!field) return; const ings = field.value.split(',').map(i=>i.trim()).filter(Boolean); ings.splice(idx, 1); field.value = ings.join(', '); renderExtractedTags(ings); renderModalSafetyPreview(ings); } function syncIngTags() { const field = document.getElementById('rx-modal-manual-ings'); if(!field) return; const ings = field.value.split(',').map(i=>i.trim()).filter(Boolean); renderExtractedTags(ings); renderModalSafetyPreview(ings); } // ── Safety Check (powered by MyBeauty.Health Safety Engine v1.0) ────────────── let _rxCurrentUV = 0; // Updated by fetchUVIndex function rxSafetyCheck(ingredients) { // Use the full Safety Engine with family matching, risk flags, and alternatives const result = analyzeSafety(ingredients, [...rxWatchIngredients], _rxCurrentUV); // Return object compatible with existing UI code return result; } // ── Search Open Beauty Facts ────────────────────────────────────────────────── // ── Beauty Product Search — multi-source with category filters ─────────────── const RX_CATEGORIES = [ { label:'All', val:'', icon:'🧴' }, { label:'Moisturizer',val:'moisturizers', icon:'💧' }, { label:'Shampoo', val:'shampoos', icon:'🚿' }, { label:'Sunscreen', val:'sunscreens', icon:'☀️' }, { label:'Makeup', val:'makeup', icon:'💄' }, { label:'Serum', val:'serums', icon:'🧪' }, { label:'Body Wash', val:'shower-gels', icon:'🛁' }, { label:'Deodorant', val:'deodorants', icon:'🌿' }, { label:'Toothpaste', val:'toothpastes', icon:'🦷' }, { label:'Perfume', val:'perfumes', icon:'🌸' }, ]; let rxSearchPage = 1; let rxLastQuery = ''; let rxLastCat = ''; let rxSearching = false; function initRxSearchUI() { const wrap = document.getElementById('rx-cat-filters'); if(!wrap || wrap.dataset.inited) return; wrap.dataset.inited = '1'; wrap.innerHTML = RX_CATEGORIES.map((c,i) => `` ).join(''); } function selectRxCat(btn, cat) { rxLastCat = cat; rxSearchPage = 1; document.querySelectorAll('.rx-cat-btn').forEach(b => { b.style.border='1.5px solid #e2e8f0'; b.style.background='#fff'; b.style.color='var(--navy)'; }); btn.style.border='1.5px solid var(--orange)'; btn.style.background='#fff7ed'; btn.style.color='var(--orange)'; if(rxLastQuery || cat) searchBeautyProduct(true); } // Helper: render safety info for search result card function _rxSearchSafetyHtml(check, hasIngs, ingCount) { if(!check) { return '
' + (hasIngs ? ingCount + ' ingredients' : 'No ingredient data \u2014 tap to add anyway') + '
'; } let h = '
' + check.icon + ' ' + check.label + '
' + '
' + check.detail + '
'; if(check.familyHits && check.familyHits.length > 0) { h += '
' + check.familyHits.slice(0,3).map(function(fh) { return fh.name; }).join(' \u2022 ') + '
'; } return h; } async function searchBeautyProduct(reset=false) { if(rxSearching) return; const q = (document.getElementById('rx-search-inp')?.value || '').trim(); const mode = document.getElementById('rx-add-mode')?.value || 'baseline'; if(!q && !rxLastCat) { document.getElementById('rx-search-results').innerHTML='
Type a product name or choose a category above.
'; return; } if(reset) { rxSearchPage=1; rxLastQuery=q; } else if(q !== rxLastQuery) { rxSearchPage=1; rxLastQuery=q; } const el = document.getElementById('rx-search-results'); if(rxSearchPage === 1) el.innerHTML = '
🔍 Searching Open Beauty Facts…
'; rxSearching = true; const products = await fetchBeautyProducts(q, rxLastCat, rxSearchPage); rxSearching = false; if(!products.length && rxSearchPage === 1) { el.innerHTML = `
No results found in Open Beauty Facts for "${q||rxLastCat}".
Try a brand name, product type, or scan the barcode directly.
`; return; } const cards = products.filter(p=>p.product_name).map(p => { const ings = parseIngredients(p.ingredients_text||''); const check = mode==='check' ? rxSafetyCheck(ings) : null; const brand = (p.brands||'').split(',')[0].trim(); const encoded = encodeURIComponent(JSON.stringify({ name:p.product_name, brand, ingredients:ings, image:p.image_small_url||'', category:(p.categories_tags||['beauty'])[0].replace(/^en:/,'') })); const hasIngs = ings.length > 0; return `
${p.image_small_url ? `` : `
🧴
`}
${p.product_name}
${brand ? `
${brand}
` : ''} ${_rxSearchSafetyHtml(check, hasIngs, ings.length)}
${mode==='check'&&check&&check.level!=='GREEN'?``:''}
`; }).join(''); if(rxSearchPage===1) el.innerHTML = cards; else el.insertAdjacentHTML('beforeend', cards); // Load more button const existingBtn = document.getElementById('rx-load-more'); if(existingBtn) existingBtn.remove(); if(products.length >= 10) { el.insertAdjacentHTML('beforeend', ``); } } function rxLoadMore() { rxSearchPage++; searchBeautyProduct(false); } async function fetchBeautyProducts(q, cat, page) { const pageSize = 12; // OBF search — no custom headers to avoid CORS preflight let url = `https://world.openbeautyfacts.org/cgi/search.pl?action=process&json=1&page_size=${pageSize}&page=${page}&fields=product_name,brands,ingredients_text,image_small_url,categories_tags`; if(q) url += `&search_terms=${encodeURIComponent(q)}&search_simple=1`; if(cat) url += `&tagtype_0=categories&tag_contains_0=contains&tag_0=${encodeURIComponent(cat)}`; try { const res = await fetch(url); if(!res.ok) throw new Error('HTTP '+res.status); const data = await res.json(); const prods = (data.products||[]).filter(p=>p.product_name); if(prods.length) return prods; throw new Error('no results'); } catch(e) { // Fallback: Open Food Facts cosmetics try { let fb = `https://world.openfoodfacts.org/cgi/search.pl?action=process&json=1&page_size=${pageSize}&page=${page}&fields=product_name,brands,ingredients_text,image_small_url,categories_tags`; if(q) fb += `&search_terms=${encodeURIComponent(q)}&search_simple=1`; if(cat) fb += `&tagtype_0=categories&tag_contains_0=contains&tag_0=${encodeURIComponent(cat)}`; else fb += `&tagtype_0=categories&tag_contains_0=contains&tag_0=cosmetics`; const r2 = await fetch(fb); if(!r2.ok) throw new Error('HTTP '+r2.status); const d2 = await r2.json(); return (d2.products||[]).filter(p=>p.product_name); } catch(e2) { return []; } } } // ── Add Product Modal (replaces prompt()) ──────────────────────────────────── let rxPendingProduct = null; let rxPendingMode = 'baseline'; function openRxAddModal(mode, encoded) { let p; try { p = typeof encoded==='string' ? JSON.parse(decodeURIComponent(encoded)) : encoded; } catch(e) { return; } rxPendingProduct = p; rxPendingMode = mode; const m = document.getElementById('rx-add-modal'); if(!m) return; // Set product info document.getElementById('rx-modal-name').textContent = p.name; document.getElementById('rx-modal-brand').textContent = p.brand || ''; document.getElementById('rx-modal-img').textContent = '🧴'; if(p.image) { const img=document.getElementById('rx-modal-img-el'); if(img){img.src=p.image;img.style.display='';} } // Reset photo capture state rxCapturedImageB64 = null; const prev = document.getElementById('rx-photo-preview'); if(prev) prev.style.display = 'none'; const scan = document.getElementById('rx-scan-status'); if(scan) scan.style.display = 'none'; const wrap = document.getElementById('rx-extracted-wrap'); if(wrap) wrap.style.display = 'none'; const noP = document.getElementById('rx-no-photo-ings'); if(noP) noP.style.display = ''; const camI = document.getElementById('rx-cam-input'); if(camI) camI.value = ''; const filI = document.getElementById('rx-file-input'); if(filI) filI.value = ''; const ingF = document.getElementById('rx-modal-manual-ings'); if(ingF) ingF.value = ''; const fbF = document.getElementById('rx-modal-manual-ings-fallback'); if(fbF) fbF.value = ''; // Toggle manual entry section const isManual = !p.name; document.getElementById('rx-modal-manual-section').style.display = isManual ? '' : 'none'; if(isManual) { document.getElementById('rx-modal-manual-name').value = ''; document.getElementById('rx-modal-manual-brand').value = ''; document.getElementById('rx-modal-manual-ings').value = ''; document.getElementById('rx-modal-name').textContent = 'Enter product details below'; document.getElementById('rx-modal-brand').textContent = ''; } // Toggle sections const isReaction = mode === 'reaction'; document.getElementById('rx-modal-reaction-section').style.display = isReaction ? '' : 'none'; document.getElementById('rx-modal-baseline-section').style.display = isReaction ? 'none' : ''; document.getElementById('rx-modal-title').textContent = isReaction ? '⚠️ Log Reaction Product' : '🧴 Add to My Products'; // Reset fields document.getElementById('rx-modal-notes').value = ''; document.getElementById('rx-modal-reaction').value = ''; document.getElementById('rx-modal-symptoms').value = ''; document.getElementById('rx-modal-sev').value = 'medium'; document.getElementById('rx-modal-date').value = new Date().toISOString().split('T')[0]; setRxStarRating(0); m.style.display = 'flex'; } function closeRxAddModal() { const m = document.getElementById('rx-add-modal'); if(m) m.style.display = 'none'; rxPendingProduct = null; } let rxStarVal = 0; function setRxStarRating(n) { rxStarVal = n; document.querySelectorAll('.rx-star').forEach((s,i) => { s.textContent = i < n ? '⭐' : '☆'; s.style.opacity = i < n ? '1' : '0.4'; }); } function saveRxProduct() { if(!rxPendingProduct) return; let p = rxPendingProduct; const id = 'rx_' + Date.now(); const notes = document.getElementById('rx-modal-notes')?.value.trim() || ''; const reaction = document.getElementById('rx-modal-reaction')?.value.trim() || ''; const symptoms = document.getElementById('rx-modal-symptoms')?.value.trim() || ''; const sev = document.getElementById('rx-modal-sev')?.value || 'medium'; const date = document.getElementById('rx-modal-date')?.value || new Date().toISOString().split('T')[0]; // Manual entry override if(!p.name) { const manName = document.getElementById('rx-modal-manual-name')?.value.trim(); const manBrand = document.getElementById('rx-modal-manual-brand')?.value.trim(); const manIngs = document.getElementById('rx-modal-manual-ings')?.value.trim(); if(!manName) { toast('Please enter a product name', '#ef4444'); return; } p = { ...p, name:manName, brand:manBrand, ingredients:parseIngredients(manIngs) }; } if(rxPendingMode === 'reaction') { rxReactionProducts.push({ id, name:p.name, brand:p.brand||'Unknown', category:p.category||'Beauty', ingredients:p.ingredients||[], reaction:reaction||'Adverse reaction noted', symptoms, severity:sev, date, image:'⚠️' }); rebuildWatchlist(); toast(`⚠️ ${p.name} added to reaction list!`, '#ef4444'); } else { rxBaselineProducts.push({ id, name:p.name, brand:p.brand||'Unknown', category:p.category||'Beauty', ingredients:p.ingredients||[], rating:rxStarVal, notes, image:'🧴' }); toast(`🧴 ${p.name} added to your products!`, '#16a34a'); } closeRxAddModal(); document.getElementById('rx-search-results').innerHTML = ''; document.getElementById('rx-search-inp').value = ''; renderRxLists(); // Persist to IndexedDB for cross-session memory try { const allProducts = [...rxBaselineProducts.map(p=>({...p,type:'baseline'})), ...rxReactionProducts.map(p=>({...p,type:'reaction'}))]; allProducts.forEach(p => mbhSaveProduct(p).catch(()=>{})); } catch(e) { console.warn('IndexedDB save:', e); } // Also sync to Supabase if (typeof litSaveProduct !== 'undefined') { const prodType = rxPendingMode === 'reaction' ? 'reaction' : 'baseline'; litSaveProduct({product_type:prodType, name:p.name, brand:p.brand||'Unknown', category:p.category||'Beauty', ingredients:p.ingredients||[], rating:rxStarVal, notes, reaction:reaction||'', symptoms:symptoms||'', severity:sev||''}); } } function addRxProduct(mode, encoded) { openRxAddModal(mode, encoded); } // ── Barcode Scanner ─────────────────────────────────────────────────────────── let _rxBarcodeScanner = null; function openRxScanner() { document.getElementById('rx-barcode-modal').style.display='flex'; document.getElementById('rx-barcode-result').innerHTML=''; document.getElementById('rx-barcode-inp').value=''; const feedback = document.getElementById('rx-scan-feedback'); if(feedback) feedback.textContent = 'Starting camera...'; // Start camera barcode scanner setTimeout(() => { if(typeof Html5Qrcode === 'undefined') { if(feedback) feedback.textContent = 'Camera scanner not available. Enter barcode manually.'; return; } try { _rxBarcodeScanner = new Html5Qrcode('rx-barcode-reader'); _rxBarcodeScanner.start( { facingMode: 'environment' }, { fps: 10, qrbox: function(vw, vh) { var s = Math.min(vw, vh); return { width: Math.floor(s * 0.8), height: Math.floor(s * 0.4) }; }, formatsToSupport: [ Html5QrcodeSupportedFormats.EAN_13, Html5QrcodeSupportedFormats.EAN_8, Html5QrcodeSupportedFormats.UPC_A, Html5QrcodeSupportedFormats.UPC_E, Html5QrcodeSupportedFormats.CODE_128, Html5QrcodeSupportedFormats.CODE_39 ] }, function onScanSuccess(decodedText) { // Barcode detected! if(feedback) feedback.innerHTML = '\u2705 Barcode found: ' + decodedText + ''; document.getElementById('rx-barcode-inp').value = decodedText; // Auto-vibrate for feedback if(navigator.vibrate) navigator.vibrate(100); // Stop scanning and auto-lookup try { _rxBarcodeScanner.stop(); } catch(e) {} _rxBarcodeScanner = null; lookupBeautyBarcode(); }, function onScanFailure() { /* ignore continuous scan misses */ } ).then(function() { if(feedback) feedback.textContent = 'Point camera at product barcode'; }).catch(function(err) { console.warn('Camera start failed:', err); if(feedback) feedback.textContent = 'Camera not available. Enter barcode manually below.'; }); } catch(e) { console.warn('Scanner init error:', e); if(feedback) feedback.textContent = 'Camera not available. Enter barcode manually below.'; } }, 300); } function closeRxScanner() { // Stop camera if running if(_rxBarcodeScanner) { try { _rxBarcodeScanner.stop(); } catch(e) {} _rxBarcodeScanner = null; } // Clear the reader element var reader = document.getElementById('rx-barcode-reader'); if(reader) reader.innerHTML = ''; document.getElementById('rx-barcode-modal').style.display='none'; document.getElementById('rx-barcode-inp').value=''; document.getElementById('rx-barcode-result').innerHTML=''; } function setRxMode(btn, mode) { rxScannerMode = mode; document.querySelectorAll('.rx-mode-btn').forEach(b => { b.style.border='1.5px solid #e2e8f0'; b.style.background='#fff'; b.style.color='var(--navy)'; }); btn.style.border='1.5px solid var(--orange)'; btn.style.background='#fff7ed'; btn.style.color='var(--orange)'; } async function lookupBeautyBarcode() { const code = document.getElementById('rx-barcode-inp').value.trim().replace(/\D/g,''); const resEl = document.getElementById('rx-barcode-result'); if(!code) { resEl.innerHTML='
Enter a barcode number.
'; return; } resEl.innerHTML='
🔍 Looking up…
'; try { const r = await fetch(`https://world.openbeautyfacts.org/api/v2/product/${code}.json`); const data = await r.json(); if(data.status !== 1 || !data.product) { resEl.innerHTML='
❌ Product not found in Open Beauty Facts.
'; return; } const p = data.product; const ings = parseIngredients(p.ingredients_text||''); const check = rxScannerMode === 'check' ? rxSafetyCheck(ings) : null; const encoded = encodeURIComponent(JSON.stringify({ name:p.product_name, brand:(p.brands||'').split(',')[0].trim(), ingredients:ings, image:p.image_small_url||'', category:(p.categories||'').split(',')[0].trim() })); resEl.innerHTML = `
${p.image_small_url ? `` : '
🧴
'}
${p.product_name||'Unknown'}
${(p.brands||'').split(',')[0].trim()}
${ings.length} ingredients detected
${check ? `
${check.icon} ${check.label}
${check.detail}
` : ''}
`; } catch(e) { resEl.innerHTML='
⚠️ Lookup failed. Check your connection.
'; } } // ══════════════════════════════════════════════════════════════════════════════ // SERVE IT! TRACKER // ══════════════════════════════════════════════════════════════════════════════ var svInited = false; var svHistory = []; var svChartWeekly = null, svChartTrend = null; var svSelectedType = ''; var svSelectedScore = ''; function generateServeItDemoData() { var hist = []; var types = ['volunteer','dollars','inkind','wisdom','leadership','blood','bloodrecruit','fundraising']; var labels = {volunteer:'Volunteer Time',dollars:'Donated Dollars',inkind:'In-Kind Donation',wisdom:'Wisdom/Mentoring',leadership:'Leadership Talent',blood:'Blood Donation',bloodrecruit:'Recruited Blood Donor',fundraising:'Fundraising'}; var coins = {volunteer:10,dollars:15,inkind:10,wisdom:10,leadership:15,blood:50,bloodrecruit:25,fundraising:20}; var filterCats = {volunteer:'volunteer',dollars:'money',inkind:'money',wisdom:'mentor',leadership:'volunteer',blood:'blood',bloodrecruit:'blood',fundraising:'money'}; var whos = ['Non-Profit Organization','A Senior Citizen','My Family','A Public Agency','School or University']; var scores = ['great','soso','terrible']; for(var d=29;d>=0;d--) { var dt = new Date(); dt.setDate(dt.getDate()-d); if(Math.random()>0.4) { var type = types[Math.floor(Math.random()*types.length)]; var mins = type==='volunteer'||type==='wisdom'||type==='leadership'?15+Math.floor(Math.random()*120):0; var dollars = type==='dollars'||type==='fundraising'?5+Math.floor(Math.random()*200):type==='inkind'?10+Math.floor(Math.random()*100):0; var units = type==='blood'?1:type==='bloodrecruit'?1+Math.floor(Math.random()*2):0; hist.push({date:dt.toISOString().slice(0,10),type:type,label:labels[type],coins:coins[type],cat:filterCats[type],who:whos[Math.floor(Math.random()*whos.length)],score:scores[Math.floor(Math.random()*3)],minutes:mins,dollars:dollars,units:units,note:''}); } } return hist; } function selectServeType(type, el) { svSelectedType = type; document.querySelectorAll('.sv-type-card').forEach(function(c){c.style.borderColor='var(--light)';c.style.background='#fff';}); el.style.borderColor = '#22c55e'; el.style.background = '#ecfdf5'; // Show detail card and the correct fields document.getElementById('sv-detail-card').style.display = 'block'; document.querySelectorAll('.sv-fields').forEach(function(f){f.style.display='none';}); var fieldMap = {volunteer:'volunteer',dollars:'dollars',inkind:'inkind',wisdom:'wisdom',leadership:'leadership',blood:'blood',bloodrecruit:'bloodrecruit',fundraising:'fundraising'}; var panel = document.getElementById('sv-fields-'+fieldMap[type]); if(panel) panel.style.display = 'block'; var titles = {volunteer:'⏱️ Volunteer Time Details',dollars:'💵 Donation Details',inkind:'🎁 In-Kind Donation Details',wisdom:'🧠 Mentoring Details',leadership:'👑 Leadership Service Details',blood:'🩸 Blood Donation Details',bloodrecruit:'🤝 Blood Donor Recruitment',fundraising:'🎗️ Fundraising Details'}; document.getElementById('sv-detail-title').textContent = titles[type] || '📝 Service Details'; } function setSvScore(score, btn) { svSelectedScore = score; document.querySelectorAll('.sv-score-btn').forEach(function(b){b.style.borderColor='var(--light)';b.style.background='#fff';}); btn.style.borderColor = '#22c55e'; btn.style.background = '#ecfdf5'; } function initServeItTracker() { if(svInited) return; svInited = true; svHistory = generateServeItDemoData(); var today = new Date().toISOString().slice(0,10); var dateInput = document.getElementById('sv-log-date'); if(dateInput) dateInput.value = today; renderSvStats(); renderSvWeeklyChart(); renderSvStreak(); renderSvTrend(); renderSvImpact(); renderSvActivityLog('all'); } function renderSvStats() { var container = document.getElementById('sv-stats-row'); if(!container) return; var totalCoins=0, totalMinutes=0, totalDollars=0, totalEntries=svHistory.length; svHistory.forEach(function(e){totalCoins+=e.coins;totalMinutes+=e.minutes||0;totalDollars+=e.dollars||0;}); var stats = [ {icon:'🪙',label:'SERVE It! Coins',val:totalCoins.toLocaleString(),color:'#22c55e'}, {icon:'⏱️',label:'Minutes Served',val:totalMinutes.toLocaleString(),color:'#003670'}, {icon:'💵',label:'$ Donated/Raised',val:'$'+totalDollars.toLocaleString(),color:'#7c3aed'}, {icon:'📊',label:'Service Entries',val:totalEntries,color:'var(--orange)'} ]; container.innerHTML = stats.map(function(s){ return '
'+s.icon+'
'+s.val+'
'+s.label+'
'; }).join(''); } function renderSvWeeklyChart() { var canvas = document.getElementById('ch-sv-weekly'); if(!canvas) return; var days=['Mon','Tue','Wed','Thu','Fri','Sat','Sun']; var mins=[0,0,0,0,0,0,0]; var now=new Date(); var sow=new Date(now);sow.setDate(now.getDate()-now.getDay()+1);sow.setHours(0,0,0,0); svHistory.forEach(function(e){var d=new Date(e.date+'T12:00:00');if(d>=sow){var idx=(d.getDay()+6)%7;mins[idx]+=(e.minutes||0);}}); var wkTotal=mins.reduce(function(a,b){return a+b},0); var lbl=document.getElementById('sv-weekly-lbl'); if(lbl) lbl.textContent=wkTotal+' minutes this week'; if(svChartWeekly) svChartWeekly.destroy(); svChartWeekly=new Chart(canvas,{type:'bar',data:{labels:days,datasets:[{data:mins,backgroundColor:'#22c55e',borderRadius:6}]},options:{responsive:true,plugins:{legend:{display:false}},scales:{y:{beginAtZero:true,title:{display:true,text:'Minutes',font:{size:11}}},x:{grid:{display:false}}}}}); } function renderSvStreak() { var dateSet={}; svHistory.forEach(function(e){dateSet[e.date]=true;}); var streak=0;var d=new Date();d.setHours(0,0,0,0); while(dateSet[d.toISOString().slice(0,10)]){streak++;d.setDate(d.getDate()-1);} var el=document.getElementById('sv-streak-num');if(el) el.textContent=streak; var dates=Object.keys(dateSet).sort(); var best=0,cur=1; for(var i=1;ibest)best=cur;} if(dates.length>0&&best===0)best=1; var bel=document.getElementById('sv-best-streak');if(bel) bel.textContent=Math.max(best,streak); var calEl=document.getElementById('sv-streak-cal'); if(calEl){var cells=[];for(var i=27;i>=0;i--){var cd=new Date();cd.setDate(cd.getDate()-i);var key=cd.toISOString().slice(0,10);var active=dateSet[key];cells.push('
');}calEl.innerHTML=cells.join('');} } function renderSvTrend() { var canvas=document.getElementById('ch-sv-trend');if(!canvas) return; var labels=[],data=[]; for(var i=29;i>=0;i--){var d=new Date();d.setDate(d.getDate()-i);var key=d.toISOString().slice(0,10);labels.push(d.toLocaleDateString('en',{month:'short',day:'numeric'}));var dayMins=0;svHistory.forEach(function(e){if(e.date===key)dayMins+=(e.minutes||0);});data.push(dayMins);} if(svChartTrend) svChartTrend.destroy(); svChartTrend=new Chart(canvas,{type:'line',data:{labels:labels,datasets:[{data:data,borderColor:'#22c55e',backgroundColor:'rgba(34,197,94,.08)',fill:true,tension:.35,pointRadius:2,pointBackgroundColor:'#22c55e',borderWidth:2}]},options:{responsive:true,plugins:{legend:{display:false}},scales:{y:{beginAtZero:true,title:{display:true,text:'Minutes',font:{size:11}}},x:{ticks:{maxTicksToShow:8,font:{size:10}},grid:{display:false}}}}}); } function renderSvImpact() { var container=document.getElementById('sv-impact');if(!container) return; var totalMins=0,totalDollars=0,totalBlood=0,totalMentorMins=0; svHistory.forEach(function(e){ totalMins+=(e.minutes||0);totalDollars+=(e.dollars||0);totalBlood+=(e.units||0); if(e.type==='wisdom')totalMentorMins+=(e.minutes||0); }); var items=[ {icon:'⏱️',label:'Total volunteer hours',val:Math.round(totalMins/60)+' hours',color:'#003670'}, {icon:'💵',label:'Total dollars donated/raised',val:'$'+totalDollars.toLocaleString(),color:'#22c55e'}, {icon:'🩸',label:'Blood units donated/recruited',val:totalBlood+' units',color:'#e53e3e'}, {icon:'🧠',label:'Mentoring hours',val:Math.round(totalMentorMins/60)+' hours',color:'#7c3aed'}, {icon:'🤝',label:'Total service entries',val:svHistory.length+' acts of service',color:'var(--orange)'} ]; container.innerHTML=items.map(function(it){ return '
'+it.icon+'
'+it.label+'
'+it.val+'
'; }).join(''); } function renderSvActivityLog(filter) { var container=document.getElementById('sv-activity-log');if(!container) return; var icons={volunteer:'⏱️',dollars:'💵',inkind:'🎁',wisdom:'🧠',leadership:'👑',blood:'🩸',bloodrecruit:'🤝',fundraising:'🎗️'}; var filtered=filter==='all'?svHistory:svHistory.filter(function(e){return e.cat===filter;}); var recent=filtered.slice(-20).reverse(); if(recent.length===0){container.innerHTML='
No service activity found for this filter.
';return;} container.innerHTML=recent.map(function(e){ var detail=e.minutes?e.minutes+' min':''; if(e.dollars) detail=(detail?detail+' | ':'')+'$'+e.dollars; if(e.units) detail=(detail?detail+' | ':'')+e.units+' unit(s)'; var scoreEmoji=e.score==='great'?'😊':e.score==='soso'?'😐':'😞'; return '
'+(icons[e.type]||'🤝')+'
'+e.label+'
'+e.date+(e.who?' · '+e.who:'')+(detail?' · '+detail:'')+'
'+scoreEmoji+'+'+e.coins+' 🪙
'; }).join(''); } function filterSvLog(filter, btn) { if(btn){btn.parentElement.querySelectorAll('.pain-loc-btn').forEach(function(b){b.classList.remove('active')});btn.classList.add('active');} renderSvActivityLog(filter); } function logServeItActivity() { if(!svSelectedType){toast('Please select a service type above!','#e53e3e');return;} var dateVal=document.getElementById('sv-log-date').value||new Date().toISOString().slice(0,10); var noteVal=document.getElementById('sv-log-note').value||''; var labels={volunteer:'Volunteer Time',dollars:'Donated Dollars',inkind:'In-Kind Donation',wisdom:'Wisdom/Mentoring',leadership:'Leadership Talent',blood:'Blood Donation',bloodrecruit:'Recruited Blood Donor',fundraising:'Fundraising'}; var coinVals={volunteer:10,dollars:15,inkind:10,wisdom:10,leadership:15,blood:50,bloodrecruit:25,fundraising:20}; var filterCats={volunteer:'volunteer',dollars:'money',inkind:'money',wisdom:'mentor',leadership:'volunteer',blood:'blood',bloodrecruit:'blood',fundraising:'money'}; var mins=0,dollars=0,units=0,who=''; if(svSelectedType==='volunteer'){mins=parseInt(document.getElementById('sv-vol-minutes').value)||0;who=document.getElementById('sv-vol-who').value;} else if(svSelectedType==='dollars'){dollars=parseFloat(document.getElementById('sv-dollars-amount').value)||0;who=document.getElementById('sv-dollars-who').value;} else if(svSelectedType==='inkind'){dollars=parseFloat(document.getElementById('sv-inkind-value').value)||0;who=document.getElementById('sv-inkind-who').value;} else if(svSelectedType==='wisdom'){mins=parseInt(document.getElementById('sv-wisdom-minutes').value)||0;who=document.getElementById('sv-wisdom-who').value;} else if(svSelectedType==='leadership'){mins=parseInt(document.getElementById('sv-lead-minutes').value)||0;} else if(svSelectedType==='blood'){units=parseInt(document.getElementById('sv-blood-units').value)||1;} else if(svSelectedType==='bloodrecruit'){units=parseInt(document.getElementById('sv-recruit-count').value)||1;} else if(svSelectedType==='fundraising'){dollars=parseFloat(document.getElementById('sv-fund-amount').value)||0;mins=parseInt(document.getElementById('sv-fund-minutes').value)||0;} var earned=coinVals[svSelectedType]||10; svHistory.push({date:dateVal,type:svSelectedType,label:labels[svSelectedType],coins:earned,cat:filterCats[svSelectedType],who:who,score:svSelectedScore||'great',minutes:mins,dollars:dollars,units:units,note:noteVal}); toast('🤝 Earned '+earned+' SERVE It! Coins!','#22c55e'); // Sync to Supabase if (typeof litSaveServeItEntry !== 'undefined') litSaveServeItEntry({date:dateVal, type:svSelectedType, label:labels[svSelectedType], coins:earned, cat:filterCats[svSelectedType], who, score:svSelectedScore||'great', minutes:mins, dollars, units, note:noteVal}); // Reset form svSelectedType='';svSelectedScore=''; document.querySelectorAll('.sv-type-card').forEach(function(c){c.style.borderColor='var(--light)';c.style.background='#fff';}); document.querySelectorAll('.sv-score-btn').forEach(function(b){b.style.borderColor='var(--light)';b.style.background='#fff';}); document.getElementById('sv-detail-card').style.display='none'; document.getElementById('sv-log-note').value=''; // Re-render renderSvStats();renderSvWeeklyChart();renderSvStreak();renderSvTrend();renderSvImpact();renderSvActivityLog('all'); } function initReactionTracker() { if(!rxInited) { rxSeedData(); initSafetyEngine(); rxLoadFromDB(); rxInited = true; } renderRxLists(); fetchUVIndex(); initRxSearchUI(); } // NUTRITION TRACKER // ══════════════════════════════════════════════════════════════════════════════ const NUT_GOALS = { cal:1520, carbs:190, protein:95, fat:50, fiber:25 }; const NUT_TIPS = [ { title:'Eat All the Meals!', body:"Divide your meals evenly throughout the day. Do not skip the 3 main meals: breakfast, lunch and dinner. But also listen to your body — if you are not very hungry, that is okay too." }, { title:'Protein Powers Recovery', body:'Aim for 0.7–1g of protein per pound of bodyweight. Protein keeps you full, builds muscle, and supports immune function.' }, { title:'Hydration Matters', body:'Drink water before each meal. Often thirst is mistaken for hunger. Aim for 8 glasses daily — more if you exercise.' }, { title:'Color Your Plate', body:'Try to include 3 different colors of vegetables or fruit in each meal. More color = more variety of vitamins and antioxidants.' }, { title:'Fiber = Gut Health', body:'Fiber feeds your good gut bacteria. Aim for 25g/day from fruits, veggies, beans, and whole grains.' }, ]; // diary: { breakfast:[], lunch:[], dinner:[], snack:[] } // each entry: { id, name, brand, cal, carbs, protein, fat, fiber, sodium, sugar, qty, unit } let nutDiary = { breakfast: [ { id:'b1', name:'Morning Berries & Green Protein Shake', brand:'Custom', cal:512, carbs:58.7, protein:55.5, fat:5.1, fiber:4.2, sodium:320, sugar:22, qty:1, unit:'serving' } ], lunch: [ { id:'l1', name:'Einsteins Smoke Salmon Cream Cheese', brand:'Einsteins', cal:90, carbs:2, protein:4, fat:7, fiber:0, sodium:210, sugar:1, qty:2, unit:'tbsp' } ], dinner: [], snack: [] }; let nutInited = false; function nutMacroCircum() { return 2 * Math.PI * 24; } // r=24 function nutRingDash(val, goal) { const c = nutMacroCircum(); const pct = Math.min(1, val / goal); return `${(pct * c).toFixed(1)} ${c.toFixed(1)}`; } function nutTotals() { const all = [...nutDiary.breakfast, ...nutDiary.lunch, ...nutDiary.dinner, ...nutDiary.snack]; return all.reduce((t, f) => { t.cal += f.cal || 0; t.carbs += f.carbs || 0; t.protein += f.protein || 0; t.fat += f.fat || 0; t.fiber += f.fiber || 0; t.sodium += f.sodium || 0; t.sugar += f.sugar || 0; return t; }, { cal:0, carbs:0, protein:0, fat:0, fiber:0, sodium:0, sugar:0 }); } function renderNutSummary() { const t = nutTotals(); const g = NUT_GOALS; const remaining = Math.max(0, g.cal - t.cal); const calPct = Math.min(100, Math.round(t.cal / g.cal * 100)); const setText = (id, v) => { const el = document.getElementById(id); if(el) el.textContent = v; }; setText('nut-remaining-cal', remaining.toLocaleString()); setText('nut-goal-lbl', g.cal.toLocaleString()); setText('nut-eaten-lbl', `${Math.round(t.cal).toLocaleString()} calories eaten`); const bar = document.getElementById('nut-cal-bar'); if(bar) { bar.style.width = calPct + '%'; bar.style.background = calPct > 100 ? '#ef4444' : calPct > 75 ? '#f59e0b' : '#22c55e'; } // Macro rings const macros = [ { key:'carbs', color:'#e91e8c', val:t.carbs, goal:g.carbs }, { key:'protein', color:'#2196f3', val:t.protein, goal:g.protein }, { key:'fat', color:'#ff6b35', val:t.fat, goal:g.fat }, { key:'fiber', color:'#22c55e', val:t.fiber, goal:g.fiber }, ]; macros.forEach(m => { const ring = document.getElementById(`nut-ring-${m.key}`); const pctEl = document.getElementById(`nut-${m.key}-pct`); const leftEl = document.getElementById(`nut-${m.key}-left`); const pct = Math.min(100, Math.round(m.val / m.goal * 100)); if(ring) ring.setAttribute('stroke-dasharray', nutRingDash(m.val, m.goal)); if(pctEl) pctEl.textContent = pct + '%'; if(leftEl) leftEl.textContent = `${Math.max(0, Math.round(m.goal - m.val))}g left`; }); // Micronutrients renderMicroNutrients(t); } function renderMicroNutrients(t) { const card = document.getElementById('nut-micro-card'); const grid = document.getElementById('nut-micro-grid'); if(!card || !grid) return; const all = [...nutDiary.breakfast, ...nutDiary.lunch, ...nutDiary.dinner, ...nutDiary.snack]; if(!all.length) { card.style.display = 'none'; return; } card.style.display = ''; const micros = [ { label:'Sodium', val: t.sodium, unit:'mg', goal:2300, color:'#6366f1' }, { label:'Sugar', val: t.sugar, unit:'g', goal:50, color:'#f59e0b' }, { label:'Fiber', val: t.fiber, unit:'g', goal:25, color:'#22c55e' }, { label:'Protein', val: t.protein, unit:'g', goal:95, color:'#2196f3' }, { label:'Total Carbs', val: t.carbs, unit:'g', goal:190, color:'#e91e8c' }, { label:'Total Fat', val: t.fat, unit:'g', goal:50, color:'#ff6b35' }, ]; grid.innerHTML = micros.map(m => { const pct = Math.min(100, Math.round(m.val / m.goal * 100)); return `
${m.label} ${Math.round(m.val)}${m.unit}
Goal: ${m.goal}${m.unit}
`; }).join(''); } function renderNutDiary() { const el = document.getElementById('nut-diary'); if(!el) return; const meals = [ { key:'breakfast', label:'Breakfast', icon:'🌅' }, { key:'lunch', label:'Lunch', icon:'☀️' }, { key:'dinner', label:'Dinner', icon:'🌙' }, { key:'snack', label:'Snack', icon:'🍎' }, ]; el.innerHTML = meals.map(m => { const entries = nutDiary[m.key]; const mealCal = entries.reduce((s,f) => s + (f.cal||0), 0); const pct = Math.round(mealCal / NUT_GOALS.cal * 100); return `
${m.icon} ${m.label}
${mealCal ? `
${Math.round(mealCal)} calories (${pct}%)
` : '
Nothing logged yet
'}
${entries.map(f => `
${nutFoodIcon(f.name)}
${f.name}
${Math.round(f.cal)} kcal · ${f.qty} ${f.unit}
C:${Math.round(f.carbs)}g · P:${Math.round(f.protein)}g · F:${Math.round(f.fat)}g
`).join('')}
`; }).join(''); } function nutFoodIcon(name) { const n = name.toLowerCase(); if(n.includes('banana') || n.includes('apple') || n.includes('fruit') || n.includes('berry')) return '🍎'; if(n.includes('chicken') || n.includes('beef') || n.includes('meat') || n.includes('salmon') || n.includes('fish')) return '🍗'; if(n.includes('shake') || n.includes('protein') || n.includes('whey')) return '🥤'; if(n.includes('egg')) return '🥚'; if(n.includes('milk') || n.includes('dairy') || n.includes('yogurt') || n.includes('cheese')) return '🥛'; if(n.includes('rice') || n.includes('bread') || n.includes('pasta') || n.includes('grain')) return '🍚'; if(n.includes('salad') || n.includes('veggie') || n.includes('vegetable') || n.includes('spinach') || n.includes('green')) return '🥗'; if(n.includes('coffee') || n.includes('tea')) return '☕'; if(n.includes('water')) return '💧'; return '🍽️'; } function removeNutEntry(meal, id) { nutDiary[meal] = nutDiary[meal].filter(f => f.id !== id); renderNutDiary(); renderNutSummary(); } function quickAddToMeal(meal) { document.getElementById('nut-meal-sel').value = meal; document.getElementById('nut-search-inp').focus(); document.getElementById('nut-search-inp').scrollIntoView({ behavior:'smooth', block:'center' }); } // ── Food Search via Open Food Facts ───────────────────────────────────────── async function searchFood() { const q = document.getElementById('nut-search-inp').value.trim(); if(!q) return; const results = document.getElementById('nut-search-results'); results.innerHTML = '
🔍 Searching Open Food Facts…
'; try { const url = `https://world.openfoodfacts.org/cgi/search.pl?search_terms=${encodeURIComponent(q)}&search_simple=1&action=process&json=1&page_size=8&fields=product_name,brands,nutriments,serving_size,serving_quantity`; const res = await fetch(url); const data = await res.json(); if(!data.products || !data.products.length) { results.innerHTML = '
No results found. Try a different search term.
'; return; } results.innerHTML = data.products .filter(p => p.product_name && p.nutriments) .slice(0, 8) .map((p, i) => { const n = p.nutriments; const cal = Math.round(n['energy-kcal_serving'] || n['energy-kcal_100g'] || 0); const carbs = +((n['carbohydrates_serving'] || n['carbohydrates_100g'] || 0)).toFixed(1); const protein = +((n['proteins_serving'] || n['proteins_100g'] || 0)).toFixed(1); const fat = +((n['fat_serving'] || n['fat_100g'] || 0)).toFixed(1); const fiber = +((n['fiber_serving'] || n['fiber_100g'] || 0)).toFixed(1); const sodium = Math.round(n['sodium_serving'] * 1000 || n['sodium_100g'] * 1000 || n['salt_serving'] * 400 || 0); const sugar = +((n['sugars_serving'] || n['sugars_100g'] || 0)).toFixed(1); const brand = p.brands ? p.brands.split(',')[0].trim() : ''; const serving = p.serving_size || '100g'; const safeId = 'sr_' + i + '_' + Date.now(); // store data in dataset for add const encoded = encodeURIComponent(JSON.stringify({ name:p.product_name, brand, cal, carbs, protein, fat, fiber, sodium, sugar, unit:serving })); return `
${nutFoodIcon(p.product_name)}
${p.product_name}
${brand ? `
${brand}
` : ''}
${cal} kcal · ${serving}
C:${carbs}g · P:${protein}g · F:${fat}g · Fiber:${fiber}g
`; }).join(''); } catch(e) { results.innerHTML = `
⚠️ Search failed. Check your connection and try again.
`; } } function addFoodFromSearch(encoded) { let f; try { f = typeof encoded === 'string' ? JSON.parse(decodeURIComponent(encoded)) : encoded; } catch(e) { return; } const meal = document.getElementById('nut-meal-sel').value || 'snack'; f.id = 'f_' + Date.now(); f.qty = 1; nutDiary[meal].push(f); renderNutDiary(); renderNutSummary(); document.getElementById('nut-search-results').innerHTML = `
✅ ${f.name} added to ${meal}!
`; // ── Supabase cloud sync ── if (typeof litSaveNutritionEntry !== 'undefined') litSaveNutritionEntry(meal, f); document.getElementById('nut-search-inp').value = ''; setTimeout(() => { const el=document.getElementById('nut-search-results'); if(el) el.innerHTML=''; }, 2500); } // ── Barcode Scanner (Open Food Facts) ──────────────────────────────────────── function openBarcodeScanner() { document.getElementById('nut-barcode-modal').style.display = 'flex'; setTimeout(() => document.getElementById('nut-barcode-inp').focus(), 100); } function closeBarcodeScanner() { document.getElementById('nut-barcode-modal').style.display = 'none'; document.getElementById('nut-barcode-inp').value = ''; document.getElementById('nut-barcode-result').innerHTML = ''; } async function lookupBarcode() { const code = document.getElementById('nut-barcode-inp').value.trim().replace(/\D/g,''); const res = document.getElementById('nut-barcode-result'); if(!code) { res.innerHTML='
Please enter a barcode number.
'; return; } res.innerHTML = '
🔍 Looking up barcode…
'; try { const r = await fetch(`https://world.openfoodfacts.org/api/v0/product/${code}.json`); const data = await r.json(); if(data.status !== 1 || !data.product) { res.innerHTML = `
❌ Product not found. Try another barcode.
`; return; } const p = data.product; const n = p.nutriments || {}; const cal = Math.round(n['energy-kcal_serving'] || n['energy-kcal_100g'] || 0); const carbs = +((n['carbohydrates_serving'] || n['carbohydrates_100g'] || 0)).toFixed(1); const protein = +((n['proteins_serving'] || n['proteins_100g'] || 0)).toFixed(1); const fat = +((n['fat_serving'] || n['fat_100g'] || 0)).toFixed(1); const fiber = +((n['fiber_serving'] || n['fiber_100g'] || 0)).toFixed(1); const sodium = Math.round(n['sodium_serving']*1000 || n['sodium_100g']*1000 || 0); const sugar = +((n['sugars_serving'] || n['sugars_100g'] || 0)).toFixed(1); const brand = p.brands ? p.brands.split(',')[0].trim() : ''; const serving = p.serving_size || '100g'; const foodObj = { name:p.product_name||'Unknown Product', brand, cal, carbs, protein, fat, fiber, sodium, sugar, unit:serving }; const encoded = encodeURIComponent(JSON.stringify(foodObj)); res.innerHTML = `
${p.image_small_url ? `` : ''}
${foodObj.name}
${brand ? `
${brand}
` : ''}
${cal} kcal · ${serving}
C:${carbs}g · P:${protein}g · F:${fat}g · Fiber:${fiber}g · Na:${sodium}mg
`; } catch(e) { res.innerHTML = `
⚠️ Lookup failed. Check your connection.
`; } } function initNutrition() { if(nutInited) { renderNutDiary(); renderNutSummary(); return; } nutInited = true; // Pick random tip const tip = NUT_TIPS[Math.floor(Math.random() * NUT_TIPS.length)]; const tt = document.getElementById('nut-tip-title'); if(tt) tt.textContent = tip.title; const tb = document.getElementById('nut-tip-body'); if(tb) tb.textContent = tip.body; renderNutDiary(); renderNutSummary(); } // ══════════════════════════════════════════════════════════════════════════════ // WEIGHT TRACKER // ══════════════════════════════════════════════════════════════════════════════ // Demo data only loads if user is NOT authenticated (see loadDemoWeightData below) var weightLog = []; var weightUnit = 'lbs'; var weightGoalData = { value: null, date: null }; function loadDemoWeightData() { // Only populate demo data when NOT logged in to Supabase if (typeof ffhLitSupabaseState !== 'undefined' && ffhLitSupabaseState.client && ffhLitSupabaseState.authenticated) return; weightLog = [ { date:'2026-02-01', value:178.2, unit:'lbs', notes:'' }, { date:'2026-02-08', value:177.0, unit:'lbs', notes:'Ran 3 miles' }, { date:'2026-02-15', value:175.5, unit:'lbs', notes:'' }, { date:'2026-02-22', value:174.8, unit:'lbs', notes:'Cut sugar' }, { date:'2026-03-01', value:173.2, unit:'lbs', notes:'Feeling great' }, { date:'2026-03-05', value:172.0, unit:'lbs', notes:'' }, ]; weightGoalData = { value: 165, date: '2026-06-01' }; } let weightChartInst = null; function setWtUnit(u) { weightUnit = u; document.getElementById('wt-unit-lbs').style.background = u==='lbs' ? 'var(--orange)' : '#fff'; document.getElementById('wt-unit-lbs').style.color = u==='lbs' ? '#fff' : 'var(--navy)'; document.getElementById('wt-unit-lbs').style.borderColor = u==='lbs' ? 'var(--orange)' : 'var(--light)'; document.getElementById('wt-unit-kg').style.background = u==='kg' ? 'var(--orange)' : '#fff'; document.getElementById('wt-unit-kg').style.color = u==='kg' ? '#fff' : 'var(--navy)'; document.getElementById('wt-unit-kg').style.borderColor = u==='kg' ? 'var(--orange)' : 'var(--light)'; } function lbsToKg(v){ return Math.round(v * 0.453592 * 10)/10; } function kgToLbs(v){ return Math.round(v * 2.20462 * 10)/10; } function logWeight() { const dateVal = document.getElementById('wt-date').value; const rawVal = parseFloat(document.getElementById('wt-value').value); const notes = document.getElementById('wt-notes').value.trim(); if(!dateVal || isNaN(rawVal) || rawVal <= 0){ toast('Enter a valid date and weight','#6b7a99'); return; } const entry = { date: dateVal, value: rawVal, unit: weightUnit, notes }; weightLog.push(entry); weightLog.sort((a,b) => a.date.localeCompare(b.date)); document.getElementById('wt-value').value = ''; document.getElementById('wt-notes').value = ''; renderWeightChart(); renderWeightStats(); renderWeightLog(); toast('⚖️ Weight logged!', '#16a34a'); // ── Supabase cloud sync (with diagnostics) ── const s = typeof ffhLitSupabaseState !== 'undefined' ? ffhLitSupabaseState : null; console.info('[FFH-LIT] logWeight sync check:', { stateExists: !!s, isConnected: s?.isConnected, consentGranted: s?.consentGranted, profileExists: s?.profileExists, userId: s?.userId ? s.userId.substring(0,8) + '...' : null, orgId: s?.organizationId ? s.organizationId.substring(0,8) + '...' : null }); if (s && s.isConnected && s.consentGranted) { window.ffhLitSupabase.saveWeightEntry(entry).then(ok => { if (ok) toast('☁️ Synced to cloud', '#16a34a'); else toast('⚠️ Cloud sync returned false — check console (F12)', '#f59e0b'); }).catch(e => { console.error('[FFH-LIT] Weight cloud sync EXCEPTION:', e); toast('⚠️ Cloud sync error — check console (F12)', '#dc2626'); }); } else if (s && s.isConnected && !s.consentGranted) { toast('⚠️ Accept the consent prompt to enable cloud saves', '#f59e0b'); } else { console.warn('[FFH-LIT] Weight NOT synced — state:', { connected: s?.isConnected, consent: s?.consentGranted }); } } function setWeightGoal() { const gVal = parseFloat(document.getElementById('wt-goal').value); const gDate = document.getElementById('wt-goal-date').value; if(isNaN(gVal) || !gDate){ toast('Enter goal weight and date','#6b7a99'); return; } weightGoalData = { value: gVal, date: gDate }; // ── Dr. Rob Coach: Unrealistic Timeline Detection ── drRobWeightGoalCheck(gVal, gDate); renderWeightStats(); renderWeightChart(); // Re-render chart with goal line toast('🎯 Goal set!','#16a34a'); // ── Supabase cloud sync ── if (ffhLitSupabaseState && ffhLitSupabaseState.isConnected && ffhLitSupabaseState.consentGranted) { window.ffhLitSupabase.saveWeightGoal({ value: gVal, unit: weightUnit, date: gDate }).then(ok => { if (ok) toast('☁️ Goal synced to cloud', '#16a34a'); else toast('⚠️ Goal sync issue — saved locally only', '#f59e0b'); }).catch(e => { console.warn('[FFH-LIT] Weight goal cloud sync error:', e); toast('⚠️ Goal sync error — saved locally only', '#f59e0b'); }); } else if (ffhLitSupabaseState && ffhLitSupabaseState.isConnected && !ffhLitSupabaseState.consentGranted) { toast('⚠️ Accept the consent prompt to enable cloud saves', '#f59e0b'); } } // ══════════════════════════════════════════════════════════════════════════════ var ROB_AVATAR = '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'; // DR. ROB COACH TOOLTIP — Weight Goal Advisor // Evidence-based safe rate: 0.5–1.0 kg/week (1–2 lbs/week) per NIH/CDC guidance // Flags goals >2 lbs/week as aggressive, >3 lbs/week as unrealistic/unsafe // ══════════════════════════════════════════════════════════════════════════════ function drRobWeightGoalCheck(goalValue, goalDate) { // Remove any existing tooltip const old = document.getElementById('dr-rob-weight-tip'); if (old) old.remove(); if (!weightLog.length) return; // Need a current weight to assess const currentWeight = displayWeight(weightLog[weightLog.length - 1]); const diff = currentWeight - goalValue; // positive = weight loss goal if (diff <= 0) return; // Weight gain or maintenance goals — no flag needed const today = new Date(); const target = new Date(goalDate + 'T12:00'); const weeksAvailable = Math.max(1, (target - today) / (7 * 24 * 60 * 60 * 1000)); const lbsPerWeek = weightUnit === 'kg' ? kgToLbs(diff) / weeksAvailable : diff / weeksAvailable; let level = 'safe'; // green let icon = '✅'; let title = 'Looks great!'; let msg = ''; let color = '#16a34a'; let bg = '#f0fdf4'; let border = '#86efac'; if (lbsPerWeek <= 2.0) { // Safe range: 1-2 lbs/week (CDC/NIH recommended) msg = `Your goal of losing ${Math.round(diff*10)/10} ${weightUnit} in ${Math.round(weeksAvailable)} weeks works out to about ${lbsPerWeek.toFixed(1)} lbs/week — right in the evidence-based safe zone (1–2 lbs/week per CDC guidelines). Sustainable and achievable!`; } else if (lbsPerWeek <= 3.0) { // Aggressive but possible with supervision level = 'caution'; icon = '⚠️'; title = 'That\'s an aggressive timeline'; color = '#d97706'; bg = '#fffbeb'; border = '#fcd34d'; msg = `This goal requires losing about ${lbsPerWeek.toFixed(1)} lbs/week, which exceeds the CDC-recommended 1–2 lbs/week. Rapid weight loss can lead to muscle loss, nutritional deficiencies, and metabolic slowdown. Consider extending your target date by ${Math.ceil((diff / 2) - weeksAvailable)} more weeks to stay in the safe range.`; } else { // Unrealistic / unsafe level = 'warning'; icon = '🚨'; title = 'This timeline may not be safe'; color = '#dc2626'; bg = '#fef2f2'; border = '#fca5a5'; const safeWeeks = Math.ceil(diff / 2); // 2 lbs/week = safe max const safeDate = new Date(today.getTime() + safeWeeks * 7 * 24 * 60 * 60 * 1000); const safeDateStr = safeDate.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }); msg = `This goal would require losing ${lbsPerWeek.toFixed(1)} lbs/week — well beyond what medical guidelines consider safe. The NIH and CDC recommend no more than 1–2 lbs/week for sustainable, healthy weight loss. Faster rates are associated with gallstones, muscle loss, and rebound weight gain. A safe target date would be around ${safeDateStr} (${safeWeeks} weeks at 2 lbs/week max).`; } // Build the Dr. Rob tooltip const tip = document.createElement('div'); tip.id = 'dr-rob-weight-tip'; tip.style.cssText = `margin-top:16px;background:${bg};border:1.5px solid ${border};border-radius:12px;padding:14px 16px;position:relative;animation:drRobFadeIn .4s ease;`; tip.innerHTML = `
Dr. Rob
Dr. Rob HEALTH COACH ${icon}
${title}
${msg}
Based on CDC/NIH evidence-based weight management guidelines · Not medical advice — consult your physician for personalized guidance.
`; // Insert after goal progress bar const goalCard = document.getElementById('wt-goal-progress')?.parentElement; if (goalCard) { goalCard.appendChild(tip); } } function displayWeight(entry) { if(weightUnit === 'lbs') return entry.unit === 'lbs' ? entry.value : kgToLbs(entry.value); return entry.unit === 'kg' ? entry.value : lbsToKg(entry.value); } function renderWeightStats() { if(!weightLog.length) return; const vals = weightLog.map(e => displayWeight(e)); const latest = vals[vals.length-1]; const prev = vals.length > 1 ? vals[vals.length-2] : latest; const change = Math.round((latest - prev)*10)/10; const low = Math.min(...vals), high = Math.max(...vals); const statsEl = document.getElementById('wt-stats-row'); if(!statsEl) return; const u = weightUnit; statsEl.innerHTML = `
${latest}${u}
Current
${change>0?'+':''}${change}${u}
Since Last
${low}${u}
Lowest
${high}${u}
Highest
`; // Goal progress if(weightGoalData.value) { const startVal = vals[0], goalVal = parseFloat(document.getElementById('wt-goal').value || weightGoalData.value); const pct = Math.min(100, Math.max(0, Math.round(Math.abs(startVal-latest)/Math.abs(startVal-goalVal)*100))); const gp = document.getElementById('wt-goal-progress'); if(gp){ gp.style.display='block'; } const bar = document.getElementById('wt-goal-bar'); if(bar) bar.style.width = pct+'%'; const sl = document.getElementById('wt-goal-start-lbl'); if(sl) sl.textContent = `Start: ${startVal} ${weightUnit}`; const el = document.getElementById('wt-goal-end-lbl'); if(el) el.textContent = `Goal: ${weightGoalData.value} ${weightUnit}`; const cap = document.getElementById('wt-goal-caption'); if(cap) cap.textContent = `${pct}% of the way there · ${Math.abs(Math.round((latest-weightGoalData.value)*10)/10)} ${weightUnit} to go`; } } var weightChartShowActual = true; var weightChartShowGoal = true; function renderWeightChart() { const labels = weightLog.map(e => { const d=new Date(e.date+'T12:00'); return d.toLocaleDateString('en-US',{month:'short',day:'numeric'}); }); const data = weightLog.map(e => displayWeight(e)); const ctx = document.getElementById('ch-weight'); if(!ctx) return; if(weightChartInst) weightChartInst.destroy(); // Build datasets const datasets = []; // Actual weight line if (weightChartShowActual) { datasets.push({ label: 'Actual Weight', data: data, borderColor: '#e8450a', backgroundColor: '#e8450a18', tension: .4, fill: true, pointRadius: 5, pointBackgroundColor: '#e8450a', pointBorderColor: '#fff', pointBorderWidth: 2, order: 1 }); } // Goal weight line (horizontal dashed line across all dates) if (weightChartShowGoal && weightGoalData.value && labels.length > 0) { const goalVal = parseFloat(weightGoalData.value); datasets.push({ label: 'Goal: ' + goalVal + ' ' + weightUnit, data: labels.map(() => goalVal), borderColor: '#16a34a', backgroundColor: '#16a34a12', borderWidth: 2, borderDash: [8, 4], tension: 0, fill: false, pointRadius: 0, pointHoverRadius: 4, pointBackgroundColor: '#16a34a', order: 2 }); } weightChartInst = new Chart(ctx, { type: 'line', data: { labels, datasets }, options: { plugins: { legend: { display: false }, tooltip: { callbacks: { label: function(context) { return context.dataset.label + ': ' + context.parsed.y + ' ' + weightUnit; } } } }, scales: { y: { ticks: { font: { size: 10 } }, grid: { color: '#f4f6fa' } } }, responsive: true, interaction: { mode: 'index', intersect: false } } }); // Render toggle buttons renderWeightChartToggles(); } function renderWeightChartToggles() { let toggleContainer = document.getElementById('wt-chart-toggles'); if (!toggleContainer) { // Insert toggles above the chart const chartCard = document.getElementById('ch-weight')?.closest('.card') || document.getElementById('ch-weight')?.parentElement; if (!chartCard) return; toggleContainer = document.createElement('div'); toggleContainer.id = 'wt-chart-toggles'; toggleContainer.style.cssText = 'display:flex;gap:8px;margin-bottom:10px;flex-wrap:wrap;'; const heading = chartCard.querySelector('h3') || chartCard.querySelector('div > span'); if (heading && heading.parentElement) { heading.parentElement.insertBefore(toggleContainer, heading.nextSibling); } else { chartCard.insertBefore(toggleContainer, chartCard.firstChild); } } const hasGoal = weightGoalData.value != null && weightGoalData.value !== ''; toggleContainer.innerHTML = ` ${hasGoal ? `` : ''} `; } function renderWeightLog() { const el = document.getElementById('wt-log-table'); if(!el) return; if(!weightLog.length){ el.innerHTML='
No entries yet.
'; return; } el.innerHTML = [...weightLog].reverse().map((e, rIdx) => { const idx = weightLog.length - 1 - rIdx; // real index in weightLog const val = displayWeight(e); const d = new Date(e.date+'T12:00').toLocaleDateString('en-US',{month:'short',day:'numeric',year:'numeric'}); return `
${d}
${val} ${weightUnit}
${e.notes?`
${e.notes}
`:''}
`; }).join(''); } function editWeightEntry(idx) { const entry = weightLog[idx]; if (!entry) return; const row = document.getElementById('wt-row-' + idx); if (!row) return; const val = displayWeight(entry); row.innerHTML = `
${weightUnit}
`; } function saveWeightEdit(idx) { const newDate = document.getElementById('wt-edit-date-' + idx)?.value; const newVal = parseFloat(document.getElementById('wt-edit-val-' + idx)?.value); const newNotes = document.getElementById('wt-edit-notes-' + idx)?.value?.trim() || ''; if (!newDate || isNaN(newVal) || newVal <= 0) { toast('Enter a valid date and weight', '#6b7a99'); return; } weightLog[idx] = { date: newDate, value: newVal, unit: weightUnit, notes: newNotes }; weightLog.sort((a,b) => a.date.localeCompare(b.date)); renderWeightChart(); renderWeightStats(); renderWeightLog(); toast('✏️ Entry updated!', '#16a34a'); // Sync to Supabase if (ffhLitSupabaseState && ffhLitSupabaseState.isConnected && ffhLitSupabaseState.consentGranted) { window.ffhLitSupabase.saveWeightEntry(weightLog[idx] || { date: newDate, value: newVal, unit: weightUnit, notes: newNotes }).catch(e => console.warn('[FFH-LIT] Weight edit sync error:', e)); } } function deleteWeightEntry(idx) { if (!confirm('Delete this weight entry? This cannot be undone.')) return; weightLog.splice(idx, 1); renderWeightChart(); renderWeightStats(); renderWeightLog(); toast('🗑️ Entry deleted', '#6b7a99'); } function initWeightTracker() { // Set todays date default const todayStr = new Date().toISOString().split('T')[0]; const dateEl = document.getElementById('wt-date'); if(dateEl && !dateEl.value) dateEl.value = todayStr; const goalDateEl = document.getElementById('wt-goal-date'); if(goalDateEl && !goalDateEl.value) goalDateEl.value = weightGoalData.date; const goalEl = document.getElementById('wt-goal'); if(goalEl && !goalEl.value) goalEl.value = weightGoalData.value; renderWeightChart(); renderWeightStats(); renderWeightLog(); } // ══════════════════════════════════════════════════════════════════════════════ // QUICK-LOG FLOATING WIDGET (Mobile-first weight entry) // ══════════════════════════════════════════════════════════════════════════════ var quickLogOpen = false; var qlShowDatePicker = false; function initQuickLogWidget() { if (document.getElementById('ffh-quicklog-fab')) return; // Weight scale icon from Brand Assets (resized 56px) const scaleIconSVG = ''; // Floating Action Button — bottom right const fab = document.createElement('button'); fab.id = 'ffh-quicklog-fab'; fab.setAttribute('aria-label', 'Quick log weight'); fab.innerHTML = scaleIconSVG; fab.style.cssText = 'position:fixed;bottom:24px;right:24px;z-index:9998;background:linear-gradient(135deg,#1e3a6e,#2d5aa0);color:#fff;border:none;border-radius:50%;width:56px;height:56px;cursor:pointer;display:flex;align-items:center;justify-content:center;box-shadow:0 4px 16px rgba(30,58,110,.45);transition:all .2s ease;-webkit-tap-highlight-color:transparent;'; fab.onmouseenter = function(){ this.style.transform='scale(1.08)'; }; fab.onmouseleave = function(){ this.style.transform='scale(1)'; }; fab.onclick = toggleQuickLog; document.body.appendChild(fab); // Quick-Log Panel — bottom right, above FAB const panel = document.createElement('div'); panel.id = 'ffh-quicklog-panel'; panel.style.cssText = 'position:fixed;bottom:90px;right:16px;z-index:9998;background:#fff;border-radius:16px;box-shadow:0 8px 32px rgba(0,0,0,.2);padding:0;width:260px;max-width:calc(100vw - 32px);transform:scale(0.8) translateY(20px);opacity:0;pointer-events:none;transition:all .25s cubic-bezier(.4,0,.2,1);transform-origin:bottom right;overflow:hidden;'; panel.innerHTML = buildQuickLogHTML(); document.body.appendChild(panel); } function buildQuickLogHTML() { const todayStr = new Date().toISOString().split('T')[0]; const todayLabel = new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); return `
Quick Log
Today
lbs
`; } function toggleQLDate() { qlShowDatePicker = !qlShowDatePicker; const dateInput = document.getElementById('ql-date'); const todayBadge = document.getElementById('ql-today-badge'); if (!dateInput) return; if (qlShowDatePicker) { dateInput.style.display = 'block'; if (todayBadge) todayBadge.style.display = 'none'; } else { dateInput.style.display = 'none'; // Reset to today const todayStr = new Date().toISOString().split('T')[0]; dateInput.value = todayStr; updateQLDateLabel(); if (todayBadge) todayBadge.style.display = ''; } } function updateQLDateLabel() { const dateInput = document.getElementById('ql-date'); const label = document.getElementById('ql-date-label'); const todayBadge = document.getElementById('ql-today-badge'); if (!dateInput || !label) return; const d = new Date(dateInput.value + 'T12:00'); label.textContent = d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); const todayStr = new Date().toISOString().split('T')[0]; if (todayBadge) { todayBadge.style.display = dateInput.value === todayStr ? '' : 'none'; } } function toggleQuickLog() { quickLogOpen = !quickLogOpen; const panel = document.getElementById('ffh-quicklog-panel'); const fab = document.getElementById('ffh-quicklog-fab'); if (!panel || !fab) return; const scaleIconSVG = ''; if (quickLogOpen) { // Refresh panel HTML with current date panel.innerHTML = buildQuickLogHTML(); qlShowDatePicker = false; panel.style.transform = 'scale(1) translateY(0)'; panel.style.opacity = '1'; panel.style.pointerEvents = 'auto'; fab.innerHTML = ''; fab.style.background = '#475569'; fab.style.boxShadow = '0 4px 16px rgba(71,85,105,.4)'; // Update unit label and auto-focus const unitEl = document.getElementById('ql-unit'); if (unitEl) unitEl.textContent = weightUnit || 'lbs'; const input = document.getElementById('ql-weight'); if (input) setTimeout(() => input.focus(), 150); } else { panel.style.transform = 'scale(0.8) translateY(20px)'; panel.style.opacity = '0'; panel.style.pointerEvents = 'none'; fab.innerHTML = scaleIconSVG; fab.style.background = 'linear-gradient(135deg,#1e3a6e,#2d5aa0)'; fab.style.boxShadow = '0 4px 16px rgba(30,58,110,.45)'; } } function quickLogSubmit() { const input = document.getElementById('ql-weight'); const dateInput = document.getElementById('ql-date'); const feedback = document.getElementById('ql-feedback'); const rawVal = parseFloat(input?.value); if (isNaN(rawVal) || rawVal <= 0) { if (feedback) feedback.innerHTML = 'Enter a valid weight'; if (input) input.focus(); return; } const dateStr = dateInput?.value || new Date().toISOString().split('T')[0]; const entry = { date: dateStr, value: rawVal, unit: weightUnit, notes: 'Quick log' }; // Check for duplicate date — update if exists const existing = weightLog.findIndex(e => e.date === dateStr); if (existing >= 0) { weightLog[existing] = entry; } else { weightLog.push(entry); } weightLog.sort((a, b) => a.date.localeCompare(b.date)); // Update all UIs renderWeightChart(); renderWeightStats(); renderWeightLog(); // Supabase sync if (ffhLitSupabaseState && ffhLitSupabaseState.isConnected && ffhLitSupabaseState.consentGranted) { window.ffhLitSupabase.saveWeightEntry(entry).then(ok => { if (ok && feedback) feedback.innerHTML = '☁️ Synced'; }).catch(e => console.warn('[FFH-LIT] Quick log sync error:', e)); } // Update sync count const countEl = document.getElementById('ffh-sync-count'); if (countEl) countEl.textContent = weightLog.length; // Success feedback const dLabel = new Date(dateStr + 'T12:00').toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); if (feedback) feedback.innerHTML = '✅ ' + rawVal + ' ' + weightUnit + ' · ' + dLabel + ''; if (input) input.value = ''; toast('⚡ ' + rawVal + ' ' + weightUnit + ' logged!', '#16a34a'); // Auto-close after a moment setTimeout(() => { if (quickLogOpen) toggleQuickLog(); }, 1800); } // Initialize the widget on page load setTimeout(initQuickLogWidget, 500); // ══════════════════════════════════════════════════════════════════════════════ // BMI CALCULATOR // ══════════════════════════════════════════════════════════════════════════════ let bmiUnit = 'imperial'; let bmiHistChart = null; function setBmiUnit(u) { bmiUnit = u; document.getElementById('bmi-imperial-inputs').style.display = u==='imperial' ? '' : 'none'; document.getElementById('bmi-metric-inputs').style.display = u==='metric' ? '' : 'none'; document.getElementById('bmi-imperial-btn').style.background = u==='imperial' ? 'var(--navy)' : 'transparent'; document.getElementById('bmi-imperial-btn').style.color = u==='imperial' ? '#fff' : 'var(--mid)'; document.getElementById('bmi-metric-btn').style.background = u==='metric' ? 'var(--navy)' : 'transparent'; document.getElementById('bmi-metric-btn').style.color = u==='metric' ? '#fff' : 'var(--mid)'; calcBMI(); } function calcBMI() { let heightM, weightKg; if(bmiUnit === 'imperial') { const ft = parseFloat(document.getElementById('bmi-ft')?.value || 5); const ins = parseFloat(document.getElementById('bmi-in')?.value || 8); const lbs = parseFloat(document.getElementById('bmi-lbs')?.value || 160); heightM = (ft * 12 + ins) * 0.0254; weightKg = lbs * 0.453592; } else { heightM = parseFloat(document.getElementById('bmi-cm')?.value || 172) / 100; weightKg = parseFloat(document.getElementById('bmi-kg')?.value || 72); } if(!heightM || heightM < 0.5 || !weightKg) return; const bmi = Math.round((weightKg / (heightM * heightM)) * 10) / 10; const numEl = document.getElementById('bmi-number'); const catEl = document.getElementById('bmi-category'); const boxEl = document.getElementById('bmi-result-box'); const tipsEl= document.getElementById('bmi-tips'); const rangeEl = document.getElementById('bmi-healthy-range'); const rangeText = document.getElementById('bmi-range-text'); if(!numEl) return; numEl.textContent = bmi; let cat, color, tips, pct; if(bmi < 18.5) { cat='Underweight'; color='#3b82f6'; tips='Consider speaking with a healthcare provider about healthy weight gain through balanced nutrition and strength training.'; pct = (bmi/18.5)*20; } else if(bmi < 25) { cat='Normal weight'; color='#22c55e'; tips='Great work! Maintain your healthy weight with regular physical activity and balanced nutrition.'; pct = 20 + ((bmi-18.5)/(25-18.5))*30; } else if(bmi < 30) { cat='Overweight'; color='#f59e0b'; tips='Modest lifestyle changes — increasing activity and improving diet — can help reach a healthy weight range.'; pct = 50 + ((bmi-25)/5)*20; } else { cat='Obese'; color='#ef4444'; tips='Speak with your healthcare provider about a comprehensive weight management plan. Small consistent steps make a big difference.'; pct = Math.min(95, 70 + ((bmi-30)/10)*25); } catEl.textContent = cat; catEl.style.color = color; numEl.style.color = color; boxEl.style.background = color + '18'; tipsEl.textContent = tips; tipsEl.style.display = ''; // Healthy weight range const minKg = 18.5*(heightM*heightM), maxKg = 24.9*(heightM*heightM); if(rangeText) { if(bmiUnit === 'imperial') { rangeText.textContent = `${Math.round(minKg*2.20462)} – ${Math.round(maxKg*2.20462)} lbs`; } else { rangeText.textContent = `${Math.round(minKg*10)/10} – ${Math.round(maxKg*10)/10} kg`; } } if(rangeEl) rangeEl.style.display = ''; // Marker position const marker = document.getElementById('bmi-marker'); if(marker) { marker.style.left = pct+'%'; marker.style.color = color; } } function renderBMIHistory() { // Build from weightLog const entries = weightLog.filter(e => e.value > 0); if(!entries.length) return; const labels = entries.map(e => { const d=new Date(e.date+'T12:00'); return d.toLocaleDateString('en-US',{month:'short',day:'numeric'}); }); const data = entries.map(e => { const wKg = e.unit==='kg' ? e.value : e.value*0.453592; // Use last known height — default 5'8" = 1.727m const h = 1.727; return Math.round(wKg/(h*h)*10)/10; }); const ctx = document.getElementById('ch-bmi-hist'); if(!ctx) return; if(bmiHistChart) bmiHistChart.destroy(); bmiHistChart = new Chart(ctx, { type:'line', data:{ labels, datasets:[{ data, borderColor:'#6366f1', backgroundColor:'#6366f118', tension:.4, fill:true, pointRadius:4, pointBackgroundColor:'#6366f1' },{ data: labels.map(()=>18.5), borderColor:'#22c55e', borderDash:[4,4], pointRadius:0, borderWidth:1.5, fill:false, label:'Healthy min' },{ data: labels.map(()=>24.9), borderColor:'#f59e0b', borderDash:[4,4], pointRadius:0, borderWidth:1.5, fill:false, label:'Healthy max' }]}, options:{ plugins:{legend:{display:true,labels:{font:{size:10}}}}, scales:{y:{min:15,max:35,ticks:{font:{size:10}}}}, responsive:true } }); } function initBMICalc() { calcBMI(); } // ══════════════════════════════════════════════════════════════════════════════ // PAIN TRACKER // ══════════════════════════════════════════════════════════════════════════════ // Demo data only loads if user is NOT authenticated (see loadDemoPainData below) var painLog = []; function loadDemoPainData() { if (typeof ffhLitSupabaseState !== 'undefined' && ffhLitSupabaseState.client && ffhLitSupabaseState.authenticated) return; painLog = [ { datetime:'2026-03-01T08:30', location:'Back', level:6, type:'Aching', notes:'Lower back after long drive' }, { datetime:'2026-03-02T14:00', location:'Head', level:4, type:'Throbbing', notes:'Tension headache' }, { datetime:'2026-03-03T09:15', location:'Knee', level:3, type:'Sharp', notes:'After morning run' }, { datetime:'2026-03-04T19:30', location:'Back', level:5, type:'Stiffness', notes:'' }, { datetime:'2026-03-05T07:00', location:'Head', level:2, type:'Pressure', notes:'Slept poorly' }, ]; } let selectedPainLoc = ''; let selectedPainType = ''; let selectedPainLevel= 5; let painChartInst = null; const PAIN_COLORS = [ '#22c55e','#86efac','#fde68a','#fcd34d','#fbbf24', '#fb923c','#f97316','#ef4444','#dc2626','#991b1b','#7f1d1d' ]; function selectPainLoc(btn, loc) { document.querySelectorAll('#pain-location-btns .pain-loc-btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); selectedPainLoc = loc; document.getElementById('pain-location-val').value = loc; } function selectPainType(btn, type) { document.querySelectorAll('#pain-type-btns .pain-loc-btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); selectedPainType = type; document.getElementById('pain-type-val').value = type; } function buildPainScaleBtns() { const wrap = document.getElementById('pain-scale-btns'); if(!wrap || wrap.children.length > 0) return; for(let i=0; i<=10; i++) { const btn = document.createElement('button'); btn.className = 'pain-scale-btn' + (i === selectedPainLevel ? ' active' : ''); btn.textContent = i; btn.style.background = i === selectedPainLevel ? PAIN_COLORS[i] : '#fff'; btn.onclick = function() { selectedPainLevel = i; document.querySelectorAll('.pain-scale-btn').forEach((b,idx) => { b.classList.toggle('active', idx===i); b.style.background = idx===i ? PAIN_COLORS[idx] : '#fff'; b.style.color = idx===i ? '#fff' : 'var(--navy)'; }); document.getElementById('pain-level-lbl').textContent = i; document.getElementById('pain-level-lbl').style.color = PAIN_COLORS[i]; }; if(i === selectedPainLevel) { btn.style.color='#fff'; } wrap.appendChild(btn); } document.getElementById('pain-level-lbl').style.color = PAIN_COLORS[selectedPainLevel]; } function logPain() { const dt = document.getElementById('pain-datetime').value; const notes = document.getElementById('pain-notes').value.trim(); if(!dt) { toast('Select date and time','#6b7a99'); return; } if(!selectedPainLoc) { toast('Select a body location','#6b7a99'); return; } if(!selectedPainType) { toast('Select a pain type','#6b7a99'); return; } painLog.push({ datetime:dt, location:selectedPainLoc, level:selectedPainLevel, type:selectedPainType, notes }); painLog.sort((a,b) => a.datetime.localeCompare(b.datetime)); document.getElementById('pain-notes').value = ''; renderPainChart(); renderPainStats(); renderPainHeatmap(); renderPainLog(); updatePainFilterOptions(); toast('🩹 Pain entry logged','#16a34a'); // Sync to Supabase if (typeof litSavePainEntry !== 'undefined') litSavePainEntry({ datetime:dt, location:selectedPainLoc, level:selectedPainLevel, type:selectedPainType, notes }); } function renderPainChart() { const ctx = document.getElementById('ch-pain'); if(!ctx) return; const now = new Date('2026-03-05'); const days = []; const data = []; for(let i=13; i>=0; i--) { const d = new Date(now); d.setDate(d.getDate()-i); const ds = d.toISOString().split('T')[0]; days.push(d.toLocaleDateString('en-US',{month:'short',day:'numeric'})); const dayEntries = painLog.filter(e => e.datetime.startsWith(ds)); data.push(dayEntries.length ? Math.max(...dayEntries.map(e=>e.level)) : null); } if(painChartInst) painChartInst.destroy(); painChartInst = new Chart(ctx, { type:'line', data:{ labels:days, datasets:[{ data, borderColor:'#ef4444', backgroundColor:'#ef444418', tension:.3, fill:true, pointRadius:5, pointBackgroundColor:data.map(v => v===null?'transparent':PAIN_COLORS[Math.min(v,10)]), pointBorderColor:'#fff', pointBorderWidth:2, spanGaps:false }]}, options:{ plugins:{legend:{display:false}}, scales:{y:{min:0,max:10,ticks:{font:{size:10},stepSize:2},grid:{color:'#f4f6fa'}}}, responsive:true } }); } function renderPainStats() { const el = document.getElementById('pain-stats-row'); if(!el || !painLog.length) return; const avg = Math.round(painLog.reduce((s,e)=>s+e.level,0)/painLog.length*10)/10; const max = Math.max(...painLog.map(e=>e.level)); const total = painLog.length; const locs = [...new Set(painLog.map(e=>e.location))].length; el.innerHTML = `
${avg}
Avg Level
${max}
Peak Level
${total}
Total Entries
${locs}
Body Areas
`; } function renderPainHeatmap() { const el = document.getElementById('pain-heatmap'); if(!el) return; const locMap = {}; painLog.forEach(e => { if(!locMap[e.location]) locMap[e.location] = { total:0, count:0 }; locMap[e.location].total += e.level; locMap[e.location].count++; }); const sorted = Object.entries(locMap).sort((a,b)=>(b[1].total/b[1].count)-(a[1].total/a[1].count)); const maxAvg = Math.max(...sorted.map(([,v])=>v.total/v.count)); el.innerHTML = sorted.map(([loc, v]) => { const avg = Math.round(v.total/v.count*10)/10; const pct = Math.round(avg/maxAvg*100); const col = PAIN_COLORS[Math.min(Math.round(avg),10)]; return `
${loc}
${avg}
${v.count} entr${v.count===1?'y':'ies'}
`; }).join('') || '
No data yet.
'; } function updatePainFilterOptions() { const sel = document.getElementById('pain-filter-loc'); if(!sel) return; const existing = Array.from(sel.options).map(o=>o.value); [...new Set(painLog.map(e=>e.location))].forEach(loc => { if(!existing.includes(loc)) { const o=document.createElement('option'); o.value=loc; o.textContent=loc; sel.appendChild(o); } }); } function renderPainLog() { const el = document.getElementById('pain-log-list'); const sel = document.getElementById('pain-filter-loc'); if(!el) return; const filterLoc = sel?.value || 'all'; let entries = [...painLog].reverse(); if(filterLoc !== 'all') entries = entries.filter(e => e.location === filterLoc); if(!entries.length){ el.innerHTML='
No entries.
'; return; } el.innerHTML = entries.map((e, rIdx) => { const realIdx = painLog.length - 1 - painLog.slice().reverse().indexOf(e); const dt = new Date(e.datetime).toLocaleString('en-US',{month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}); const col = PAIN_COLORS[Math.min(e.level,10)]; return `
${e.level}
${e.location} ${e.type} ${dt}
${e.notes?`
${e.notes}
`:''}
`; }).join(''); } function editPainEntry(idx) { const entry = painLog[idx]; if (!entry) return; const row = document.getElementById('pain-row-' + idx); if (!row) return; row.innerHTML = `
Level:
`; } function savePainEdit(idx) { const dt = document.getElementById('pain-edit-dt-' + idx)?.value; const level = parseInt(document.getElementById('pain-edit-level-' + idx)?.value); const notes = document.getElementById('pain-edit-notes-' + idx)?.value?.trim() || ''; if (!dt || isNaN(level)) { toast('Enter valid date and level', '#6b7a99'); return; } painLog[idx].datetime = dt; painLog[idx].level = Math.max(0, Math.min(10, level)); painLog[idx].notes = notes; painLog.sort((a,b) => a.datetime.localeCompare(b.datetime)); renderPainChart(); renderPainStats(); renderPainHeatmap(); renderPainLog(); toast('✏️ Pain entry updated!', '#16a34a'); } function deletePainEntry(idx) { if (!confirm('Delete this pain entry? This cannot be undone.')) return; painLog.splice(idx, 1); renderPainChart(); renderPainStats(); renderPainHeatmap(); renderPainLog(); updatePainFilterOptions(); toast('🗑️ Entry deleted', '#6b7a99'); } function initPainTracker() { // Set default datetime const dtEl = document.getElementById('pain-datetime'); if(dtEl && !dtEl.value) { const now = new Date(); now.setMinutes(now.getMinutes() - now.getTimezoneOffset()); dtEl.value = now.toISOString().slice(0,16); } buildPainScaleBtns(); renderPainChart(); renderPainStats(); renderPainHeatmap(); renderPainLog(); updatePainFilterOptions(); } // ══════════════════════════════════════════════════════════════════════════════ // SCREEN TIME TRACKER // ══════════════════════════════════════════════════════════════════════════════ const ST_CATS = { Social: { color:'#6366f1', icon:'💬' }, Entertainment: { color:'#e8450a', icon:'🎬' }, Health: { color:'#16a34a', icon:'💚' }, Productivity: { color:'#0ea5e9', icon:'💼' }, Other: { color:'#6b7a99', icon:'📱' }, }; // Seeded app data (minutes today) const ST_APPS = [ { name:'Instagram', cat:'Social', mins:42, icon:'📸' }, { name:'YouTube', cat:'Entertainment', mins:68, icon:'▶️' }, { name:'Force for Health', cat:'Health', mins:35, icon:'💚' }, { name:'Messages', cat:'Social', mins:28, icon:'💬' }, { name:'Chrome', cat:'Productivity', mins:24, icon:'🌐' }, { name:'TikTok', cat:'Entertainment', mins:51, icon:'🎵' }, { name:'Email', cat:'Productivity', mins:18, icon:'📧' }, { name:'Maps', cat:'Other', mins:8, icon:'🗺️' }, { name:'Spotify', cat:'Entertainment', mins:22, icon:'🎧' }, { name:'Slack', cat:'Productivity', mins:15, icon:'💼' }, ]; // 14-day history (total minutes per day) — last entry is today let stHistory = [ { date:'2026-02-20', total:{ Social:60, Entertainment:90, Health:20, Productivity:45, Other:15 } }, { date:'2026-02-21', total:{ Social:80, Entertainment:120,Health:25, Productivity:30, Other:10 } }, { date:'2026-02-22', total:{ Social:45, Entertainment:60, Health:40, Productivity:60, Other:5 } }, { date:'2026-02-23', total:{ Social:70, Entertainment:95, Health:30, Productivity:50, Other:12 } }, { date:'2026-02-24', total:{ Social:55, Entertainment:110,Health:20, Productivity:35, Other:8 } }, { date:'2026-02-25', total:{ Social:90, Entertainment:130,Health:15, Productivity:25, Other:20 } }, { date:'2026-02-26', total:{ Social:65, Entertainment:80, Health:35, Productivity:55, Other:10 } }, { date:'2026-02-27', total:{ Social:50, Entertainment:70, Health:45, Productivity:65, Other:8 } }, { date:'2026-02-28', total:{ Social:75, Entertainment:100,Health:30, Productivity:40, Other:15 } }, { date:'2026-03-01', total:{ Social:85, Entertainment:115,Health:25, Productivity:45, Other:18 } }, { date:'2026-03-02', total:{ Social:60, Entertainment:90, Health:35, Productivity:55, Other:10 } }, { date:'2026-03-03', total:{ Social:55, Entertainment:75, Health:40, Productivity:60, Other:7 } }, { date:'2026-03-04', total:{ Social:72, Entertainment:105,Health:28, Productivity:42, Other:13 } }, { date:'2026-03-05', total:{ Social:70, Entertainment:141,Health:35, Productivity:57, Other:8 } }, // today ]; let stGoalHrs = 4; let stDonutChart = null, stWeeklyChart = null, stTrendChart = null; let stAppFilter = 'all'; function minsToHrMin(m) { if(m < 60) return m + 'm'; return Math.floor(m/60) + 'h ' + (m%60 ? (m%60)+'m' : ''); } function filterStApps(cat, btn) { stAppFilter = cat; document.querySelectorAll('#st-cat-filter .pain-loc-btn').forEach(b => b.classList.remove('active')); if(btn) btn.classList.add('active'); renderStAppList(); } function updateStGoal() { stGoalHrs = parseFloat(document.getElementById('st-goal-hrs')?.value) || 4; renderStGoalBar(); } function renderStGoalBar() { const today = stHistory[stHistory.length - 1]; const todayMins = Object.values(today.total).reduce((a,b)=>a+b,0); const goalMins = stGoalHrs * 60; const pct = Math.min(100, Math.round(todayMins/goalMins*100)); const over = todayMins > goalMins; const bar = document.getElementById('st-goal-bar'); const caption = document.getElementById('st-goal-caption'); const icon = document.getElementById('st-goal-status-icon'); const lbl = document.getElementById('st-goal-status-lbl'); const endLbl = document.getElementById('st-goal-lbl-end'); if(bar) { bar.style.width = pct+'%'; bar.style.background = over ? '#ef4444' : pct > 80 ? '#f59e0b' : '#22c55e'; } if(caption){ caption.textContent = `${minsToHrMin(todayMins)} used of ${minsToHrMin(goalMins)} goal (${pct}%)`; } if(icon) { icon.textContent = over ? '⚠️' : pct > 80 ? '🟡' : '✅'; } if(lbl) { lbl.textContent = over ? 'Over limit' : pct > 80 ? 'Almost there' : 'On track'; lbl.style.color = over ? '#ef4444' : pct > 80 ? '#f59e0b' : '#16a34a'; } if(endLbl) { endLbl.textContent = minsToHrMin(goalMins); } } function renderStStats() { const today = stHistory[stHistory.length-1]; const yesterday = stHistory[stHistory.length-2]; const todayMins = Object.values(today.total).reduce((a,b)=>a+b,0); const yestMins = Object.values(yesterday.total).reduce((a,b)=>a+b,0); const diff = todayMins - yestMins; const weekMins = stHistory.slice(-7).reduce((s,d)=>s+Object.values(d.total).reduce((a,b)=>a+b,0),0); const weekAvg = Math.round(weekMins/7); const el = document.getElementById('st-stats-row'); if(!el) return; el.innerHTML = `
${minsToHrMin(todayMins)}
Today
${diff>0?'+':''}${minsToHrMin(Math.abs(diff))}
vs Yesterday
${minsToHrMin(weekAvg)}
7-Day Avg
${minsToHrMin(weekMins)}
This Week
`; } function renderStDonut() { const today = stHistory[stHistory.length-1]; const todayMins = Object.values(today.total).reduce((a,b)=>a+b,0); const labels = Object.keys(ST_CATS); const data = labels.map(k => today.total[k] || 0); const colors = labels.map(k => ST_CATS[k].color); const ctx = document.getElementById('ch-st-donut'); if(!ctx) return; if(stDonutChart) stDonutChart.destroy(); stDonutChart = new Chart(ctx, { type:'doughnut', data:{ labels, datasets:[{ data, backgroundColor: colors, borderWidth:2, borderColor:'#fff', hoverOffset:6 }]}, options:{ cutout:'72%', plugins:{ legend:{ display:false } }, responsive:true } }); const totalEl = document.getElementById('st-total-today'); if(totalEl) totalEl.textContent = minsToHrMin(todayMins); // Custom legend const leg = document.getElementById('st-legend'); if(leg) leg.innerHTML = labels.map((lbl,i) => { const pct = Math.round(data[i]/todayMins*100); return `
${ST_CATS[lbl].icon} ${lbl}
${minsToHrMin(data[i])}
${pct}%
`; }).join(''); } function renderStWeekly() { const week = stHistory.slice(-7); const labels = week.map(d => { const dt=new Date(d.date+'T12:00'); return dt.toLocaleDateString('en-US',{weekday:'short'}); }); const cats = Object.keys(ST_CATS); const datasets = cats.map(cat => ({ label: cat, data: week.map(d => Math.round((d.total[cat]||0)/60*10)/10), backgroundColor: ST_CATS[cat].color + 'cc', borderRadius: 4, })); const ctx = document.getElementById('ch-st-weekly'); if(!ctx) return; if(stWeeklyChart) stWeeklyChart.destroy(); stWeeklyChart = new Chart(ctx, { type:'bar', data:{ labels, datasets }, options:{ plugins:{legend:{display:false}}, scales:{ x:{stacked:true,ticks:{font:{size:10}}}, y:{stacked:true,ticks:{font:{size:10}},title:{display:true,text:'hrs',font:{size:10}}} }, responsive:true } }); // Week summary chips const avgEl = document.getElementById('st-weekly-avg'); const totalWeek = stHistory.slice(-7).reduce((s,d)=>s+Object.values(d.total).reduce((a,b)=>a+b,0),0); if(avgEl) avgEl.textContent = 'Avg ' + minsToHrMin(Math.round(totalWeek/7)) + '/day'; const sumEl = document.getElementById('st-week-summary'); if(sumEl) { const topCat = cats.reduce((best,c) => (stHistory.slice(-7).reduce((s,d)=>s+(d.total[c]||0),0) > stHistory.slice(-7).reduce((s,d)=>s+(d.total[best]||0),0)) ? c : best, cats[0]); sumEl.innerHTML = `
${ST_CATS[topCat].icon}
Most: ${topCat}
📉
${minsToHrMin(Math.round(totalWeek/7))} avg/day
⏱️
${minsToHrMin(totalWeek)} total
`; } } function renderStTrend() { const labels = stHistory.map(d => { const dt=new Date(d.date+'T12:00'); return dt.toLocaleDateString('en-US',{month:'short',day:'numeric'}); }); const data = stHistory.map(d => Math.round(Object.values(d.total).reduce((a,b)=>a+b,0)/60*10)/10); const ctx = document.getElementById('ch-st-trend'); if(!ctx) return; if(stTrendChart) stTrendChart.destroy(); const goalLine = stHistory.map(()=>stGoalHrs); stTrendChart = new Chart(ctx, { type:'line', data:{ labels, datasets:[ { data, borderColor:'#6366f1', backgroundColor:'#6366f118', tension:.4, fill:true, pointRadius:3, pointBackgroundColor:'#6366f1', label:'Screen Time' }, { data:goalLine, borderColor:'#ef4444', borderDash:[5,4], pointRadius:0, borderWidth:1.5, fill:false, label:'Goal' } ]}, options:{ plugins:{legend:{labels:{font:{size:10}}}}, scales:{y:{ticks:{font:{size:10}},title:{display:true,text:'hrs',font:{size:10}}}}, responsive:true } }); } function renderStAppList() { const el = document.getElementById('st-app-list'); if(!el) return; const apps = stAppFilter === 'all' ? ST_APPS : ST_APPS.filter(a => a.cat === stAppFilter); const maxMins = Math.max(...apps.map(a=>a.mins)); if(!apps.length){ el.innerHTML='
No apps in this category.
'; return; } el.innerHTML = apps.sort((a,b)=>b.mins-a.mins).map(app => { const pct = Math.round(app.mins/maxMins*100); const col = ST_CATS[app.cat]?.color || '#6b7a99'; return `
${app.icon}
${app.name} ${minsToHrMin(app.mins)}
${app.cat}
`; }).join(''); } function buildStLogInputs() { const el = document.getElementById('st-log-inputs'); if(!el || el.children.length > 0) return; Object.entries(ST_CATS).forEach(([cat, meta]) => { const wrap = document.createElement('div'); wrap.innerHTML = `
${meta.icon} ${cat.toUpperCase()}
min
`; el.appendChild(wrap); }); } function logScreenTime() { const dateVal = document.getElementById('st-log-date')?.value; if(!dateVal){ toast('Select a date','#6b7a99'); return; } const total = {}; let hasAny = false; Object.keys(ST_CATS).forEach(cat => { const v = parseInt(document.getElementById('st-log-'+cat)?.value || 0); total[cat] = v || 0; if(v > 0) hasAny = true; }); if(!hasAny){ toast('Enter time for at least one category','#6b7a99'); return; } // Update or insert const existing = stHistory.findIndex(d => d.date === dateVal); if(existing >= 0) stHistory[existing].total = total; else { stHistory.push({ date:dateVal, total }); stHistory.sort((a,b)=>a.date.localeCompare(b.date)); } // Re-render everything renderStStats(); renderStDonut(); renderStWeekly(); renderStTrend(); renderStGoalBar(); // Clear inputs Object.keys(ST_CATS).forEach(cat => { const el=document.getElementById('st-log-'+cat); if(el) el.value=''; }); toast('📱 Screen time logged!','#16a34a'); // ── Supabase cloud sync ── if (typeof litSaveScreenTime !== 'undefined') litSaveScreenTime({ date: dateVal, total }); } // ══════════════════════════════════════════════════════════════════════════════ // CHALLENGES & STREAKS // ══════════════════════════════════════════════════════════════════════════════ const CHAL_TYPES = { steps: { label:'Steps', icon:'👟', color:'#f59e0b', unit:'steps/day', default:7500 }, cardio: { label:'Cardio', icon:'❤️', color:'#ef4444', unit:'min/day', default:30 }, strength: { label:'Strength', icon:'🏋️', color:'#3b82f6', unit:'sessions/wk', default:2 }, water: { label:'Water', icon:'💧', color:'#0ea5e9', unit:'glasses/day', default:8 }, mindfulness: { label:'Mindfulness', icon:'🧘', color:'#8b5cf6', unit:'min/day', default:10 }, custom: { label:'Custom', icon:'✏️', color:'#6b7a99', unit:'times/day', default:1 }, }; // Active challenges list let activeChallenges = [ { id:1, type:'steps', name:'Steps', target:7500, unit:'steps', length:7, startDate:'2026-03-01', color:'#f59e0b', icon:'👟', log:{ '2026-03-01':8200,'2026-03-02':7900,'2026-03-03':8100,'2026-03-04':6800 } }, { id:2, type:'cardio', name:'Cardio', target:30, unit:'min', length:14, startDate:'2026-02-26', color:'#ef4444', icon:'❤️', log:{ '2026-02-26':35,'2026-02-27':40,'2026-02-28':30,'2026-03-01':45,'2026-03-02':30,'2026-03-03':0,'2026-03-04':30 } }, { id:3, type:'strength', name:'Strength', target:2, unit:'sets', length:21, startDate:'2026-03-01', color:'#3b82f6', icon:'🏋️', log:{ '2026-03-01':3,'2026-03-03':2,'2026-03-05':2 } }, { id:4, type:'mindfulness', name:'Mindfulness', target:10, unit:'min', length:30, startDate:'2026-02-14', color:'#8b5cf6', icon:'🧘', log:{ '2026-02-14':12,'2026-02-15':10,'2026-02-16':15,'2026-02-18':10,'2026-02-19':10,'2026-02-20':12,'2026-02-22':10,'2026-02-24':15,'2026-02-25':10,'2026-02-27':12,'2026-02-28':10,'2026-03-01':10,'2026-03-02':12,'2026-03-03':10,'2026-03-04':15,'2026-03-05':10 } }, ]; let selectedChalGoalType = 'steps'; let selectedChalLen = 7; let viewingChalId = 1; // which challenge the calendar shows let chalCalYear = 2026; let chalCalMonth = 2; // 0-indexed = March let chalInited = false; let monthlyJoined = false; // ── helpers ────────────────────────────────────────────────────────────────── function dateStr(d) { return d.toISOString().split('T')[0]; } function getChalStreak(chal) { const today = new Date('2026-03-05'); let cur = 0, longest = 0, run = 0; const start = new Date(chal.startDate + 'T12:00'); for(let d = new Date(start); d <= today; d.setDate(d.getDate()+1)) { const ds = dateStr(d); const val = chal.log[ds] || 0; const hit = chal.type === 'strength' ? val >= chal.target : val >= chal.target; if(hit) { run++; longest = Math.max(longest, run); } else { run = 0; } } // current streak = from today backwards for(let d = new Date(today); d >= start; d.setDate(d.getDate()-1)) { const ds = dateStr(d); const val = chal.log[ds] || 0; if(val >= chal.target) cur++; else break; } const daysHit = Object.values(chal.log).filter(v => v >= chal.target).length; return { cur, longest, daysHit }; } function getChalPct(chal) { const streak = getChalStreak(chal); return Math.min(100, Math.round(streak.daysHit / chal.length * 100)); } // ── selectChalGoalType ─────────────────────────────────────────────────────── function selectChalGoalType(btn, type) { selectedChalGoalType = type; document.querySelectorAll('.chal-type-btn').forEach(b => { const t = b.dataset.type; const meta = CHAL_TYPES[t]; b.style.border = '2px solid #e2e8f0'; b.style.background = '#fff'; b.style.color = 'var(--navy)'; }); const meta = CHAL_TYPES[type]; btn.style.border = `2px solid ${meta.color}`; btn.style.background = meta.color + '18'; btn.style.color = meta.color; // Update target input const valEl = document.getElementById('chal-target-val'); const unitEl = document.getElementById('chal-target-unit'); const nameWrap = document.getElementById('chal-custom-name-wrap'); if(valEl) valEl.value = meta.default; if(unitEl) unitEl.textContent = meta.unit; if(nameWrap) nameWrap.style.display = type === 'custom' ? '' : 'none'; } function selectChalLen(btn, len) { selectedChalLen = len; document.querySelectorAll('.chal-len-btn').forEach(b => { b.style.border = '2px solid #e2e8f0'; b.style.background = '#fff'; b.style.color = 'var(--navy)'; }); btn.style.border = '2px solid var(--orange)'; btn.style.background = '#fff7ed'; btn.style.color = 'var(--orange)'; } // ── createChallenge ────────────────────────────────────────────────────────── function createChallenge() { const type = selectedChalGoalType; const meta = CHAL_TYPES[type]; const target = parseFloat(document.getElementById('chal-target-val').value) || meta.default; const start = document.getElementById('chal-start-date').value; const len = selectedChalLen; const customName = document.getElementById('chal-custom-name')?.value.trim(); const name = type === 'custom' ? (customName || 'Custom Goal') : meta.label; if(!start) { toast('Pick a start date','#6b7a99'); return; } const id = Date.now(); activeChallenges.push({ id, type, name, target, unit: meta.unit.split('/')[0], length: len, startDate: start, color: meta.color, icon: meta.icon, log:{} }); viewingChalId = id; renderActiveChallengeCards(); renderChalCalendar(); updateChalStreak(); toast(`🔥 ${name} challenge started!`, meta.color); } // ── joinMonthlyChallengeFFH ────────────────────────────────────────────────── function joinMonthlyChallengeFFH(btn) { monthlyJoined = !monthlyJoined; btn.textContent = monthlyJoined ? '✅ Joined!' : '🔥 Join Challenge'; btn.style.background = monthlyJoined ? '#fff' : '#fff'; btn.style.color = monthlyJoined ? '#16a34a' : 'var(--orange)'; const pct = document.getElementById('monthly-pct-lbl'); const bar = document.getElementById('monthly-prog-bar'); if(monthlyJoined && pct && bar) { pct.textContent = '5 / 31 days'; bar.style.width = '16%'; toast('🏆 You joined the March Challenge!','#e8450a'); } } // ── renderActiveChallengeCards ─────────────────────────────────────────────── function renderActiveChallengeCards() { const el = document.getElementById('ch-active-challenges'); if(!el) return; el.innerHTML = activeChallenges.map(c => { const s = getChalStreak(c); const pct = getChalPct(c); const isViewing = c.id === viewingChalId; return `
${c.icon}
${c.name}
${c.target} ${c.unit}/day · ${c.length}d
${s.daysHit} days hit${pct}%
🔥${s.cur}
streak
${c.length}d
goal
🏆${s.longest}
best
`; }).join(''); } function viewChallenge(id) { viewingChalId = id; chalCalYear = 2026; chalCalMonth = 2; renderActiveChallengeCards(); renderChalCalendar(); updateChalStreak(); updateChalLogUnit(); } function updateChalStreak() { const c = activeChallenges.find(c => c.id === viewingChalId); if(!c) return; const s = getChalStreak(c); const cur = document.getElementById('chal-current-streak'); const lng = document.getElementById('chal-longest-streak'); const hit = document.getElementById('chal-days-hit'); if(cur) cur.textContent = s.cur; if(lng) lng.textContent = s.longest; if(hit) hit.textContent = s.daysHit; updateMilestones(s.longest); // Header const hdr = document.getElementById('chal-cal-header'); if(hdr) { hdr.innerHTML = `
${c.icon}
${c.name}
${c.target} ${c.unit}/day · ${c.length}-day challenge
`; } } function updateChalLogUnit() { const c = activeChallenges.find(c => c.id === viewingChalId); const el = document.getElementById('chal-log-unit-lbl'); if(el && c) el.textContent = c.unit; } function updateMilestones(longest) { const milestones = [ { days:3, label:'3-Day Spark', icon:'🥉', desc:'Complete a 3-day streak' }, { days:7, label:'7-Day Warrior', icon:'🥈', desc:'Complete a full week streak' }, { days:14, label:'14-Day Champion', icon:'🏆', desc:'Two full weeks — no breaks' }, { days:21, label:'21-Day Habit Builder',icon:'💎',desc:"Science says habits form here" }, { days:30, label:'30-Day Legend', icon:'🌟', desc:"A full month. You are unstoppable." }, ]; const el = document.getElementById('chal-milestones'); if(!el) return; el.innerHTML = milestones.map(m => { const unlocked = longest >= m.days; return `
${m.icon}
${m.label}
${m.desc}
${unlocked?'✅ Earned':'Locked'}
`; }).join(''); } // ── Calendar ───────────────────────────────────────────────────────────────── function chalCalNav(dir) { chalCalMonth += dir; if(chalCalMonth < 0) { chalCalMonth = 11; chalCalYear--; } if(chalCalMonth > 11) { chalCalMonth = 0; chalCalYear++; } renderChalCalendar(); } function renderChalCalendar() { const lbl = document.getElementById('chal-cal-month-lbl'); const grid = document.getElementById('chal-cal-grid'); if(!lbl || !grid) return; const months = ['January','February','March','April','May','June','July','August','September','October','November','December']; lbl.textContent = months[chalCalMonth] + ' ' + chalCalYear; const c = activeChallenges.find(c => c.id === viewingChalId); if(!c) { grid.innerHTML=''; return; } const startD = new Date(c.startDate + 'T12:00'); const endD = new Date(startD); endD.setDate(endD.getDate() + c.length - 1); const today = new Date('2026-03-05T12:00'); const first = new Date(chalCalYear, chalCalMonth, 1).getDay(); const daysInM = new Date(chalCalYear, chalCalMonth+1, 0).getDate(); let html = ''; for(let i=0; i= startD && dt <= endD; const isPast = dt < today && inRange; const isToday = ds === '2026-03-05'; const isFuture = dt > today; const hit = val !== undefined && val >= c.target; const partial = val !== undefined && val > 0 && val < c.target; const missed = isPast && !hit && val === undefined; let bg = '#f4f6fa', color = '#ccc', border = 'transparent', ring = ''; if(!inRange) { bg='transparent'; color='#d1d5db'; } else if(isToday) { bg=c.color; color='#fff'; border=c.color; } else if(hit) { bg=c.color+'22'; color=c.color; border=c.color+'66'; ring=`
`; } else if(partial) { bg='#fef9c3'; color='#b45309'; border='#fcd34d'; } else if(missed) { bg='#fef2f2'; color='#fca5a5'; border='#fecaca'; } else if(isFuture && inRange) { bg='#fff'; color='var(--navy)'; border='#e2e8f0'; } const emoji = !inRange ? '' : hit ? '✓' : partial ? '~' : missed ? '✗' : ''; html += `
${ring} ${d} ${emoji?`${emoji}`:''}
`; } grid.innerHTML = html; } function chalDayClick(ds, chalId) { // Quick-log: prompt for value or toggle if already logged const c = activeChallenges.find(c => c.id === chalId); if(!c) return; const today = '2026-03-05'; if(ds > today) return; // cannot log future const existing = c.log[ds]; if(existing !== undefined) { delete c.log[ds]; toast('Removed log for ' + ds, '#6b7a99'); } else { c.log[ds] = c.target; // mark as hit toast(`✅ Marked ${ds} as complete!`, c.color); } renderChalCalendar(); updateChalStreak(); renderActiveChallengeCards(); } function logChallengeDay() { const c = activeChallenges.find(c => c.id === viewingChalId); if(!c) { toast('Select a challenge first','#6b7a99'); return; } const val = parseFloat(document.getElementById('chal-log-val').value); if(!val || val < 0) { toast('Enter a valid value','#6b7a99'); return; } const today = '2026-03-05'; c.log[today] = val; document.getElementById('chal-log-val').value = ''; renderChalCalendar(); updateChalStreak(); renderActiveChallengeCards(); const hit = val >= c.target; toast(hit ? `🔥 Goal hit! ${val} ${c.unit} logged!` : `📝 ${val} ${c.unit} logged (${c.target} ${c.unit} goal)`, hit ? c.color : '#6b7a99'); } function initChallenges() { if(!document.getElementById('chal-start-date').value) { document.getElementById('chal-start-date').value = '2026-03-05'; } renderActiveChallengeCards(); renderChalCalendar(); updateChalStreak(); updateChalLogUnit(); } // ══════════════════════════════════════════════════════════════════════════════ // TREATMENT TRACKER // ══════════════════════════════════════════════════════════════════════════════ const TX_CAT_META = { Medication: { color:'#6366f1', icon:'💊' }, Physical: { color:'#e8450a', icon:'🤸' }, Thermal: { color:'#0ea5e9', icon:'🌡️' }, Mental: { color:'#16a34a', icon:'🧘' }, Alternative: { color:'#8b5cf6', icon:'🌿' }, Lifestyle: { color:'#f59e0b', icon:'🏃' }, }; // Demo data only loads if user is NOT authenticated (see loadDemoTreatmentData below) var treatmentLog = []; function loadDemoTreatmentData() { if (typeof ffhLitSupabaseState !== 'undefined' && ffhLitSupabaseState.client && ffhLitSupabaseState.authenticated) return; treatmentLog = [ { name:'Ibuprofen', cat:'Medication', date:'2026-02-20', duration:1, durationUnit:'days', before:7, after:4, effectiveness:4, notes:'Works well for back pain' }, { name:'Ice Pack', cat:'Thermal', date:'2026-02-22', duration:20, durationUnit:'min', before:6, after:4, effectiveness:3, notes:'Helps temporarily' }, { name:'Stretching', cat:'Physical', date:'2026-02-24', duration:30, durationUnit:'min', before:5, after:2, effectiveness:5, notes:'Best relief for lower back' }, { name:'Heat Pack', cat:'Thermal', date:'2026-02-26', duration:25, durationUnit:'min', before:6, after:5, effectiveness:2, notes:'Not much improvement' }, { name:'Ibuprofen', cat:'Medication', date:'2026-02-28', duration:1, durationUnit:'days', before:8, after:4, effectiveness:4, notes:'' }, { name:'Meditation', cat:'Mental', date:'2026-03-01', duration:15, durationUnit:'min', before:5, after:3, effectiveness:4, notes:'Reduces tension headaches' }, { name:'Stretching', cat:'Physical', date:'2026-03-02', duration:20, durationUnit:'min', before:4, after:1, effectiveness:5, notes:'Morning routine helps a lot' }, { name:'Acetaminophen', cat:'Medication', date:'2026-03-03', duration:1, durationUnit:'days', before:6, after:4, effectiveness:3, notes:'Mild relief only' }, { name:'Walking', cat:'Lifestyle', date:'2026-03-04', duration:30, durationUnit:'min', before:4, after:2, effectiveness:5, notes:'30 min walk cleared tension' }, { name:'Massage', cat:'Physical', date:'2026-03-05', duration:60, durationUnit:'min', before:7, after:3, effectiveness:5, notes:'Professional massage — excellent' }, ]; } let selectedTxCat = ''; let txBeforeLevel = 5, txAfterLevel = 3, txEffLevel = 4; let txEffChart = null, txReductionChart = null; let txLibFilter = 'all'; let txInited = false; const TX_PAIN_COLORS = ['#22c55e','#86efac','#fde68a','#fcd34d','#fbbf24','#fb923c','#f97316','#ef4444','#dc2626','#991b1b','#7f1d1d']; function selectTxCat(btn, cat) { document.querySelectorAll('#tx-cat-btns .pain-loc-btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); selectedTxCat = cat; document.getElementById('tx-cat-val').value = cat; } function buildTxPainScale(containerId, hiddenId, defaultVal, onChange) { const wrap = document.getElementById(containerId); if(!wrap || wrap.children.length > 0) return; let selected = defaultVal; for(let i = 0; i <= 10; i++) { const btn = document.createElement('button'); btn.className = 'pain-scale-btn' + (i === selected ? ' active' : ''); btn.textContent = i; btn.style.cssText = `width:26px;height:26px;border-radius:6px;border:1.5px solid var(--light);background:${i===selected?TX_PAIN_COLORS[i]:'#fff'};color:${i===selected?'#fff':'var(--navy)'};font-size:11px;font-weight:800;cursor:pointer;font-family:inherit;transition:all .15s;flex-shrink:0`; btn.onclick = function() { selected = i; document.getElementById(hiddenId).value = i; if(onChange) onChange(i); Array.from(wrap.children).forEach((b, idx) => { b.classList.toggle('active', idx === i); b.style.background = idx === i ? TX_PAIN_COLORS[idx] : '#fff'; b.style.color = idx === i ? '#fff' : 'var(--navy)'; }); }; wrap.appendChild(btn); } document.getElementById(hiddenId).value = selected; } function buildTxEffStars() { const wrap = document.getElementById('tx-eff-stars'); if(!wrap || wrap.children.length > 0) return; const labels = ['','Unhelpful','Slightly helpful','Moderately helpful','Very helpful','Excellent']; for(let i = 1; i <= 5; i++) { const btn = document.createElement('button'); btn.style.cssText = 'background:none;border:none;font-size:28px;cursor:pointer;padding:2px 4px;transition:transform .15s;line-height:1'; btn.textContent = i <= txEffLevel ? '⭐' : '☆'; btn.title = labels[i]; btn.onclick = function() { txEffLevel = i; document.getElementById('tx-eff-lbl').textContent = i; Array.from(wrap.children).forEach((b, idx) => { b.textContent = idx + 1 <= i ? '⭐' : '☆'; }); }; wrap.appendChild(btn); } } function logTreatment() { const name = document.getElementById('tx-name').value.trim(); const cat = document.getElementById('tx-cat-val').value; const date = document.getElementById('tx-date').value; const dur = parseInt(document.getElementById('tx-duration').value); const durU = document.getElementById('tx-duration-unit').value; const before = parseInt(document.getElementById('tx-before-val').value); const after = parseInt(document.getElementById('tx-after-val').value); const notes = document.getElementById('tx-notes').value.trim(); if(!name) { toast('Enter a treatment name','#6b7a99'); return; } if(!cat) { toast('Select a category','#6b7a99'); return; } if(!date) { toast('Select a date','#6b7a99'); return; } treatmentLog.push({ name, cat, date, duration: dur||0, durationUnit: durU, before, after, effectiveness: txEffLevel, notes }); treatmentLog.sort((a,b) => a.date.localeCompare(b.date)); // Reset form document.getElementById('tx-name').value = ''; document.getElementById('tx-notes').value = ''; selectedTxCat = ''; document.querySelectorAll('#tx-cat-btns .pain-loc-btn').forEach(b => b.classList.remove('active')); txEffLevel = 4; if(document.getElementById('tx-eff-stars')) { Array.from(document.getElementById('tx-eff-stars').children).forEach((b,i) => b.textContent = i<4?'⭐':'☆'); document.getElementById('tx-eff-lbl').textContent = '4'; } renderTxStats(); renderTxCharts(); renderTxLibrary(); renderTxLog(); toast('💊 Treatment logged!', '#16a34a'); // ── Supabase cloud sync ── if (typeof litSaveTreatmentEntry !== 'undefined') litSaveTreatmentEntry({ name, category: cat, date, duration: dur||0, duration_unit: durU, pain_before: before, pain_after: after, effectiveness: txEffLevel, notes }); } function getTxSummary() { // Aggregate by treatment name const map = {}; treatmentLog.forEach(e => { if(!map[e.name]) map[e.name] = { name:e.name, cat:e.cat, count:0, effTotal:0, reductionTotal:0, entries:[] }; map[e.name].count++; map[e.name].effTotal += e.effectiveness; map[e.name].reductionTotal += (e.before - e.after); map[e.name].entries.push(e); }); return Object.values(map).map(t => ({ ...t, avgEff: Math.round(t.effTotal / t.count * 10) / 10, avgReduction: Math.round(t.reductionTotal / t.count * 10) / 10, })).sort((a,b) => b.avgEff - a.avgEff); } function renderTxStats() { const el = document.getElementById('tx-stats-row'); if(!el || !treatmentLog.length) return; const summary = getTxSummary(); const best = summary[0]; const worst = [...summary].sort((a,b) => a.avgEff - b.avgEff)[0]; const totalEntries = treatmentLog.length; const avgReduction = Math.round(treatmentLog.reduce((s,e)=>s+(e.before-e.after),0)/totalEntries * 10)/10; el.innerHTML = `
${best.name}
🏆 Most Effective
${best.avgEff}/5 avg rating
↓${avgReduction}
Avg Pain Reduction
points on 0–10 scale
${totalEntries}
Treatments Logged
${summary.length} unique treatments
${worst.name}
⚠️ Least Effective
${worst.avgEff}/5 avg rating
`; } function renderTxCharts() { const summary = getTxSummary(); const labels = summary.map(t => t.name); const effData = summary.map(t => t.avgEff); const redData = summary.map(t => t.avgReduction); const colors = summary.map(t => TX_CAT_META[t.cat]?.color || '#6b7a99'); const effCtx = document.getElementById('ch-tx-eff'); if(effCtx) { if(txEffChart) txEffChart.destroy(); txEffChart = new Chart(effCtx, { type: 'bar', data: { labels, datasets:[{ data: effData, backgroundColor: colors.map(c=>c+'cc'), borderColor: colors, borderWidth:2, borderRadius:8 }]}, options: { indexAxis:'y', plugins:{legend:{display:false}}, scales:{ x:{min:0,max:5,ticks:{font:{size:10}},title:{display:true,text:'Rating (0–5)',font:{size:10}}}, y:{ticks:{font:{size:10}}} }, responsive:true } }); } const redCtx = document.getElementById('ch-tx-reduction'); if(redCtx) { if(txReductionChart) txReductionChart.destroy(); txReductionChart = new Chart(redCtx, { type: 'bar', data: { labels, datasets:[{ data: redData, backgroundColor: colors.map(c=>c+'99'), borderColor: colors, borderWidth:2, borderRadius:8, label:'Pain points reduced' }]}, options: { indexAxis:'y', plugins:{legend:{display:false}}, scales:{ x:{min:0,ticks:{font:{size:10}},title:{display:true,text:'Avg pain points reduced',font:{size:10}}}, y:{ticks:{font:{size:10}}} }, responsive:true } }); } } function filterTxLib(cat, btn) { txLibFilter = cat; document.querySelectorAll('#tx-filter-row .pain-loc-btn').forEach(b => b.classList.remove('active')); if(btn) btn.classList.add('active'); renderTxLibrary(); } function renderTxLibrary() { const el = document.getElementById('tx-library-grid'); if(!el) return; const summary = getTxSummary().filter(t => txLibFilter === 'all' || t.cat === txLibFilter); if(!summary.length) { el.innerHTML='
No treatments in this category yet.
'; return; } el.innerHTML = summary.map(t => { const meta = TX_CAT_META[t.cat] || { color:'#6b7a99', icon:'💊' }; const stars = '⭐'.repeat(Math.round(t.avgEff)) + '☆'.repeat(5-Math.round(t.avgEff)); const trend = t.avgReduction > 0 ? `↓${t.avgReduction} pain pts` : 'No reduction'; const trendColor = t.avgReduction > 2 ? 'var(--green)' : t.avgReduction > 0 ? '#f59e0b' : '#ef4444'; const lastEntry = t.entries[t.entries.length-1]; const lastDate = new Date(lastEntry.date+'T12:00').toLocaleDateString('en-US',{month:'short',day:'numeric'}); return `
${t.name}
${meta.icon} ${t.cat}
${stars}
${t.avgEff}/5
${trend}
avg reduction
${t.count}
sessions
${lastDate}
last used
`; }).join(''); } function renderTxLog() { const el = document.getElementById('tx-log-list'); if(!el) return; if(!treatmentLog.length) { el.innerHTML='
No treatments logged yet.
'; return; } el.innerHTML = [...treatmentLog].reverse().map(e => { const meta = TX_CAT_META[e.cat] || { color:'#6b7a99', icon:'💊' }; const d = new Date(e.date+'T12:00').toLocaleDateString('en-US',{month:'short',day:'numeric',year:'numeric'}); const stars = '⭐'.repeat(e.effectiveness) + '☆'.repeat(5-e.effectiveness); const reduc = e.before - e.after; const reducColor = reduc > 2 ? 'var(--green)' : reduc > 0 ? '#f59e0b' : '#ef4444'; const durStr = e.duration ? `${e.duration} ${e.durationUnit}` : ''; const txIdx = treatmentLog.length - 1 - treatmentLog.slice().reverse().indexOf(e); return `
${meta.icon}
${e.name} ${e.cat} ${durStr?`${durStr}`:''} ${d}
Pain: ${e.before}${e.after} ${reduc>0?'↓'+reduc+' pts':reduc===0?'No change':'↑'+Math.abs(reduc)+' pts'} ${stars}
${e.notes?`
${e.notes}
`:''}
`; }).join(''); } function deleteTxEntry(idx) { if (!confirm('Delete this treatment entry?')) return; treatmentLog.splice(idx, 1); renderTxStats(); renderTxCharts(); renderTxLibrary(); renderTxLog(); toast('🗑️ Treatment deleted', '#6b7a99'); } function initTreatmentTracker() { if(txInited) { renderTxStats(); renderTxLibrary(); renderTxLog(); return; } txInited = true; const todayStr = new Date().toISOString().split('T')[0]; const dateEl = document.getElementById('tx-date'); if(dateEl && !dateEl.value) dateEl.value = todayStr; buildTxPainScale('tx-before-scale','tx-before-val', 5, v => { txBeforeLevel=v; }); buildTxPainScale('tx-after-scale','tx-after-val', 3, v => { txAfterLevel=v; }); buildTxEffStars(); renderTxStats(); renderTxCharts(); renderTxLibrary(); renderTxLog(); } function initScreenTime() { // Set today default const dateEl = document.getElementById('st-log-date'); if(dateEl && !dateEl.value) dateEl.value = '2026-03-05'; buildStLogInputs(); renderStStats(); if(!stDonutChart) renderStDonut(); if(!stWeeklyChart) renderStWeekly(); if(!stTrendChart) renderStTrend(); renderStGoalBar(); renderStAppList(); } // ══════════════════════════════════════════════════════════════════════════════ function setWkView(id, btn) { wkView = id; document.getElementById('wk-browse').style.display = id==='browse' ? 'block' : 'none'; document.getElementById('wk-builder').style.display = id==='builder' ? 'flex' : 'none'; document.getElementById('wk-myworkouts').style.display = id==='myworkouts' ? 'flex' : 'none'; document.querySelectorAll('.wk-snb').forEach(b=>{ b.style.background=b===btn?'var(--orange)':'#fff'; b.style.color=b===btn?'#fff':'var(--navy)'; b.style.borderColor=b===btn?'var(--orange)':'var(--light)'; }); if (id==='myworkouts') renderSavedWorkouts(); } // ── Build filter UI ─────────────────────────────────────────────────────────── // ── Exercise Data & Workout State ── // Dynamic exercise library - loads 1,500 exercises from free ExerciseDB v1 API let EXERCISES = []; let _exercisesLoaded = false; let _exercisesLoading = false; const BODY_PARTS_WK = [ {id:'all', label:'All', icon:'\uD83D\uDCAA', color:'#0f2044'}, {id:'back', label:'Back', icon:'\uD83D\uDD19', color:'#2980b9'}, {id:'cardio', label:'Cardio', icon:'\u2764\uFE0F', color:'#e74c3c'}, {id:'chest', label:'Chest', icon:'\uD83E\uDEC1', color:'#c0392b'}, {id:'lower arms',label:'Forearms', icon:'\uD83D\uDCAA', color:'#e67e22'}, {id:'lower legs',label:'Calves', icon:'\uD83E\uDDB5', color:'#16a085'}, {id:'neck', label:'Neck', icon:'\uD83E\uDDD1', color:'#7f8c8d'}, {id:'shoulders',label:'Shoulders', icon:'\uD83D\uDE4C', color:'#8e44ad'}, {id:'upper arms',label:'Arms', icon:'\uD83D\uDCAA', color:'#2c3e50'}, {id:'upper legs',label:'Legs', icon:'\uD83E\uDDB5', color:'#27ae60'}, {id:'waist', label:'Core', icon:'\uD83E\uDDD8', color:'#f39c12'}, ]; const EQUIPMENT_WK = ['all','assisted','band','barbell','body weight','bosu ball','cable','dumbbell','elliptical machine','ez barbell','hammer','kettlebell','leverage machine','medicine ball','olympic barbell','resistance band','roller','rope','skierg machine','sled machine','smith machine','stability ball','stationary bike','stepmill machine','tire','trap bar','upper body ergometer','weighted','wheel roller']; // ── State ──────────────────────────────────────────────────────────────────── let wkFilter = { bp:'all', eq:'all', q:'' }; let currentWk = []; // exercises in current workout being built let savedWks = []; // array of saved workout objects let wkHistory = []; let activeExId = null; let wkView = 'browse'; // ── Dynamic Exercise Loader ────────────────────────────────────────────────── async function loadExercisesFromAPI() { if (_exercisesLoaded || _exercisesLoading) return; _exercisesLoading = true; // Show loading indicator var grid = document.getElementById('ex-grid'); var countEl = document.getElementById('ex-count'); if (grid) grid.innerHTML = '
\u23F3
Loading 1,500 exercises with animated demos...
Powered by ExerciseDB Open Source
'; if (countEl) countEl.textContent = 'Loading...'; var allExercises = []; var limit = 100; var offset = 0; var total = 1500; var retries = 0; while (offset < total && retries < 5) { try { var resp = await fetch('https://exercisedb.dev/api/v1/exercises?limit=' + limit + '&offset=' + offset); if (resp.status === 429) { retries++; await new Promise(function(r) { setTimeout(r, 3000 * retries); }); continue; } var json = await resp.json(); if (json.metadata && json.metadata.totalExercises) total = json.metadata.totalExercises; var batch = json.data || []; if (batch.length === 0) break; allExercises = allExercises.concat(batch); offset += limit; retries = 0; // Update loading progress if (countEl) countEl.textContent = 'Loading... ' + allExercises.length + '/' + total; // Small delay between requests if (offset < total) await new Promise(function(r) { setTimeout(r, 300); }); } catch(e) { retries++; if (retries >= 5) break; await new Promise(function(r) { setTimeout(r, 2000 * retries); }); } } // Convert to our format EXERCISES = allExercises.map(function(e) { var nm = e.name.split(' ').map(function(w) { return w.charAt(0).toUpperCase() + w.slice(1); }).join(' '); return { id: e.exerciseId, name: nm, bodyPart: (e.bodyParts && e.bodyParts[0]) || '', equipment: (e.equipments && e.equipments[0]) || 'body weight', target: (e.targetMuscles && e.targetMuscles[0]) || '', secondary: e.secondaryMuscles || [], gif: e.gifUrl || ('https://static.exercisedb.dev/media/' + e.exerciseId + '.gif') }; }); _exercisesLoaded = true; _exercisesLoading = false; // Rebuild filters with loaded data buildWorkoutFilters(); } function buildWorkoutFilters() { const bpEl = document.getElementById('bp-filters'); if(!bpEl) return; bpEl.innerHTML = ''; BODY_PARTS_WK.forEach(bp=>{ const active = bp.id === wkFilter.bp; const btn = document.createElement('button'); btn.textContent = bp.icon + ' ' + bp.label; btn.style.cssText = 'display:inline-flex;align-items:center;gap:5px;padding:7px 13px;border-radius:20px;font-weight:700;font-size:12px;cursor:pointer;transition:all .15s;font-family:inherit;margin-bottom:4px'; btn.style.border = active ? '2px solid ' + bp.color : '2px solid #e2e8f0'; btn.style.background = active ? bp.color + '18' : '#fff'; btn.style.color = active ? bp.color : '#6b7a99'; btn.onclick = function(){ wkFilter.bp = bp.id; buildWorkoutFilters(); }; bpEl.appendChild(btn); }); const eqEl = document.getElementById('eq-filters'); if(!eqEl) return; eqEl.innerHTML = ''; EQUIPMENT_WK.forEach(eq=>{ const active = eq === wkFilter.eq; const btn = document.createElement('button'); btn.textContent = eq === 'all' ? 'All Equipment' : eq.charAt(0).toUpperCase() + eq.slice(1); btn.style.cssText = 'padding:5px 11px;border-radius:20px;font-weight:600;font-size:11px;cursor:pointer;transition:all .15s;font-family:inherit;margin-bottom:4px'; btn.style.border = active ? '2px solid #0f2044' : '2px solid #e2e8f0'; btn.style.background = active ? '#fff' : '#f5f7fa'; btn.style.color = active ? '#fff' : '#6b7a99'; btn.onclick = function(){ wkFilter.eq = eq; buildWorkoutFilters(); }; eqEl.appendChild(btn); }); filterExercises(); } // ── Filter + render exercise grid ───────────────────────────────────────────── function filterExercises() { const q = (document.getElementById('ex-search')?.value||'').toLowerCase(); wkFilter.q = q; const filtered = EXERCISES.filter(e=>{ const bpOk = wkFilter.bp==='all' || e.bodyPart===wkFilter.bp; const eqOk = wkFilter.eq==='all' || e.equipment===wkFilter.eq; const qOk = !q || e.name.toLowerCase().includes(q) || e.target.toLowerCase().includes(q) || e.bodyPart.toLowerCase().includes(q); return bpOk && eqOk && qOk; }); const countEl = document.getElementById('ex-count'); if(countEl) countEl.textContent = filtered.length + ' exercise' + (filtered.length!==1?'s':'') + ' found'; const grid = document.getElementById('ex-grid'); if(!grid) return; grid.innerHTML = ''; filtered.forEach(e=>{ const inWk = !!currentWk.find(w=>w.id===e.id); const bpColor = BODY_PARTS_WK.find(b=>b.id===e.bodyPart)?.color||'#6b7a99'; const card = document.createElement('div'); card.style.cssText = 'background:#fff;border:2px solid ' + (inWk?'#16a34a':'#e2e8f0') + ';border-radius:12px;cursor:pointer;transition:all .18s;overflow:hidden;position:relative'; card.onclick = function(){ openExModal(e.id); }; card.onmouseover = function(){ this.style.borderColor=bpColor; this.style.transform='translateY(-2px)'; this.style.boxShadow='0 4px 16px rgba(0,0,0,.12)'; }; card.onmouseout = function(){ this.style.borderColor=inWk?'#16a34a':'#e2e8f0'; this.style.transform='none'; this.style.boxShadow='none'; }; if(inWk){ const badge = document.createElement('div'); badge.textContent = '✓ Added'; badge.style.cssText = 'position:absolute;top:6px;right:6px;background:#16a34a;color:#fff;border-radius:20px;padding:2px 7px;font-size:10px;font-weight:700;z-index:1'; card.appendChild(badge); } const imgWrap = document.createElement('div'); imgWrap.style.cssText = 'height:130px;background:#f4f6fa;display:flex;align-items:center;justify-content:center;overflow:hidden'; const img = document.createElement('img'); img.src = e.gif; img.alt = e.name; img.loading = 'lazy'; img.style.cssText = 'height:130px;width:100%;object-fit:cover'; img.onerror = function(){ this.style.display='none'; fallback.style.display='flex'; }; const fallback = document.createElement('div'); fallback.textContent = '💪'; fallback.style.cssText = 'display:none;height:130px;width:100%;align-items:center;justify-content:center;font-size:40px'; imgWrap.appendChild(img); imgWrap.appendChild(fallback); card.appendChild(imgWrap); const info = document.createElement('div'); info.style.cssText = 'padding:10px 12px'; const nameEl = document.createElement('div'); nameEl.textContent = e.name; nameEl.style.cssText = 'font-size:12px;font-weight:700;color:#0f2044;line-height:1.3;margin-bottom:5px'; info.appendChild(nameEl); const tags = document.createElement('div'); tags.style.cssText = 'display:flex;gap:4px;flex-wrap:wrap'; const bpTag = document.createElement('span'); bpTag.textContent = e.bodyPart; bpTag.style.cssText = 'font-size:10px;font-weight:600;padding:2px 7px;border-radius:20px;background:' + bpColor + '18;color:' + bpColor; tags.appendChild(bpTag); const eqTag = document.createElement('span'); eqTag.textContent = e.equipment; eqTag.style.cssText = 'font-size:10px;padding:2px 7px;border-radius:20px;background:#f4f6fa;color:#6b7a99'; tags.appendChild(eqTag); info.appendChild(tags); card.appendChild(info); grid.appendChild(card); }); } // ── Exercise detail modal ───────────────────────────────────────────────────── function openExModal(id) { const e = EXERCISES.find(x=>x.id===id); if(!e) return; activeExId = id; const bpColor = BODY_PARTS_WK.find(b=>b.id===e.bodyPart)?.color||'#6b7a99'; document.getElementById('ex-modal-title').textContent = e.name; const gifEl = document.getElementById('ex-modal-gif'); gifEl.innerHTML = ''; const mImg = document.createElement('img'); mImg.src = e.gif; mImg.alt = e.name; mImg.style.cssText = 'width:180px;height:180px;object-fit:cover'; const mFb = document.createElement('div'); mFb.textContent = '💪'; mFb.style.cssText = 'display:none;font-size:56px;width:180px;height:180px;align-items:center;justify-content:center'; mImg.onerror = function(){ this.style.display='none'; mFb.style.display='flex'; }; gifEl.appendChild(mImg); gifEl.appendChild(mFb); const tagsEl = document.getElementById('ex-modal-tags'); tagsEl.innerHTML = ''; const bpSpan = document.createElement('span'); bpSpan.textContent = e.bodyPart; bpSpan.style.cssText = 'background:' + bpColor + '18;color:' + bpColor + ';border-radius:20px;padding:3px 10px;font-size:11px;font-weight:700'; const eqSpan = document.createElement('span'); eqSpan.textContent = e.equipment; eqSpan.style.cssText = 'background:#f4f6fa;color:#6b7a99;border-radius:20px;padding:3px 10px;font-size:11px'; tagsEl.appendChild(bpSpan); tagsEl.appendChild(eqSpan); document.getElementById('ex-modal-muscles').textContent = e.target; document.getElementById('ex-modal-secondary').textContent = e.secondary.length ? e.secondary.join(', ') : 'None'; document.getElementById('ex-modal').style.display = 'flex'; } function closeExModal() { document.getElementById('ex-modal').style.display='none'; activeExId=null; } // ── Add exercise to builder ─────────────────────────────────────────────────── function addExToWorkout() { if(!activeExId) return; const e = EXERCISES.find(x=>x.id===activeExId); const sets = parseInt(document.getElementById('ex-sets').value)||3; const reps = parseInt(document.getElementById('ex-reps').value)||12; const rest = parseInt(document.getElementById('ex-rest').value)||60; if(currentWk.find(w=>w.id===e.id)) { toast('Already in workout!','#6b7a99'); closeExModal(); return; } currentWk.push({...e, sets, reps, rest}); closeExModal(); filterExercises(); // refresh grid to show ✓ badges renderWkBuilder(); toast(`✅ ${e.name} added to workout!`,'#16a34a'); } // ── Render workout builder ──────────────────────────────────────────────────── function renderWkBuilder() { const list = document.getElementById('wk-exercise-list'); if(!list) return; if(!currentWk.length) { list.innerHTML = '
No exercises yet. Browse the Exercise Library and click "Add to Current Workout".
'; return; } list.innerHTML = currentWk.map((e,i)=>{ const bpColor = BODY_PARTS_WK.find(b=>b.id===e.bodyPart)?.color||'#6b7a99'; return `
${i+1}
${e.name}
${e.bodyPart} · ${e.equipment}
${e.sets}
SETS
×
${e.reps}
REPS
${e.rest}s
REST
`; }).join(''); } function removeFromWk(idx) { currentWk.splice(idx,1); renderWkBuilder(); filterExercises(); } function clearWorkout() { currentWk=[]; renderWkBuilder(); filterExercises(); } // ── Save workout ────────────────────────────────────────────────────────────── function saveWorkout() { if(!currentWk.length){ toast('Add at least one exercise first','#6b7a99'); return; } const name = document.getElementById('wk-name').value.trim()||'My Workout'; const type = document.getElementById('wk-type').value; const wk = { id: Date.now(), name, type, exercises: [...currentWk], created: new Date().toLocaleDateString(), totalSets: currentWk.reduce((s,e)=>s+e.sets,0), estMin: Math.round(currentWk.reduce((s,e)=>s+(e.sets*(e.reps*3+e.rest)),0)/60), }; savedWks.unshift(wk); document.getElementById('wk-count-badge').textContent = savedWks.length; toast(`💾 "${name}" saved!`,'#16a34a'); // Sync to Supabase if (typeof litSaveWorkout !== 'undefined') litSaveWorkout(wk); currentWk=[]; renderWkBuilder(); filterExercises(); renderSavedWorkouts(); } // ── Render saved workouts ───────────────────────────────────────────────────── function renderSavedWorkouts() { const el = document.getElementById('saved-workouts-list'); if(!el) return; if(!savedWks.length) { el.innerHTML='
No saved workouts yet. Build your first one!
'; return; } el.innerHTML = savedWks.map(wk=>`
${wk.name}
${wk.type} ${wk.exercises.length} exercises ~${wk.estMin} min ${wk.totalSets} total sets
${wk.exercises.slice(0,5).map(e=>`
${e.name.split(' ').slice(0,2).join(' ')}
`).join('')} ${wk.exercises.length>5?`
+${wk.exercises.length-5}
`:''}
Created ${wk.created}
`).join(''); // History const hel = document.getElementById('wk-history-list'); if(hel) { hel.innerHTML = wkHistory.length ? wkHistory.map(h=>`
${h.name}${h.date}
✓ Completed ${h.exercises} exercises · ${h.duration} min +${h.xp} XP
`).join('') : '
No workouts logged yet.
'; } } function startWorkout(id) { const wk = savedWks.find(w=>w.id===id); if(!wk) return; // Log the workout wkHistory.unshift({ name: wk.name, date: new Date().toLocaleDateString(), exercises: wk.exercises.length, duration: wk.estMin, xp: wk.totalSets*10 }); toast(`🏋️ "${wk.name}" logged! +${wk.totalSets*10} ATAA coins earned!`,'#16a34a'); // Sync to Supabase if (typeof litLogWorkoutHistory !== 'undefined') litLogWorkoutHistory({name:wk.name, date:new Date().toLocaleDateString(), exercises_count:wk.exercises.length, duration_minutes:wk.estMin, xp_earned:wk.totalSets*10}); renderSavedWorkouts(); } function loadWorkoutToBuilder(id) { const wk = savedWks.find(w=>w.id===id); if(!wk) return; currentWk = [...wk.exercises]; document.getElementById('wk-name').value = wk.name+' (copy)'; renderWkBuilder(); setWkView('builder',document.querySelectorAll('.wk-snb')[1]); } function deleteWorkout(id) { savedWks = savedWks.filter(w=>w.id!==id); document.getElementById('wk-count-badge').textContent = savedWks.length; renderSavedWorkouts(); } // Close exercise modal on backdrop click document.addEventListener('click', e=>{ const modal = document.getElementById('ex-modal'); if(modal && e.target===modal) closeExModal(); }); // ══════════════════════════════════════════════════════════════════════════════ // COURSE CATEGORIES — Role-gated // ══════════════════════════════════════════════════════════════════════════════ const COURSE_CATS = { basics: [ { icon:'🌱', name:'New Member Basics', count:6, progress:80, color:'#16a34a' }, { icon:'❤️', name:'Health Basics', count:8, progress:65, color:'#e53e3e' }, { icon:'🧬', name:'VIVA Anatomy Series', count:10, progress:40, color:'#8b5cf6' }, { icon:'🧠', name:'Mental Health & Resilience', count:7, progress:20, color:'#0ea5e9' }, { icon:'🌍', name:'My Healthy Global Citizen', count:5, progress:0, color:'#059669' }, { icon:'👨‍👩‍👧', name:'Fit FamilyFORCE', count:6, progress:0, color:'#f59e0b' }, { icon:'⚕️', name:'Specialty Health', count:9, progress:0, color:'#e8450a' }, ], ambassador: [ { icon:'🚀', name:"Ambassador's Bootcamp", count:8, progress:55, color:'#e8450a' }, { icon:'🫀', name:'360 Human Explorer', count:12, progress:30, color:'#8b5cf6', link:'human360' }, { icon:'🏃', name:'Physical Education', count:7, progress:0, color:'#16a34a' }, { icon:'💪', name:'Form and Function', count:6, progress:0, color:'#0ea5e9' }, { icon:'💡', name:'My Healthy IDEAS', count:5, progress:0, color:'#f59e0b' }, { icon:'🩺', name:'Healthcare Pre-Apprentice', count:10, progress:0, color:'#e53e3e' }, { icon:'🥗', name:'Nutrition Ambassador', count:8, progress:0, color:'#059669' }, { icon:'💊', name:'Opioids Ambassador', count:6, progress:0, color:'#dc2626' }, { icon:'🫁', name:'Certified Asthma Patient', count:4, progress:0, color:'#0284c7' }, ], groupleader: [ { icon:'🎖️', name:'Group Leader Bootcamp', count:6, progress:70, color:'#e8450a' }, { icon:'🗺️', name:'My Healthy IDEAS Gameplan', count:8, progress:25, color:'#8b5cf6' }, { icon:'📝', name:'Lessons Learned', count:5, progress:0, color:'#16a34a' }, ], commdir: [ { icon:'🗺️', name:'Community Director Bootcamp', count:8, progress:0, color:'#0ea5e9' }, ], }; // Which roles see which categories const CAT_ROLE_MAP = { basics: ['basic','ambassador','groupleader','commdir','instructor','school','regional','super','coach'], ambassador: ['ambassador','instructor','regional','super'], groupleader: ['groupleader','instructor','regional','super','commdir'], commdir: ['commdir','regional','super'], }; let currentCatTab = 'basics'; function initCourseCategories() { // Show/hide category tabs based on role const role = window.currentRole || 'basic'; document.querySelectorAll('#cat-tabs .cat-tb').forEach(btn=>{ const catId = btn.getAttribute('onclick').match(/'(\w+)'/)[1]; const allowed = CAT_ROLE_MAP[catId] || []; btn.style.display = allowed.includes(role) ? '' : 'none'; }); // Default to basics always visible; if not allowed, pick first visible renderCourseCat('basics'); } function setCatTab(id, btn) { currentCatTab = id; document.querySelectorAll('#cat-tabs .cat-tb').forEach(b=>b.classList.remove('active')); btn.classList.add('active'); ['basics','ambassador','groupleader','commdir'].forEach(cat=>{ const el = document.getElementById('cat-'+cat); if(el) el.style.display = cat===id ? '' : 'none'; }); renderCourseCat(id); } function renderCourseCat(catId) { const grid = document.getElementById(catId+'-grid'); if(!grid) return; grid.innerHTML = ''; (COURSE_CATS[catId]||[]).forEach(cat=>{ const card = document.createElement('div'); card.className = 'course-cat-card'; card.onclick = cat.link ? function(){ setTab(cat.link, null); } : function(){ toast('Opening ' + cat.name + '…', cat.color); }; const icon = document.createElement('div'); icon.className = 'cat-icon'; icon.textContent = cat.icon; card.appendChild(icon); const name = document.createElement('div'); name.className = 'cat-name'; name.textContent = cat.name; card.appendChild(name); const countEl = document.createElement('div'); countEl.className = 'cat-count'; countEl.textContent = cat.count + ' courses' + (cat.progress>0 ? ' · ' + cat.progress + '% complete' : ''); card.appendChild(countEl); const bar = document.createElement('div'); bar.className = 'cat-bar'; const fill = document.createElement('div'); fill.className = 'cat-bar-fill'; fill.style.width = cat.progress + '%'; fill.style.background = cat.color; bar.appendChild(fill); card.appendChild(bar); // Accent strip on hover card.style.borderTopColor = cat.color; card.onmouseover = function(){ this.style.borderColor = cat.color; }; card.onmouseout = function(){ this.style.borderColor = '#e2e8f0'; this.style.borderTopColor = cat.color; }; grid.appendChild(card); }); } // ══════════════════════════════════════════════════════════════════════════════ // RESOURCES TAB — Blog, Announcements, Calendar // ══════════════════════════════════════════════════════════════════════════════ const MOCK_BLOG = [ { cat:'Health Education', title:'The Power of the 360 Human: How Anatomy Visualization Transforms Learning', excerpt:'Interactive 3D anatomy is changing how students understand the body. Here is what the research shows about visual learning and retention.', date:'Mar 1, 2026', author:'Dr. Rob Gillio', readMin:5 }, { cat:'Community', title:'Ambassador Spotlight: Maria Torres Brings Health Literacy to Rural Arizona', excerpt:'Meet one of our most impactful community ambassadors and learn how she is reaching families who have never had a health educator in their community.', date:'Feb 24, 2026', author:'FFH Team', readMin:4 }, { cat:'Nutrition', title:'Understanding Macronutrients: Protein, Carbs, and Fat Explained Simply', excerpt:'Confused about macros? Our latest guide breaks down what every nutrient does, how much you need, and how to build meals around your health goals.', date:'Feb 18, 2026', author:'Dr. Rob Gillio', readMin:6 }, { cat:'Wellness', title:'Why Sleep Is the Most Underrated Health Metric — And How to Track It', excerpt:'Most people track steps and calories. But emerging research shows sleep quality may be the single biggest predictor of long-term health outcomes.', date:'Feb 12, 2026', author:'FFH Team', readMin:7 }, { cat:'Mental Health', title:'Resilience in Schools: The FFH Mental Health & Resilience Curriculum', excerpt:'We are rolling out our new mental health module to 12 school districts this spring. Here is what teachers and counselors need to know.', date:'Feb 5, 2026', author:'Dr. Rob Gillio', readMin:5 }, { cat:'Platform', title:'New Workout Feature Now Live: Track Your Fitness with Animated Exercise Demos', excerpt:'Today we are launching the Workout Club feature inside the Force for Health dashboard. Browse 1,300+ exercises with animated demos and build custom workouts.', date:'Jan 29, 2026', author:'FFH Team', readMin:3 }, ]; const MOCK_ANNOUNCE = [ { type:'info', icon:'📣', title:'Spring Ambassador Cohort Now Enrolling', body:'Applications are open for the Spring 2026 Ambassador cohort. Deadline is March 31. Contact your regional director to apply.', date:'Mar 3, 2026' }, { type:'success', icon:'✅', title:'New Course Released: Opioids Ambassador', body:'The Opioids Ambassador course is now available. This 6-module course covers harm reduction, naloxone training, and community response frameworks.', date:'Feb 28, 2026' }, { type:'warning', icon:'⚠️', title:'Scheduled Maintenance — Mar 8, 2–4am EST', body:'The dashboard will be briefly offline for server maintenance. No action required. All saved data will be preserved.', date:'Feb 27, 2026' }, { type:'info', icon:'🎉', title:'FFH Hits 10,000 Health Literacy Completions!', body:'Our community just crossed 10,000 course completions since launch. Thank you to every student, ambassador, and group leader who made this possible.', date:'Feb 20, 2026' }, ]; const ANNOUNCE_COLORS = { info:'#0ea5e9', success:'#16a34a', warning:'#f59e0b' }; const CAL_EVENTS = [ { date:'2026-03-08', title:'System Maintenance', color:'#f59e0b' }, { date:'2026-03-10', title:'Ambassador Webinar', color:'#8b5cf6' }, { date:'2026-03-15', title:'Spring Cohort Kickoff', color:'#16a34a' }, { date:'2026-03-18', title:'Dr. Rob Live Q&A', color:'#e8450a' }, { date:'2026-03-22', title:'Group Leader Summit', color:'#0ea5e9' }, { date:'2026-03-25', title:'Nutrition Workshop', color:'#059669' }, { date:'2026-03-31', title:'Ambassador Application Deadline', color:'#dc2626' }, { date:'2026-04-05', title:'New Course Launch: VIVA Series', color:'#8b5cf6' }, ]; let calYear = 2026, calMonth = 2; // 0-indexed, so 2 = March function initResources() { renderBlog(); renderAnnouncements(); renderCalendar(); } function setResTab(id, btn) { document.querySelectorAll('#res-tabs .res-tb').forEach(b=>b.classList.remove('active')); btn.classList.add('active'); ['blog','announcements','calendar'].forEach(tab=>{ const el = document.getElementById('res-'+tab); if(el) el.style.display = tab===id ? '' : 'none'; }); } function renderBlog() { const el = document.getElementById('blog-feed-list'); if(!el) return; el.innerHTML = ''; MOCK_BLOG.forEach(post=>{ const card = document.createElement('div'); card.className = 'blog-card'; card.innerHTML = '
' + post.cat + '
' + '
' + post.title + '
' + '
' + post.excerpt + '
' + '
' + post.author + ' · ' + post.date + ' · ' + post.readMin + ' min read
'; el.appendChild(card); }); } function renderAnnouncements() { const el = document.getElementById('announce-list'); if(!el) return; el.innerHTML = ''; MOCK_ANNOUNCE.forEach(a=>{ const card = document.createElement('div'); card.className = 'announce-card'; card.style.borderLeftColor = ANNOUNCE_COLORS[a.type]||'var(--orange)'; card.innerHTML = '
' + '' + a.icon + '' + '' + a.title + '' + '' + a.date + '' + '
' + '
' + a.body + '
'; el.appendChild(card); }); } function renderCalendar() { const label = document.getElementById('cal-month-label'); if(!label) return; const monthNames = ['January','February','March','April','May','June','July','August','September','October','November','December']; label.textContent = monthNames[calMonth] + ' ' + calYear; const grid = document.getElementById('cal-grid'); if(!grid) return; grid.innerHTML = ''; const firstDay = new Date(calYear, calMonth, 1).getDay(); const daysInMonth = new Date(calYear, calMonth+1, 0).getDate(); const daysInPrev = new Date(calYear, calMonth, 0).getDate(); const today = new Date(); // Build 6-week grid for(let i=0; i<42; i++){ let day, thisMonth=true; if(i < firstDay){ day = daysInPrev - firstDay + i + 1; thisMonth=false; } else if(i >= firstDay + daysInMonth){ day = i - firstDay - daysInMonth + 1; thisMonth=false; } else{ day = i - firstDay + 1; } const cell = document.createElement('div'); cell.className = 'cal-day' + (!thisMonth?' other-month':''); const isToday = thisMonth && day===today.getDate() && calMonth===today.getMonth() && calYear===today.getFullYear(); if(isToday) cell.className += ' today'; const dateEl = document.createElement('div'); dateEl.className = 'cal-date'; dateEl.textContent = day; cell.appendChild(dateEl); // Check for events on this day if(thisMonth){ const dateStr = calYear + '-' + String(calMonth+1).padStart(2,'0') + '-' + String(day).padStart(2,'0'); CAL_EVENTS.filter(e=>e.date===dateStr).forEach(ev=>{ const dot = document.createElement('div'); dot.className = 'cal-event-dot'; dot.textContent = ev.title; dot.style.background = ev.color + '22'; dot.style.color = ev.color; cell.appendChild(dot); }); } grid.appendChild(cell); } // Todays events list const todayEl = document.getElementById('cal-events-today'); if(todayEl){ const todayStr = today.getFullYear() + '-' + String(today.getMonth()+1).padStart(2,'0') + '-' + String(today.getDate()).padStart(2,'0'); const todayEvs = CAL_EVENTS.filter(e=>e.date===todayStr); todayEl.innerHTML = todayEvs.length ? '
Todays Events
' + todayEvs.map(e=>'
'+e.title+'
').join('') : '
No events today.
'; } } function calNav(dir) { calMonth += dir; if(calMonth > 11){ calMonth=0; calYear++; } if(calMonth < 0) { calMonth=11; calYear--; } renderCalendar(); } // ══════════════════════════════════════════════════════════════════════════════ // COMMUNITY FEED // ══════════════════════════════════════════════════════════════════════════════ const RSS_FEEDS = [ { source:'CDC Health News', title:'CDC Updates Guidance on Respiratory Illness Prevention for 2026', excerpt:'New recommendations cover mask use, ventilation standards, and community spread mitigation for schools and workplaces.', date:'Mar 4, 2026', type:'rss', avatar:'🏥', color:'#0ea5e9' }, { source:'WHO Global Health', title:'Global Nutrition Report 2026: Progress and Persistent Gaps', excerpt:'New data shows improvements in child stunting rates but continued challenges with adult obesity and micronutrient deficiency worldwide.', date:'Mar 2, 2026', type:'rss', avatar:'🌍', color:'#059669' }, { source:'NIH Research', title:'Study Links Daily Walking to 30% Reduction in Cardiovascular Risk', excerpt:'A landmark NIH-funded study of 45,000 adults confirms what health educators have long advocated — daily movement matters more than gym sessions.', date:'Feb 27, 2026', type:'rss', avatar:'🔬', color:'#8b5cf6' }, ]; const FFH_POSTS = [ { source:'Force for Health', title:'🎉 Ambassador Maria Torres reaches 500 health literacy completions in her community!', excerpt:'Huge congratulations to Maria and her Group Leader team in rural Arizona. This is what the Force for Health mission looks like in action.', date:'Mar 3, 2026', type:'ffh', avatar:'💚', color:'#16a34a' }, { source:'Force for Health', title:'New course drop: Certified Asthma Patient is live!', excerpt:'The Certified Asthma Patient course is now available in the Ambassador track. 4 modules covering diagnosis, management, and advocacy.', date:'Feb 28, 2026', type:'ffh', avatar:'💚', color:'#e8450a' }, ]; let communityUserPosts = []; let commFilter = 'all'; let pendingMediaDataURL = null; // base64 data URL of selected file let pendingMediaType = null; // 'image' | 'video' | 'gif' function initCommunityFeed() { updateComposeForRole(); renderCommunityFeed(); } function setCommTab(id, btn) { commFilter = id; document.querySelectorAll('#comm-tabs .res-tb').forEach(b=>b.classList.remove('active')); btn.classList.add('active'); renderCommunityFeed(); } // ── Media upload handler ────────────────────────────────────────────────────── function handleMediaUpload(event) { const file = event.target.files[0]; if(!file) return; const isVideo = file.type.startsWith('video/'); const isGif = file.type === 'image/gif'; const isImage = file.type.startsWith('image/') && !isGif; pendingMediaType = isVideo ? 'video' : isGif ? 'gif' : 'image'; const reader = new FileReader(); reader.onload = function(e) { pendingMediaDataURL = e.target.result; const preview = document.getElementById('media-preview'); const imgEl = document.getElementById('media-preview-img'); const vidEl = document.getElementById('media-preview-vid'); const badge = document.getElementById('media-type-badge'); imgEl.style.display = 'none'; vidEl.style.display = 'none'; if(isVideo) { vidEl.src = pendingMediaDataURL; vidEl.style.display = 'block'; badge.textContent = '🎬 VIDEO'; } else { imgEl.src = pendingMediaDataURL; imgEl.style.display = 'block'; badge.textContent = isGif ? '🎞️ GIF' : '🖼️ PHOTO'; } preview.style.display = 'block'; }; reader.readAsDataURL(file); // Reset input so same file can be re-selected event.target.value = ''; } function clearMedia() { pendingMediaDataURL = null; pendingMediaType = null; const preview = document.getElementById('media-preview'); if(preview) preview.style.display = 'none'; const imgEl = document.getElementById('media-preview-img'); const vidEl = document.getElementById('media-preview-vid'); if(imgEl) { imgEl.src=''; imgEl.style.display='none'; } if(vidEl) { vidEl.src=''; vidEl.style.display='none'; } } function updateStickyLabel() { const cb = document.getElementById('sticky-toggle'); const label = document.getElementById('sticky-label'); const icon = document.getElementById('sticky-icon'); const text = document.getElementById('sticky-text'); if(cb.checked) { label.classList.add('active'); icon.textContent = '📌'; text.textContent = 'Pinned!'; } else { label.classList.remove('active'); icon.textContent = '📌'; text.textContent = 'Pin to top'; } } // ══════════════════════════════════════════════════════════════════════════════ // POLL SYSTEM — Super Admin only // ══════════════════════════════════════════════════════════════════════════════ let pollBuilderOpen = false; let pollOptionCount = 0; const POLL_COLORS = ['#6366f1','#e8450a','#16a34a','#0ea5e9','#f59e0b','#ec4899']; function updateComposeForRole() { const role = window.currentRole || 'basic'; const pollBtn = document.getElementById('poll-toggle-btn'); if(pollBtn) { pollBtn.style.display = role === 'super' ? 'flex' : 'none'; } // Update avatar letter/color to match role const av = document.getElementById('compose-avatar'); if(av) { const roleMap = { super: { letter:'SA', bg:'#6366f1' }, commdir: { letter:'CD', bg:'#0ea5e9' }, school: { letter:'SA', bg:'#059669' }, instructor: { letter:'GL', bg:'#f59e0b' }, basic: { letter:'A', bg:'var(--orange)' }, ambassador: { letter:'AM', bg:'#8b5cf6' }, groupleader: { letter:'GL', bg:'#f59e0b' }, coach: { letter:'C', bg:'#16a34a' }, }; const r = roleMap[role] || { letter:'A', bg:'var(--orange)' }; av.textContent = r.letter; av.style.background = r.bg; av.style.fontSize = r.letter.length > 1 ? '11px' : '15px'; } // Hide poll builder if switching away from super if(role !== 'super' && pollBuilderOpen) { togglePollBuilder(true); // force close } } function togglePollBuilder(forceClose) { const builder = document.getElementById('poll-builder'); if(!builder) return; if(forceClose || pollBuilderOpen) { builder.style.display = 'none'; pollBuilderOpen = false; const btn = document.getElementById('poll-toggle-btn'); if(btn) { btn.style.background='#fff'; btn.style.borderColor='#c4b5fd'; } } else { builder.style.display = 'block'; pollBuilderOpen = true; const btn = document.getElementById('poll-toggle-btn'); if(btn) { btn.style.background='#f5f3ff'; btn.style.borderColor='#6366f1'; } // Init with 2 default options if empty const list = document.getElementById('poll-options-list'); if(list && list.children.length === 0) { addPollOption('Yes'); addPollOption('No'); } document.getElementById('poll-question')?.focus(); } } function addPollOption(defaultVal) { const list = document.getElementById('poll-options-list'); if(!list) return; if(list.children.length >= 6) { toast('Maximum 6 options', '#6b7a99'); return; } pollOptionCount++; const idx = list.children.length; const color = POLL_COLORS[idx % POLL_COLORS.length]; const row = document.createElement('div'); row.style.cssText = 'display:flex;align-items:center;gap:7px'; row.dataset.optIdx = pollOptionCount; const colorDot = document.createElement('div'); colorDot.style.cssText = 'width:10px;height:10px;border-radius:50%;flex-shrink:0;background:'+color; row.appendChild(colorDot); const inp = document.createElement('input'); inp.type = 'text'; inp.className = 'poll-opt-input'; inp.placeholder = 'Option ' + (idx + 1); inp.value = defaultVal || ''; row.appendChild(inp); const delBtn = document.createElement('button'); delBtn.textContent = '×'; delBtn.style.cssText = 'background:none;border:none;color:#6b7a99;font-size:18px;cursor:pointer;padding:2px 6px;border-radius:6px;flex-shrink:0;font-family:inherit'; delBtn.onmouseover = function(){ this.style.color='#e53e3e'; }; delBtn.onmouseout = function(){ this.style.color='#6b7a99'; }; delBtn.onclick = function() { list.removeChild(row); const addBtn = document.getElementById('poll-add-opt-btn'); if(addBtn) addBtn.style.display = list.children.length < 6 ? '' : 'none'; // Refresh placeholder labels Array.from(list.children).forEach((r,i)=>{ const input = r.querySelector('input'); if(input && !input.value) input.placeholder = 'Option '+(i+1); const dot = r.querySelector('div'); if(dot) dot.style.background = POLL_COLORS[i % POLL_COLORS.length]; }); }; row.appendChild(delBtn); list.appendChild(row); // Hide add button at max const addBtn = document.getElementById('poll-add-opt-btn'); if(addBtn) addBtn.style.display = list.children.length >= 6 ? 'none' : ''; inp.focus(); } function collectPollData() { const question = document.getElementById('poll-question')?.value.trim(); if(!question) { toast('Add a poll question first', '#6b7a99'); return null; } const optInputs = document.querySelectorAll('#poll-options-list input'); const options = Array.from(optInputs).map(i=>i.value.trim()).filter(Boolean); if(options.length < 2) { toast('Add at least 2 poll options', '#6b7a99'); return null; } const duration = parseInt(document.getElementById('poll-duration')?.value || '3'); const multi = document.getElementById('poll-multi')?.checked || false; const anon = document.getElementById('poll-anon')?.checked || true; const expires = new Date(Date.now() + duration * 864e5); return { question, options: options.map((text, i) => ({ text, votes: 0, color: POLL_COLORS[i % POLL_COLORS.length], })), multi, anon, expires: expires.toLocaleDateString('en-US',{month:'short',day:'numeric',year:'numeric'}), totalVotes: 0, userVotes: [], // indices the current user voted for closed: false, }; } function resetPollBuilder() { const q = document.getElementById('poll-question'); if(q) q.value = ''; const list = document.getElementById('poll-options-list'); if(list) list.innerHTML = ''; pollOptionCount = 0; const addBtn = document.getElementById('poll-add-opt-btn'); if(addBtn) addBtn.style.display = ''; const dur = document.getElementById('poll-duration'); if(dur) dur.value = '3'; const multi = document.getElementById('poll-multi'); if(multi) multi.checked = false; const anon = document.getElementById('poll-anon'); if(anon) anon.checked = true; } // ── Voting handler (called from rendered feed cards) ───────────────────────── function castVote(postId, optionIndex) { const post = communityUserPosts.find(p => p.id === postId); if(!post || !post.poll || post.poll.closed) return; const poll = post.poll; const alreadyVoted = poll.userVotes.includes(optionIndex); if(!poll.multi) { // Single choice — toggle off previous vote poll.options.forEach((o,i)=>{ if(poll.userVotes.includes(i)) o.votes = Math.max(0, o.votes-1); }); poll.totalVotes = Math.max(0, poll.totalVotes - poll.userVotes.length); poll.userVotes = alreadyVoted ? [] : [optionIndex]; if(!alreadyVoted) { poll.options[optionIndex].votes++; poll.totalVotes++; } } else { // Multi-choice — toggle individual option if(alreadyVoted) { poll.userVotes = poll.userVotes.filter(v => v !== optionIndex); poll.options[optionIndex].votes = Math.max(0, poll.options[optionIndex].votes - 1); poll.totalVotes = Math.max(0, poll.totalVotes - 1); } else { poll.userVotes.push(optionIndex); poll.options[optionIndex].votes++; poll.totalVotes++; } } renderCommunityFeed(); } function closePoll(postId) { const post = communityUserPosts.find(p => p.id === postId); if(post && post.poll) { post.poll.closed = true; renderCommunityFeed(); toast('Poll closed', '#6b7a99'); } } // ── Build poll card DOM element ─────────────────────────────────────────────── function buildPollCard(p) { const card = document.createElement('div'); card.className = 'poll-card comm-post' + (p.sticky ? ' sticky-post' : ''); if(p.sticky) { const banner = document.createElement('div'); banner.className = 'sticky-banner'; banner.innerHTML = '📌 Pinned Post'; card.appendChild(banner); } // Header const header = document.createElement('div'); header.style.cssText = 'display:flex;align-items:center;gap:10px;margin-bottom:12px'; const av = document.createElement('div'); av.className = 'post-avatar'; av.style.cssText = 'background:#6366f122;color:#6366f1;font-weight:800;font-size:11px'; av.textContent = 'SA'; header.appendChild(av); const meta = document.createElement('div'); meta.style.flex='1'; meta.innerHTML = '
Super Admin
' + '
' + p.date + (p.poll.closed ? ' · Closed' : ' · Closes ' + p.poll.expires) + '
'; header.appendChild(meta); // Poll badge const badge = document.createElement('span'); badge.textContent = '📊 Poll'; badge.style.cssText = 'font-size:10px;font-weight:800;padding:3px 9px;border-radius:20px;background:#6366f1;color:#fff'; header.appendChild(badge); // Controls for super admin if(window.currentRole === 'super') { const ctrlWrap = document.createElement('div'); ctrlWrap.style.cssText = 'display:flex;gap:4px;margin-left:6px'; const pinBtn = document.createElement('button'); pinBtn.title = p.sticky ? 'Unpin' : 'Pin to top'; pinBtn.textContent = p.sticky ? '📌' : '📍'; pinBtn.style.cssText = 'background:none;border:none;font-size:15px;cursor:pointer;padding:2px 4px;border-radius:6px'; pinBtn.onclick = function(){ p.sticky = !p.sticky; renderCommunityFeed(); toast(p.sticky?'📌 Poll pinned!':'Poll unpinned','#6b7a99'); }; ctrlWrap.appendChild(pinBtn); if(!p.poll.closed) { const closeBtn = document.createElement('button'); closeBtn.title = 'Close poll'; closeBtn.textContent = '🔒'; closeBtn.style.cssText = 'background:none;border:none;font-size:15px;cursor:pointer;padding:2px 4px;border-radius:6px'; closeBtn.onclick = function(){ closePoll(p.id); }; ctrlWrap.appendChild(closeBtn); } const delBtn = document.createElement('button'); delBtn.title = 'Delete poll'; delBtn.textContent = '🗑️'; delBtn.style.cssText = 'background:none;border:none;font-size:14px;cursor:pointer;padding:2px 4px;border-radius:6px'; delBtn.onmouseover = function(){ this.style.background='#fee2e2'; }; delBtn.onmouseout = function(){ this.style.background='none'; }; delBtn.onclick = function(){ const idx = communityUserPosts.indexOf(p); if(idx>-1) communityUserPosts.splice(idx,1); renderCommunityFeed(); }; ctrlWrap.appendChild(delBtn); header.appendChild(ctrlWrap); } card.appendChild(header); // Question const q = document.createElement('div'); q.style.cssText = 'font-size:15px;font-weight:800;color:var(--navy);margin-bottom:14px;line-height:1.35'; q.textContent = p.poll.question; card.appendChild(q); // Post body text (if any) if(p.title) { const body = document.createElement('div'); body.style.cssText = 'font-size:13px;color:#444;margin-bottom:12px;line-height:1.5'; body.textContent = p.title; card.appendChild(body); } // Options const poll = p.poll; const hasVoted = poll.userVotes.length > 0; const showResults = hasVoted || poll.closed; poll.options.forEach((opt, i) => { const pct = poll.totalVotes > 0 ? Math.round((opt.votes / poll.totalVotes) * 100) : 0; const isVoted = poll.userVotes.includes(i); const bar = document.createElement('div'); bar.className = 'poll-opt-bar' + (isVoted ? ' poll-opt-voted' : ''); bar.style.cssText = 'background:#f4f6fa;border-radius:8px;overflow:hidden;margin-bottom:6px;position:relative;height:38px;' + (!poll.closed ? 'cursor:pointer' : 'cursor:default'); if(!poll.closed) { bar.onmouseover = function(){ this.style.opacity='.85'; }; bar.onmouseout = function(){ this.style.opacity='1'; }; bar.onclick = function(){ castVote(p.id, i); }; } const fill = document.createElement('div'); fill.className = 'poll-opt-fill'; fill.style.cssText = 'position:absolute;inset:0;width:' + (showResults ? pct+'%' : '0%') + ';background:' + opt.color + '22;border-radius:8px;transition:width .6s ease'; bar.appendChild(fill); const labelRow = document.createElement('div'); labelRow.className = 'poll-opt-label'; const leftSide = document.createElement('div'); leftSide.style.cssText = 'display:flex;align-items:center;gap:7px'; const dot = document.createElement('div'); dot.style.cssText = 'width:8px;height:8px;border-radius:50%;background:'+opt.color+';flex-shrink:0'; leftSide.appendChild(dot); const optText = document.createElement('span'); optText.textContent = opt.text; optText.style.cssText = 'color:var(--navy);' + (isVoted?'font-weight:800':'font-weight:600'); leftSide.appendChild(optText); if(isVoted) { const tick = document.createElement('span'); tick.textContent = '✓'; tick.style.cssText = 'font-size:10px;color:'+opt.color+';font-weight:800'; leftSide.appendChild(tick); } labelRow.appendChild(leftSide); if(showResults) { const pctEl = document.createElement('span'); pctEl.textContent = pct+'%' + (opt.votes ? ' ('+opt.votes+')' : ''); pctEl.style.cssText = 'font-size:11px;color:var(--mid);font-weight:700'; labelRow.appendChild(pctEl); } else { const hint = document.createElement('span'); hint.textContent = poll.closed ? '' : 'Click to vote'; hint.style.cssText = 'font-size:10px;color:#c4b5fd'; labelRow.appendChild(hint); } bar.appendChild(labelRow); card.appendChild(bar); }); // Poll meta const metaRow = document.createElement('div'); metaRow.className = 'poll-meta'; metaRow.innerHTML = '🗳️ ' + poll.totalVotes + ' vote' + (poll.totalVotes!==1?'s':'') + '' + (poll.multi ? '✅ Multiple choice' : '') + (poll.anon ? '🔒 Anonymous' : '') + (poll.closed ? '🔒 Closed' : ''); if(!showResults && !poll.closed) { const seeResults = document.createElement('button'); seeResults.textContent = 'See results'; seeResults.style.cssText = 'background:none;border:none;font-size:11px;color:#6366f1;font-weight:700;cursor:pointer;padding:0;font-family:inherit'; seeResults.onclick = function(){ poll.userVotes = [-1]; renderCommunityFeed(); }; metaRow.appendChild(seeResults); } card.appendChild(metaRow); return card; } // ══════════════════════════════════════════════════════════════════════════════ // POST COMMUNITY (updated to handle polls) // ══════════════════════════════════════════════════════════════════════════════ function postCommunity() { const input = document.getElementById('comm-post-input'); const sticky = document.getElementById('sticky-toggle')?.checked || false; const text = input?.value.trim(); const isPoll = pollBuilderOpen; if(isPoll) { const pollData = collectPollData(); if(!pollData) return; const post = { id: Date.now(), source: 'Super Admin', title: text || '', date: 'Just now', type: 'user', avatar: 'SA', color: '#6366f1', isUser: true, sticky: sticky, media: null, mediaType: null, poll: pollData, }; communityUserPosts.unshift(post); if(input) input.value = ''; resetPollBuilder(); togglePollBuilder(true); const cb = document.getElementById('sticky-toggle'); if(cb) { cb.checked=false; updateStickyLabel(); } renderCommunityFeed(); toast('📊 Poll published!', '#6366f1'); return; } if(!text && !pendingMediaDataURL) { toast('Add some text or media first!', '#6b7a99'); return; } const post = { id: Date.now(), source: 'Alex (You)', title: text || '', excerpt: '', date: 'Just now', type: 'user', avatar: 'A', color: 'var(--orange)', isUser: true, sticky: sticky, media: pendingMediaDataURL || null, mediaType:pendingMediaType || null, poll: null, }; communityUserPosts.unshift(post); if(input) input.value = ''; clearMedia(); const cb = document.getElementById('sticky-toggle'); if(cb) { cb.checked = false; updateStickyLabel(); } renderCommunityFeed(); toast(sticky ? '📌 Post pinned to top!' : 'Posted to community feed!', '#16a34a'); } // ── Render feed ─────────────────────────────────────────────────────────────── function renderCommunityFeed() { const el = document.getElementById('community-feed-list'); if(!el) return; el.innerHTML = ''; let posts = [...communityUserPosts, ...FFH_POSTS, ...RSS_FEEDS]; if(commFilter === 'ffh') posts = posts.filter(p => p.type==='ffh' || p.type==='user'); if(commFilter === 'rss') posts = posts.filter(p => p.type==='rss'); // Sticky posts always float to top within their filtered set posts.sort((a, b) => (b.sticky ? 1 : 0) - (a.sticky ? 1 : 0)); posts.forEach(p => { // Poll posts get their own renderer if(p.poll) { el.appendChild(buildPollCard(p)); return; } const card = document.createElement('div'); card.className = 'comm-post' + (p.sticky ? ' sticky-post' : ''); // Sticky banner if(p.sticky) { const banner = document.createElement('div'); banner.className = 'sticky-banner'; banner.innerHTML = '📌 Pinned Post'; card.appendChild(banner); } // Header row const header = document.createElement('div'); header.style.cssText = 'display:flex;align-items:flex-start;gap:10px;margin-bottom:8px'; const av = document.createElement('div'); av.className = 'post-avatar'; av.style.background = p.isUser ? 'var(--orange)' : p.color + '22'; av.style.color = p.isUser ? '#fff' : p.color; av.textContent = p.avatar; header.appendChild(av); const meta = document.createElement('div'); meta.style.flex = '1'; meta.innerHTML = '
' + p.source + '
' + '
' + p.date + (p.type==='rss' ? ' · Health News' : '') + '
'; header.appendChild(meta); // Badges row (RSS / Sticky toggle for user posts) const badgeRow = document.createElement('div'); badgeRow.style.cssText = 'display:flex;gap:5px;align-items:center'; if(p.type === 'rss') { const rssBadge = document.createElement('span'); rssBadge.textContent = 'RSS'; rssBadge.style.cssText = 'font-size:9px;font-weight:700;padding:2px 6px;border-radius:20px;background:#f4f6fa;color:var(--mid)'; badgeRow.appendChild(rssBadge); } if(p.isUser) { // Inline pin/unpin button const pinBtn = document.createElement('button'); pinBtn.title = p.sticky ? 'Unpin post' : 'Pin to top'; pinBtn.textContent = p.sticky ? '📌' : '📍'; pinBtn.style.cssText = 'background:none;border:none;font-size:16px;cursor:pointer;padding:2px 4px;border-radius:6px;transition:background .15s'; pinBtn.onmouseover = function(){ this.style.background='#f4f6fa'; }; pinBtn.onmouseout = function(){ this.style.background='none'; }; pinBtn.onclick = function() { p.sticky = !p.sticky; renderCommunityFeed(); toast(p.sticky ? '📌 Post pinned!' : 'Post unpinned', '#6b7a99'); }; badgeRow.appendChild(pinBtn); // Delete button const delBtn = document.createElement('button'); delBtn.title = 'Delete post'; delBtn.textContent = '🗑️'; delBtn.style.cssText = 'background:none;border:none;font-size:14px;cursor:pointer;padding:2px 4px;border-radius:6px;transition:background .15s'; delBtn.onmouseover = function(){ this.style.background='#fee2e2'; }; delBtn.onmouseout = function(){ this.style.background='none'; }; delBtn.onclick = function() { const idx = communityUserPosts.indexOf(p); if(idx > -1) communityUserPosts.splice(idx, 1); renderCommunityFeed(); toast('Post deleted', '#6b7a99'); }; badgeRow.appendChild(delBtn); } if(badgeRow.children.length) header.appendChild(badgeRow); card.appendChild(header); // Post text if(p.title) { const title = document.createElement('div'); title.style.cssText = 'font-size:13px;font-weight:' + (p.isUser ? '500' : '700') + ';color:var(--navy);margin-bottom:4px;line-height:1.5'; title.textContent = p.title; card.appendChild(title); } if(p.excerpt) { const ex = document.createElement('div'); ex.style.cssText = 'font-size:12px;color:var(--mid);line-height:1.5'; ex.textContent = p.excerpt; card.appendChild(ex); } // Media attachment if(p.media) { const mediaWrap = document.createElement('div'); mediaWrap.className = 'post-media'; if(p.mediaType === 'video') { const vid = document.createElement('video'); vid.src = p.media; vid.controls = true; vid.style.cssText = 'width:100%;max-height:320px;border-radius:10px;display:block'; mediaWrap.appendChild(vid); } else { const img = document.createElement('img'); img.src = p.media; img.alt = 'Post media'; img.style.cssText = 'width:100%;max-height:320px;object-fit:cover;border-radius:10px;display:block'; // GIF badge if(p.mediaType === 'gif') { const gifBadge = document.createElement('div'); gifBadge.textContent = 'GIF'; gifBadge.style.cssText = 'display:inline-block;background:rgba(0,0,0,.6);color:#fff;font-size:9px;font-weight:800;padding:2px 6px;border-radius:4px;position:relative;top:-4px;left:8px;margin-bottom:-4px'; mediaWrap.appendChild(gifBadge); } mediaWrap.appendChild(img); } card.appendChild(mediaWrap); } // Action bar const actions = document.createElement('div'); actions.style.cssText = 'display:flex;gap:14px;margin-top:10px;padding-top:8px;border-top:1px solid #f4f6fa'; const likeCount = Math.floor(Math.random() * 12); [ { label: '👍 Like', count: likeCount }, { label: '💬 Comment', count: null }, { label: '↗️ Share', count: null }, ].forEach(({label, count}) => { const btn = document.createElement('button'); btn.textContent = label + (count ? ' ' + count : ''); btn.style.cssText = 'background:none;border:none;font-size:12px;color:var(--mid);cursor:pointer;font-family:inherit;padding:2px 0;font-weight:600;transition:color .15s'; btn.onmouseover = function(){ this.style.color = 'var(--orange)'; }; btn.onmouseout = function(){ this.style.color = 'var(--mid)'; }; btn.onclick = function() { toast(label.split(' ')[1] + '!', '#16a34a'); }; actions.appendChild(btn); }); card.appendChild(actions); el.appendChild(card); }); // Empty state if(!posts.length) { el.innerHTML = '
No posts yet. Be the first to share!
'; } } // ══════════════════════════════════════════════════════════════════════════════ // AMBASSADOR TAB SWITCHER // ══════════════════════════════════════════════════════════════════════════════ function setAmbTab(id, btn) { document.querySelectorAll('.tp-amb').forEach(p=>{ p.classList.remove('active-amb'); p.style.display='none'; }); const panel = document.getElementById('tp-'+id); if(panel){ panel.classList.add('active-amb'); panel.style.display='flex'; } document.querySelectorAll('#stabs-ambassador .tb').forEach(b=>b.classList.remove('active')); if(btn) btn.classList.add('active'); } function setCatTabAmb(id, btn) { document.querySelectorAll('#tp-courses-amb [id^="cat-"]').forEach(el=>el.style.display='none'); const panel = document.getElementById('cat-'+id); if(panel) panel.style.display=''; document.querySelectorAll('#tp-courses-amb .cat-tb').forEach(b=>b.classList.remove('active')); if(btn) btn.classList.add('active'); } function initAmbCourses() { // Basics grid for ambassador const bg = document.getElementById('basics-amb-grid'); if(bg && !bg.children.length) renderCourseCatInto(COURSE_CATS.basics, bg); // Ambassador grid const ag = document.getElementById('ambassador-amb-grid'); if(ag && !ag.children.length) renderCourseCatInto(COURSE_CATS.ambassador, ag); } // ── Ambassador Resources tab switcher ───────────────────────────────────────── function setResTabAmb(id, btn) { document.querySelectorAll('#res-tabs-amb .res-tb').forEach(b => b.classList.remove('active')); if(btn) btn.classList.add('active'); ['blog-amb','announcements-amb','calendar-amb'].forEach(tab => { const el = document.getElementById('res-' + tab); if(el) el.style.display = tab === id ? '' : 'none'; }); } // ── Ambassador calendar (shares global calYear/calMonth and CAL_EVENTS) ─────── let calYearAmb = 2026, calMonthAmb = 2; function calNavAmb(dir) { calMonthAmb += dir; if(calMonthAmb > 11){ calMonthAmb = 0; calYearAmb++; } if(calMonthAmb < 0) { calMonthAmb = 11; calYearAmb--; } renderCalendarAmb(); } function renderCalendarAmb() { const label = document.getElementById('cal-month-label-amb'); if(!label) return; const monthNames = ['January','February','March','April','May','June','July','August','September','October','November','December']; label.textContent = monthNames[calMonthAmb] + ' ' + calYearAmb; const grid = document.getElementById('cal-grid-amb'); if(!grid) return; grid.innerHTML = ''; const firstDay = new Date(calYearAmb, calMonthAmb, 1).getDay(); const daysInMonth = new Date(calYearAmb, calMonthAmb+1, 0).getDate(); const daysInPrev = new Date(calYearAmb, calMonthAmb, 0).getDate(); const today = new Date(); for(let i = 0; i < 42; i++) { let day, thisMonth = true; if(i < firstDay){ day = daysInPrev - firstDay + i + 1; thisMonth = false; } else if(i >= firstDay + daysInMonth){ day = i - firstDay - daysInMonth + 1; thisMonth = false; } else{ day = i - firstDay + 1; } const isToday = thisMonth && day === today.getDate() && calMonthAmb === today.getMonth() && calYearAmb === today.getFullYear(); const cell = document.createElement('div'); cell.className = 'cal-day' + (!thisMonth?' other-month':'') + (isToday?' today':''); const dateEl = document.createElement('div'); dateEl.className = 'cal-date'; dateEl.textContent = day; cell.appendChild(dateEl); if(thisMonth) { const dateStr = calYearAmb + '-' + String(calMonthAmb+1).padStart(2,'0') + '-' + String(day).padStart(2,'0'); CAL_EVENTS.filter(e => e.date === dateStr).forEach(ev => { const dot = document.createElement('div'); dot.className = 'cal-event-dot'; dot.textContent = ev.title; dot.style.background = ev.color + '22'; dot.style.color = ev.color; cell.appendChild(dot); }); } grid.appendChild(cell); } const todayEl = document.getElementById('cal-events-today-amb'); if(todayEl) { const todayStr = today.getFullYear() + '-' + String(today.getMonth()+1).padStart(2,'0') + '-' + String(today.getDate()).padStart(2,'0'); const todayEvs = CAL_EVENTS.filter(e => e.date === todayStr); todayEl.innerHTML = todayEvs.length ? '
Todays Events
' + todayEvs.map(e => '
'+e.title+'
').join('') : '
No events today.
'; } } // ── Ambassador Community feed ────────────────────────────────────────────────── let commFilterAmb = 'all'; let pendingMediaAmb = null; let pendingMediaTypeAmb = null; function setCommTabAmb(id, btn) { commFilterAmb = id; document.querySelectorAll('#comm-tabs-amb .res-tb').forEach(b => b.classList.remove('active')); if(btn) btn.classList.add('active'); renderCommunityFeedAmb(); } function handleMediaUploadAmb(event) { const file = event.target.files[0]; if(!file) return; const isVideo = file.type.startsWith('video/'); const isGif = file.type === 'image/gif'; pendingMediaTypeAmb = isVideo ? 'video' : isGif ? 'gif' : 'image'; const reader = new FileReader(); reader.onload = function(e) { pendingMediaAmb = e.target.result; const preview = document.getElementById('media-preview-amb'); const imgEl = document.getElementById('media-preview-img-amb'); const vidEl = document.getElementById('media-preview-vid-amb'); imgEl.style.display = 'none'; vidEl.style.display = 'none'; if(isVideo){ vidEl.src = pendingMediaAmb; vidEl.style.display = 'block'; } else { imgEl.src = pendingMediaAmb; imgEl.style.display = 'block'; } if(preview) preview.style.display = 'block'; }; reader.readAsDataURL(file); event.target.value = ''; } function clearMediaAmb() { pendingMediaAmb = null; pendingMediaTypeAmb = null; const preview = document.getElementById('media-preview-amb'); if(preview) preview.style.display = 'none'; const imgEl = document.getElementById('media-preview-img-amb'); const vidEl = document.getElementById('media-preview-vid-amb'); if(imgEl){ imgEl.src=''; imgEl.style.display='none'; } if(vidEl){ vidEl.src=''; vidEl.style.display='none'; } } function postCommunityAmb() { const input = document.getElementById('comm-post-input-amb'); const text = input?.value.trim(); if(!text && !pendingMediaAmb){ toast('Add some text or media first!','#6b7a99'); return; } communityUserPosts.unshift({ id: Date.now(), source:'Ambassador (You)', title: text||'', excerpt:'', date:'Just now', type:'user', avatar:'AM', color:'#8b5cf6', isUser:true, sticky:false, media: pendingMediaAmb||null, mediaType: pendingMediaTypeAmb||null, poll: null }); if(input) input.value = ''; clearMediaAmb(); renderCommunityFeedAmb(); toast('Posted to community feed!','#16a34a'); } function renderCommunityFeedAmb() { const el = document.getElementById('community-feed-list-amb'); if(!el) return; el.innerHTML = ''; let posts = [...communityUserPosts, ...FFH_POSTS, ...RSS_FEEDS]; if(commFilterAmb === 'ffh') posts = posts.filter(p => p.type==='ffh'||p.type==='user'); if(commFilterAmb === 'rss') posts = posts.filter(p => p.type==='rss'); posts.sort((a,b) => (b.sticky?1:0) - (a.sticky?1:0)); posts.forEach(p => { if(p.poll){ el.appendChild(buildPollCard(p)); return; } const card = document.createElement('div'); card.className = 'comm-post' + (p.sticky?' sticky-post':''); if(p.sticky){ const b=document.createElement('div'); b.className='sticky-banner'; b.innerHTML='📌 Pinned Post'; card.appendChild(b); } const header = document.createElement('div'); header.style.cssText = 'display:flex;align-items:flex-start;gap:10px;margin-bottom:8px'; const av = document.createElement('div'); av.className = 'post-avatar'; av.style.background = p.isUser ? '#8b5cf622' : p.color+'22'; av.style.color = p.isUser ? '#8b5cf6' : p.color; av.textContent = p.avatar; header.appendChild(av); const meta = document.createElement('div'); meta.style.flex='1'; meta.innerHTML = '
'+p.source+'
'+ '
'+p.date+(p.type==='rss'?' · Health News':'')+'
'; header.appendChild(meta); if(p.type==='rss'){ const b=document.createElement('span'); b.textContent='RSS'; b.style.cssText='font-size:9px;font-weight:700;padding:2px 6px;border-radius:20px;background:#f4f6fa;color:var(--mid)'; header.appendChild(b); } card.appendChild(header); if(p.title){ const t=document.createElement('div'); t.style.cssText='font-size:13px;font-weight:'+(p.isUser?'500':'700')+';color:var(--navy);margin-bottom:4px;line-height:1.5'; t.textContent=p.title; card.appendChild(t); } if(p.excerpt){ const x=document.createElement('div'); x.style.cssText='font-size:12px;color:var(--mid);line-height:1.5'; x.textContent=p.excerpt; card.appendChild(x); } if(p.media){ const mw=document.createElement('div'); mw.className='post-media'; if(p.mediaType==='video'){ const v=document.createElement('video'); v.src=p.media; v.controls=true; v.style.cssText='width:100%;max-height:320px;border-radius:10px;display:block'; mw.appendChild(v); } else{ const img=document.createElement('img'); img.src=p.media; img.alt=''; img.style.cssText='width:100%;max-height:320px;object-fit:cover;border-radius:10px;display:block'; mw.appendChild(img); } card.appendChild(mw); } const actions=document.createElement('div'); actions.style.cssText='display:flex;gap:14px;margin-top:10px;padding-top:8px;border-top:1px solid #f4f6fa'; ['👍 Like','💬 Comment','↗️ Share'].forEach(label=>{ const btn=document.createElement('button'); btn.textContent=label; btn.style.cssText='background:none;border:none;font-size:12px;color:var(--mid);cursor:pointer;font-family:inherit;padding:2px 0;font-weight:600'; btn.onclick=function(){ toast(label.split(' ')[1]+'!','#16a34a'); }; actions.appendChild(btn); }); card.appendChild(actions); el.appendChild(card); }); if(!posts.length) el.innerHTML='
No posts yet.
'; } // ── Ambassador AI Tutor ──────────────────────────────────────────────────────── function sendChatAmb() { const input = document.getElementById('chat-in-amb'); const msgs = document.getElementById('chat-msgs-amb'); if(!input || !msgs) return; const text = input.value.trim(); if(!text) return; input.value = ''; // User bubble const userBubble = document.createElement('div'); userBubble.style.cssText = 'align-self:flex-end;background:var(--navy);color:#fff;padding:10px 14px;border-radius:16px 16px 4px 16px;max-width:80%;font-size:13px;line-height:1.5'; userBubble.textContent = text; msgs.appendChild(userBubble); msgs.scrollTop = msgs.scrollHeight; // AI response via Anthropic API const thinking = document.createElement('div'); thinking.style.cssText = 'align-self:flex-start;background:#f4f6fa;padding:10px 14px;border-radius:16px 16px 16px 4px;max-width:80%;font-size:13px;color:var(--mid)'; thinking.textContent = '…'; msgs.appendChild(thinking); msgs.scrollTop = msgs.scrollHeight; fetch('https://api.anthropic.com/v1/messages',{ method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({ model:'claude-sonnet-4-20250514', max_tokens:300, system:'You are the Force for Health AI Tutor — a friendly, encouraging health and wellness coach for Ambassadors. Keep answers concise (2-4 sentences) and focused on health education.', messages:[{role:'user',content:text}] }) }).then(r=>r.json()).then(data=>{ thinking.style.cssText = 'align-self:flex-start;background:#f0fdf4;color:var(--navy);padding:10px 14px;border-radius:16px 16px 16px 4px;max-width:80%;font-size:13px;line-height:1.5'; thinking.textContent = data.content?.[0]?.text || 'I am here to help! Ask me anything about health.'; msgs.scrollTop = msgs.scrollHeight; }).catch(()=>{ thinking.textContent = 'AI Tutor is available — ask me anything about health, wellness, or your Ambassador courses!'; }); } // ── Ambassador full init (called when switching to ambassador role) ──────────── // ══════════════════════════════════════════════════════════════════════════════ // INTERN VIEW // ══════════════════════════════════════════════════════════════════════════════ const INTERN_TASKS = [ { id:1, title:'Draft social media post for FFH launch', cat:'Community', due:'Today 5:00 PM', priority:'high', done:false }, { id:2, title:'Complete Module 4 quiz — Nutrition Basics', cat:'Training', due:'Today 3:00 PM', priority:'high', done:false }, { id:3, title:'Submit weekly reflection journal', cat:'Reporting', due:'Today EOD', priority:'high', done:false }, { id:4, title:'Review Ambassador onboarding materials', cat:'Training', due:'Thu Mar 6', priority:'medium', done:false }, { id:5, title:'Prepare talking points for community event', cat:'Community', due:'Fri Mar 7', priority:'medium', done:false }, { id:6, title:'Update intern progress tracker spreadsheet', cat:'Reporting', due:'Fri Mar 7', priority:'low', done:false }, { id:7, title:'Shadow Group Leader session (virtual)', cat:'Shadowing', due:'Mon Mar 10', priority:'medium', done:false }, { id:8, title:'Read FFH Mission & Impact Report 2025', cat:'Training', due:'Mar 12', priority:'low', done:false }, { id:9, title:'Completed orientation modules', cat:'Training', due:'Feb 28', priority:'high', done:true }, { id:10,title:'Attended week 3 team standup', cat:'Community', due:'Mar 1', priority:'low', done:true }, { id:11,title:'Submitted Week 3 reflection journal', cat:'Reporting', due:'Mar 3', priority:'high', done:true }, { id:12,title:'Posted in community feed (intro post)', cat:'Community', due:'Mar 2', priority:'medium', done:true }, ]; let internTaskFilter = 'all'; let internCalYear = 2026, internCalMonth = 2; let internChatGreeted = false; const INTERN_PRIORITY = { high:'#ef4444', medium:'#f59e0b', low:'#22c55e' }; const INTERN_CAT_ICON = { Training:'📚', Community:'💬', Reporting:'📝', Shadowing:'👁️' }; function setInternTab(id, btn) { document.querySelectorAll('.tp-intern').forEach(p => { p.classList.remove('active-intern'); p.style.display='none'; }); document.querySelectorAll('#stabs-intern .tb').forEach(b => b.classList.remove('active')); const panel = document.getElementById('tp-' + id); if(panel){ panel.classList.add('active-intern'); panel.style.display='flex'; } if(btn) btn.classList.add('active'); setTimeout(() => { if(id === 'tasks-intern') renderInternTasks(); if(id === 'courses-intern') initInternCourses(); if(id === 'resources-intern') initInternResources(); if(id === 'community-intern') renderCommunityFeedIntern(); if(id === 'tutor-intern') initInternTutor(); }, 20); } function filterInternTasks(f, btn) { internTaskFilter = f; document.querySelectorAll('#tp-tasks-intern .pain-loc-btn').forEach(b => b.classList.remove('active')); if(btn) btn.classList.add('active'); renderInternTasks(); } function toggleInternTask(id) { const t = INTERN_TASKS.find(t => t.id === id); if(t) t.done = !t.done; renderInternTasks(); } function renderInternTasks() { const el = document.getElementById('intern-task-list'); if(!el) return; let tasks = INTERN_TASKS; if(internTaskFilter === 'open') tasks = tasks.filter(t => !t.done); if(internTaskFilter === 'done') tasks = tasks.filter(t => t.done); el.innerHTML = tasks.map(t => { const col = INTERN_PRIORITY[t.priority]; const icon = INTERN_CAT_ICON[t.cat] || '📋'; return `
${t.title}
${t.priority.toUpperCase()} ${icon} ${t.cat} 📅 ${t.due}
`; }).join('') || '
No tasks here.
'; } function setCatTabIntern(id, btn) { document.querySelectorAll('#tp-courses-intern .cat-tb').forEach(b => b.classList.remove('active')); if(btn) btn.classList.add('active'); ['basics-intern','intern-intern'].forEach(cat => { const el = document.getElementById('cat-'+cat); if(el) el.style.display = cat === id ? '' : 'none'; }); } function initInternCourses() { const grid = document.getElementById('basics-intern-grid'); if(grid && !grid.children.length) { // Populate from shared COURSES data const cats = window.ROLE_COURSES ? window.ROLE_COURSES['basic'] : []; if(cats && cats.length) { grid.innerHTML = cats.map(c => `
${c.icon||'📗'}
${c.title}
${c.lessons||'—'} lessons
`).join(''); } else { grid.innerHTML = '
Basics courses load here.
'; } } } function setResTabIntern(tab, btn) { document.querySelectorAll('#tp-resources-intern .res-tb').forEach(b => b.classList.remove('active')); if(btn) btn.classList.add('active'); ['res-blog-intern','res-announce-intern','res-calendar-intern'].forEach(id => { const el = document.getElementById(id); if(el) el.style.display = id.includes(tab) ? '' : 'none'; }); if(tab === 'calendar') renderCalendarIntern(); } function initInternResources() { // Blog const blogEl = document.getElementById('blog-feed-list-intern'); if(blogEl && !blogEl.children.length && typeof MOCK_BLOG !== 'undefined') { blogEl.innerHTML = MOCK_BLOG.map(p=>`
${p.tag}
${p.title}
${p.excerpt}
${p.date} · ${p.author}
`).join(''); } const annEl = document.getElementById('announce-list-intern'); if(annEl && !annEl.children.length && typeof MOCK_ANNOUNCE !== 'undefined') { annEl.innerHTML = MOCK_ANNOUNCE.map(a=>`
${a.icon} ${a.title}
${a.body}
${a.date}
`).join(''); } renderCalendarIntern(); } function calNavIntern(dir) { internCalMonth += dir; if(internCalMonth < 0) { internCalMonth = 11; internCalYear--; } if(internCalMonth > 11) { internCalMonth = 0; internCalYear++; } renderCalendarIntern(); } function renderCalendarIntern() { const titleEl = document.getElementById('cal-title-intern'); const gridEl = document.getElementById('cal-grid-intern'); if(!titleEl || !gridEl) return; const months = ['January','February','March','April','May','June','July','August','September','October','November','December']; titleEl.textContent = months[internCalMonth] + ' ' + internCalYear; const first = new Date(internCalYear, internCalMonth, 1).getDay(); const days = new Date(internCalYear, internCalMonth+1, 0).getDate(); const today = new Date(); let html = ['Su','Mo','Tu','We','Th','Fr','Sa'].map(d=>`
${d}
`).join(''); for(let i=0;i`; for(let d=1;d<=days;d++) { const isToday = d===today.getDate()&&internCalMonth===today.getMonth()&&internCalYear===today.getFullYear(); const ds = `${internCalYear}-${String(internCalMonth+1).padStart(2,'0')}-${String(d).padStart(2,'0')}`; const hasEv = typeof CAL_EVENTS!=='undefined' && CAL_EVENTS.some(e=>e.date===ds); html+=`
${d}${hasEv?`
`:''}
`; } gridEl.innerHTML = html; } function renderCommunityFeedIntern() { const el = document.getElementById('community-feed-list-intern'); if(!el || el.children.length > 0) return; el.innerHTML = `
👋 Welcome to the FFH Community Feed! As an intern, you can read posts and reply to discussions.
📢
FFH Team
Welcome to our new intern cohort! We are excited to have you on the team this spring. 🌱
2 days ago · 12 likes
💬
Dr. Sarah Chen
Reminder: weekly intern check-ins are every Friday at 2PM. Come with your weekly reflection ready!
Yesterday · 8 likes
`; } function initInternTutor() { if(internChatGreeted) return; internChatGreeted = true; const msgs = document.getElementById('chat-msgs-intern'); if(msgs) { msgs.innerHTML = `
Hi! I am your FFH Intern Assistant 📋 I can help with your tasks, training modules, the FFH mission, or anything you need to succeed in your internship. What is on your mind?
`; } } async function sendChatIntern() { const inp = document.getElementById('chat-in-intern'); const box = document.getElementById('chat-msgs-intern'); if(!inp||!box||!inp.value.trim()) return; const msg = inp.value.trim(); inp.value=''; box.innerHTML += `
${msg}
`; box.scrollTop = box.scrollHeight; const thinking = document.createElement('div'); thinking.style.cssText='background:#f4f6fa;border-radius:10px;padding:10px 14px;max-width:85%;font-size:13px;color:var(--mid)'; thinking.textContent='Thinking…'; box.appendChild(thinking); box.scrollTop=box.scrollHeight; try { const res = await fetch('https://api.anthropic.com/v1/messages',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ model:'claude-sonnet-4-20250514',max_tokens:300, system:'You are a helpful AI assistant for Force for Health interns. Help with tasks, training, FFH mission & values, community outreach, and intern success. Keep responses concise and encouraging.', messages:[{role:'user',content:msg}] })}); const data = await res.json(); thinking.style.color='var(--navy)'; thinking.textContent = data.content?.[0]?.text || 'Sorry, I had trouble responding.'; } catch(e) { thinking.textContent='Connection error. Please try again.'; } box.scrollTop = box.scrollHeight; } function initInternView() { // Set first tab active const firstTab = document.querySelector('#stabs-intern .tb'); if(firstTab) setInternTab('home-intern', firstTab); } function initAmbassadorView() { initAmbCourses(); // Populate blog const blogEl = document.getElementById('blog-feed-list-amb'); if(blogEl && !blogEl.children.length) { MOCK_BLOG.forEach(post => { const card = document.createElement('div'); card.className = 'blog-card'; card.innerHTML = '
'+post.cat+'
'+post.title+'
'+post.excerpt+'
'+post.author+' · '+post.date+' · '+post.readMin+' min read
'; blogEl.appendChild(card); }); } // Populate announcements const annEl = document.getElementById('announce-list-amb'); if(annEl && !annEl.children.length) { MOCK_ANNOUNCE.forEach(a => { const card = document.createElement('div'); card.className = 'announce-card'; card.style.borderLeftColor = ANNOUNCE_COLORS[a.type]||'var(--orange)'; card.innerHTML = '
'+a.icon+''+a.title+''+a.date+'
'+a.body+'
'; annEl.appendChild(card); }); } // Init calendar renderCalendarAmb(); // Init community feed renderCommunityFeedAmb(); // Seed AI tutor greeting const chatMsgs = document.getElementById('chat-msgs-amb'); if(chatMsgs && !chatMsgs.children.length) { const greeting = document.createElement('div'); greeting.style.cssText = 'align-self:flex-start;background:#f0fdf4;color:var(--navy);padding:10px 14px;border-radius:16px 16px 16px 4px;max-width:80%;font-size:13px;line-height:1.5'; greeting.textContent = "Hi Ambassador! 🌟 I am your Force for Health AI Tutor. Ask me anything about your courses, community health, or wellness goals!"; chatMsgs.appendChild(greeting); } // Init wellness charts for ambassador view initAmbWellnessCharts(); } function initAmbWellnessCharts() { const days = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun']; const stepsData = [7200,8400,6100,9800,7600,11200,8900]; const sleepData = [6.5,7.2,6.8,7.8,6.2,8.1,7.4]; const hrData = [68,72,75,80,85,92,88,82,76,70,66,64]; if(document.getElementById('ch-steps-amb')) new Chart(document.getElementById('ch-steps-amb'),{type:'bar',data:{labels:days,datasets:[{data:stepsData,backgroundColor:'#e8450a44',borderColor:'#e8450a',borderWidth:2,borderRadius:6}]},options:{plugins:{legend:{display:false}},scales:{y:{beginAtZero:true,ticks:{font:{size:10}}}}}}); if(document.getElementById('ch-sleep-amb')) new Chart(document.getElementById('ch-sleep-amb'),{type:'line',data:{labels:days,datasets:[{data:sleepData,borderColor:'#8b5cf6',backgroundColor:'#8b5cf622',tension:.4,fill:true,pointRadius:4}]},options:{plugins:{legend:{display:false}},scales:{y:{min:4,max:10,ticks:{font:{size:10}}}}}}); if(document.getElementById('ch-hr-amb')) new Chart(document.getElementById('ch-hr-amb'),{type:'line',data:{labels:hrData.map((_,i)=>i*2+'h'),datasets:[{data:hrData,borderColor:'#e53e3e',backgroundColor:'#e53e3e11',tension:.4,fill:true,pointRadius:3}]},options:{plugins:{legend:{display:false}},scales:{y:{min:50,max:120,ticks:{font:{size:10}}}}}}); } // ══════════════════════════════════════════════════════════════════════════════ // GROUP LEADER TAB SWITCHER // ══════════════════════════════════════════════════════════════════════════════ function setGLTab(id, btn) { document.querySelectorAll('.tp-gl').forEach(p=>{ p.classList.remove('active-gl'); p.style.display='none'; }); const panel = document.getElementById('tp-'+id); if(panel){ panel.classList.add('active-gl'); panel.style.display='flex'; } document.querySelectorAll('#stabs-groupleader .tb').forEach(b=>b.classList.remove('active')); if(btn) btn.classList.add('active'); } function setCatTabGL(id, btn) { document.querySelectorAll('#tp-courses-gl [id^="cat-"]').forEach(el=>el.style.display='none'); const panel = document.getElementById('cat-'+id); if(panel) panel.style.display=''; document.querySelectorAll('#tp-courses-gl .cat-tb').forEach(b=>b.classList.remove('active')); if(btn) btn.classList.add('active'); } function initGLCourses() { const bg = document.getElementById('basics-gl-grid'); if(bg && !bg.children.length) renderCourseCatInto(COURSE_CATS.basics, bg); const ag = document.getElementById('ambassador-gl-grid'); if(ag && !ag.children.length) renderCourseCatInto(COURSE_CATS.ambassador, ag); const gg = document.getElementById('groupleader-gl-grid'); if(gg && !gg.children.length) renderCourseCatInto(COURSE_CATS.groupleader, gg); const cg = document.getElementById('commdir-gl-grid'); if(cg && !cg.children.length) renderCourseCatInto(COURSE_CATS.commdir, cg); } // Shared renderer — accepts array of cat objects + target DOM element function renderCourseCatInto(cats, gridEl) { if(!gridEl) return; gridEl.innerHTML = ''; cats.forEach(cat=>{ const card = document.createElement('div'); card.className = 'course-cat-card'; card.onclick = cat.link ? function(){ setTab(cat.link, null); } : function(){ toast('Opening ' + cat.name + '…', cat.color); }; const icon = document.createElement('div'); icon.className = 'cat-icon'; icon.textContent = cat.icon; card.appendChild(icon); const name = document.createElement('div'); name.className = 'cat-name'; name.textContent = cat.name; card.appendChild(name); const countEl = document.createElement('div'); countEl.className = 'cat-count'; countEl.textContent = cat.count + ' courses' + (cat.progress>0 ? ' · ' + cat.progress + '% complete' : ''); card.appendChild(countEl); const bar = document.createElement('div'); bar.className = 'cat-bar'; const fill = document.createElement('div'); fill.className = 'cat-bar-fill'; fill.style.width = cat.progress + '%'; fill.style.background = cat.color; bar.appendChild(fill); card.appendChild(bar); card.style.borderTopColor = cat.color; card.onmouseover = function(){ this.style.borderColor = cat.color; }; card.onmouseout = function(){ this.style.borderColor = '#e2e8f0'; this.style.borderTopColor = cat.color; }; gridEl.appendChild(card); }); } // ══════════════════════════════════════════════════════════════════════════════ // COMMUNITY DIRECTOR TAB SWITCHER // ══════════════════════════════════════════════════════════════════════════════ function setCDTab(id, btn) { document.querySelectorAll('.tp-cd').forEach(p=>{ p.classList.remove('active-cd'); p.style.display='none'; }); const panel = document.getElementById('tp-'+id); if(panel){ panel.classList.add('active-cd'); panel.style.display='flex'; } document.querySelectorAll('#v-commdir .tb').forEach(b=>b.classList.remove('active')); if(btn) btn.classList.add('active'); } function setCatTabCD(id, btn) { document.querySelectorAll('#tp-courses-cd [id^="cat-"]').forEach(el=>el.style.display='none'); const panel = document.getElementById('cat-'+id); if(panel) panel.style.display=''; document.querySelectorAll('#tp-courses-cd .cat-tb').forEach(b=>b.classList.remove('active')); if(btn) btn.classList.add('active'); } function initCDCourses() { const bg = document.getElementById('basics-cd-grid'); if(bg && !bg.children.length) renderCourseCatInto(COURSE_CATS.basics, bg); const cg = document.getElementById('commdir-cd-grid'); if(cg && !cg.children.length) renderCourseCatInto(COURSE_CATS.commdir, cg); } // ══ INIT ══ // ══ HEALTH DEMO DATA ══ const HEALTH = { source: null, srcName:'', srcIcon:'', today: { steps:7842, steps_goal:10000, active_min:44, active_goal:60, cal_burned:1920, cal_burned_goal:2500, cal_in:1840, cal_in_goal:2200, distance_km:5.8, floors:12, hr_resting:62, hr_avg:74, hr_peak:142, hrv_rmssd:68, spo2:98, stress:28, readiness:82, sleep_h:7.5, sleep_goal:8, macros:{protein:124, carbs:210, fat:58}, macro_goals:{protein:140, carbs:250, fat:65}, }, week: { days:['Mon','Tue','Wed','Thu','Fri','Sat','Today'], steps:[6200,8400,7100,9200,7800,5400,7842], sleep:[6.8,7.2,8.1,7.5,6.9,8.4,7.5], }, hr_24h:[62,64,68,72,85,95,110,142,130,108,88,74,71,68,66,72,70,74,76,74,72,70,68,65], }; const PROVIDERS = [ {id:'apple_health', name:'Apple Health', icon:'\u{1f34e}', note:'iOS only'}, {id:'google_fit', name:'Google Fit', icon:'\u{1f535}', note:'Android & Web'}, {id:'fitbit', name:'Fitbit', icon:'\u{1f7e6}', note:''}, {id:'garmin', name:'Garmin', icon:'\u2b1b', note:''}, ]; let connectedSources = []; let waterCount = 8; let charts = {}; function openTerra() { alert('Terra integration coming soon! Connect Apple Health, Google Fit, Fitbit, Garmin & more.'); } function disconnectSource(id) { connectedSources = connectedSources.filter(s=>s.id!==id); renderHealthUI(); } function renderHealthUI() { const d = HEALTH.today; const w = HEALTH.week; const srcLbl = HEALTH.source ? HEALTH.srcIcon+' '+HEALTH.srcName : '\u{1f4ca} Demo data'; const wellStats = document.getElementById('well-stats'); if(wellStats) wellStats.innerHTML = [ {label:'HRV (RMSSD)', val:d.hrv_rmssd, sub:'ms \u00b7 Good', color:'#16a34a'}, {label:'Resting HR', val:d.hr_resting+' bpm',sub:'\u2193 2 from yesterday', color:'#e53e3e'}, {label:'SpO\u2082', val:d.spo2+'%', sub:'Normal range', color:'#2980b9'}, {label:'Stress Score', val:d.stress, sub:'Low \u00b7 Well recovered', color:'#f59e0b'}, ].map(s=>'
'+s.label+'
'+s.val+'
'+s.sub+'
').join(''); ['steps-lbl','sleep-lbl'].forEach(function(id){ var el=document.getElementById(id); if(el) el.textContent=srcLbl; }); var hrRest=document.getElementById('hr-rest'); if(hrRest) hrRest.textContent = d.hr_resting; var hrAvg=document.getElementById('hr-avg'); if(hrAvg) hrAvg.textContent = d.hr_avg; var hrPeak=document.getElementById('hr-peak'); if(hrPeak) hrPeak.textContent = d.hr_peak; var recoveryEl = document.getElementById('recovery-metrics'); if(recoveryEl) recoveryEl.innerHTML = [ {label:'Readiness Score', val:d.readiness, color:'#16a34a'}, {label:'Sleep Quality', val:Math.round(d.sleep_h/d.sleep_goal*100), color:'#2980b9'}, {label:'Recovery', val:82, color:'#e8450a'}, ].map(function(m){return '
'+m.label+''+m.val+'/100
';}).join(''); var calLbl = document.getElementById('cal-lbl'); if(calLbl) calLbl.textContent = d.cal_in.toLocaleString()+' / '+d.cal_in_goal.toLocaleString()+' kcal'; var macroEl = document.getElementById('macro-bars'); if(macroEl) macroEl.innerHTML = [ {name:'Protein',val:d.macros.protein,goal:d.macro_goals.protein,color:'#e8450a'}, {name:'Carbs', val:d.macros.carbs, goal:d.macro_goals.carbs, color:'#3498db'}, {name:'Fat', val:d.macros.fat, goal:d.macro_goals.fat, color:'#f59e0b'}, ].map(function(m){return '
'+m.name+''+m.val+'g / '+m.goal+'g
';}).join(''); var terraPills = document.getElementById('terra-pills'); if(terraPills) terraPills.innerHTML = PROVIDERS.slice(0,4).map(function(p){ var conn = connectedSources.find(function(s){return s.id===p.id}); return ''+p.icon+' '+p.name+''; }).join(''); renderWater(); } function renderWater() { var wg = document.getElementById('water-glasses'); if(wg) wg.innerHTML = Array.from({length:8},function(_,i){return '' + '' + ''; var overlay = document.createElement('div'); overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.4);z-index:99998'; overlay.onclick = function(){overlay.remove();el.remove();}; document.body.appendChild(overlay); document.body.appendChild(el); } function vipBuildRow(rank,name,subtitle,badges,podium,mode,inactive,entityType,state){ var m=vipCalcRow(badges); var cls=inactive?' class="vip-inactive"':podium<=3?' class="vip-podium-'+podium+'"':''; var nameEsc = name.replace(/'/g,"\'"); var eType = entityType||''; var eSt = state||''; var healthDots = ''; /* health dots pre-computed in vipGetData */ var cells=''+rank+'
'+name+(subtitle?'
'+subtitle+healthDots+'
':'')+'
'; var vipRingCls=['vip-cell-core','vip-cell-core','vip-cell-core','vip-cell-inner','vip-cell-inner','vip-cell-inner','vip-cell-middle','vip-cell-middle','vip-cell-middle','vip-cell-outer','vip-cell-outer','vip-cell-outer'];badges.forEach(function(b,bi){cells+=''+((b>0)?b:'—')+'';}); cells+=''+m.gold+''+m.silver+''+m.bronze+''; return''+cells+''; } function vipGetData(mode) { const search = (document.getElementById('vipSearchInput')||{}).value||''; const q = search.toLowerCase().trim(); let data = []; if(mode==='individual') data = VIP_INDIVIDUALS.map(p=>({...p,subtitle:'',m:vipCalcRow(p.b)})); else if(mode==='group') { const gfSel = typeof vipGFSelections!=='undefined'?vipGFSelections:[]; const gStateFilt = (document.getElementById('vipGroupStateFilter')||{}).value||''; data = VIP_GROUPS.filter(p=>{ if(gStateFilt && p.state!==gStateFilt) return false; if(gfSel.length===0) return true; var regionFilts=gfSel.filter(function(s){return s.startsWith('region:');}).map(function(s){return s.substring(7);}); var typeFilts=gfSel.filter(function(s){return s.startsWith('type:');}).map(function(s){return s.substring(5);}); var regionOk=regionFilts.length===0||regionFilts.indexOf(p.region)>=0; var typeOk=typeFilts.length===0||typeFilts.indexOf(p.type)>=0; return regionOk&&typeOk; }).map(p=>({...p,subtitle:p.type+' \u2022 '+p.state+' \u2022 '+p.members+' members',m:vipCalcRow(p.b)})); } else if(mode==='county') { const stateFilt = (document.getElementById('vipCountyFilter')||{}).value||''; const statusFilt = (document.getElementById('vipStatusFilter')||{}).value||''; const entityFilt = (document.getElementById('vipEntityTypeFilter')||{}).value||''; let combined = []; if(entityFilt!=='tribe' && entityFilt!=='fed_tribe' && entityFilt!=='state_tribe') combined = combined.concat(VIP_COUNTIES.map(p=>({...p,entityType:'county',recognition:''}))); if(entityFilt!=='county' && entityFilt!=='state_tribe') combined = combined.concat(VIP_TRIBES.map(p=>({...p,entityType:'tribe',recognition:'federal'}))); if(entityFilt!=='county' && entityFilt!=='fed_tribe') combined = combined.concat(VIP_STATE_TRIBES.map(p=>({...p,entityType:'tribe',recognition:'state'}))); data = combined.filter(p=>{ if(stateFilt && p.state!==stateFilt) return false; const totalBadges = p.b.reduce((s,v)=>s+v,0); if(statusFilt==='active' && totalBadges===0) return false; if(statusFilt==='inactive' && totalBadges>0) return false; return true; }).map(p=>{ const totalBadges = p.b.reduce((s,v)=>s+v,0); const statusTag = totalBadges>0 ? '' : ' \u2022 Inactive'; const typeTag = p.entityType==='tribe' ? (p.recognition==='state' ? '\ud83c\udfdb\ufe0f State Tribe' : '\ud83c\udfdb\ufe0f Federal Tribe') : '\ud83c\udfe0 County'; var hd='';try{hd=p.entityType==='tribe'?tribalHealthDots(p.name,p.state||''):p.entityType==='county'?countyHealthDots(p.name,p.state||''):'';}catch(e){console.error('dots:',e);}return {...p, subtitle: typeTag+' \u2022 '+p.state+(p.pop?' \u2022 '+p.pop:'')+statusTag+hd, m:vipCalcRow(p.b), inactive:totalBadges===0}; }); } else if(mode==='state') data = VIP_STATES.map(p=>({...p,subtitle:p.abbr+' \u2022 '+p.members.toLocaleString()+' members',m:vipCalcRow(p.b)})); if(q) data = data.filter(p=>p.name.toLowerCase().includes(q)||(p.subtitle&&p.subtitle.toLowerCase().includes(q))||(p.type&&p.type.toLowerCase().includes(q))); return data; } function vipSortData(data) { if(!vipSortKey) { return data.sort((a,b)=>b.m.gold-a.m.gold||b.m.silver-a.m.silver||b.m.bronze-a.m.bronze); } const dir = vipSortDir==='asc'?1:-1; return data.sort((a,b)=>{ let av,bv; if(vipSortKey==='name'){av=a.name.toLowerCase();bv=b.name.toLowerCase();return avbv?1*dir:0;} if(vipSortKey==='rank') return 0; if(vipSortKey==='gold'){av=a.m.gold;bv=b.m.gold;} else if(vipSortKey==='silver'){av=a.m.silver;bv=b.m.silver;} else if(vipSortKey==='bronze'){av=a.m.bronze;bv=b.m.bronze;} else if(vipSortKey.startsWith('b')){const idx=parseInt(vipSortKey.substring(1));av=a.b[idx]||0;bv=b.b[idx]||0;} else{av=0;bv=0;} return(bv-av)*dir; }); } function vipSort(key, th) { if(vipSortKey===key){vipSortDir=vipSortDir==='desc'?'asc':'desc';} else{vipSortKey=key;vipSortDir='desc';} document.querySelectorAll('#vipLeagueHeaders th').forEach(h=>{h.classList.remove('vip-sort-asc','vip-sort-desc');}); th.classList.add(vipSortDir==='asc'?'vip-sort-asc':'vip-sort-desc'); vipMatrixRender(vipCurrentMode); } function vipMatrixRender(mode){ const body=document.getElementById('vipMatrixBody'); if(!body) return; vipCurrentMode = mode; const searchInput = document.getElementById('vipSearchInput'); const groupFilter = document.getElementById('vipGroupFilter'); if(searchInput){ const placeholders={individual:'Search individuals...',group:'Search groups...',county:'Search counties & tribes...',state:'Search states...'}; searchInput.placeholder=placeholders[mode]||'Search...'; } if(groupFilter) groupFilter.style.display = mode==='group'?'inline-block':'none'; const groupStateFilter = document.getElementById('vipGroupStateFilter'); if(groupStateFilter) groupStateFilter.style.display = mode==='group'?'inline-block':'none'; const countyFilter = document.getElementById('vipCountyFilter'); if(countyFilter) countyFilter.style.display = mode==='county'?'inline-block':'none'; const entityTypeFilter = document.getElementById('vipEntityTypeFilter'); if(entityTypeFilter) entityTypeFilter.style.display = mode==='county'?'inline-block':'none'; const statusFilter = document.getElementById('vipStatusFilter'); if(statusFilter) statusFilter.style.display = mode==='county'?'inline-block':'none'; let data = vipGetData(mode); data = vipSortData(data); vipFilteredData=data; let rows='',totG=0,totS=0,totB=0; data.forEach((p,i)=>{totG+=p.m.gold;totS+=p.m.silver;totB+=p.m.bronze;const isInactive=p.b.reduce((s,v)=>s+v,0)===0;rows+=vipBuildRow(i+1,p.name,p.subtitle,p.b,i+1,mode,isInactive,p.entityType||'',p.state||'');}); body.innerHTML=rows; document.getElementById('vipTotalGold').textContent=totG; document.getElementById('vipTotalSilver').textContent=totS; document.getElementById('vipTotalBronze').textContent=totB; document.getElementById('vipTotalMedals').textContent=totG+totS+totB; } function vipMatrixView(mode,btn){ /* (vipMatrixView body) */ document.querySelectorAll('.vip-tab').forEach(t=>t.classList.remove('active')); btn.classList.add('active'); vipSortKey=null;vipSortDir='desc'; document.querySelectorAll('#vipLeagueHeaders th').forEach(h=>{h.classList.remove('vip-sort-asc','vip-sort-desc');}); const searchInput=document.getElementById('vipSearchInput'); if(searchInput) searchInput.value=''; if(typeof vipGFClear==='function')vipGFClear(); const groupStateFilter2=document.getElementById('vipGroupStateFilter'); if(groupStateFilter2) groupStateFilter2.value=''; const countyFilter2=document.getElementById('vipCountyFilter'); if(countyFilter2) countyFilter2.value=''; const statusFilter2=document.getElementById('vipStatusFilter'); if(statusFilter2) statusFilter2.value=''; vipMatrixRender(mode);if(typeof vipChartVisible!=="undefined"&&vipChartVisible)vipRenderChart(); } // Tooltip auto-flip when near top of viewport document.addEventListener('mouseover', function(e) { const tip = e.target.closest('.ffh-tip'); if (!tip) return; const box = tip.querySelector('.ffh-tip-box'); if (!box) return; const rect = tip.getBoundingClientRect(); if (rect.top < 260) { box.classList.add('ffh-tip-below'); } else { box.classList.remove('ffh-tip-below'); } }); /* --- VIP CHART VISUALIZATION JS --- */ var vipChartVisible=false; var vipChartType='medals'; var vipFilteredData=[]; function vipToggleChart(){ vipChartVisible=!vipChartVisible; var p=document.getElementById('vipChartPanel'); var b=document.getElementById('vipChartToggle'); if(!p||!b)return; if(vipChartVisible){p.classList.add('visible');b.classList.add('active');vipRenderChart();} else{p.classList.remove('visible');b.classList.remove('active');} } function vipRenderChart(){ var el=document.getElementById('vipChartArea'); var tabs=document.getElementById('vipChartTabs'); if(!el||!tabs)return; var mode=typeof vipCurrentMode!=='undefined'?vipCurrentMode:'individual'; var data=vipFilteredData||[]; var tabDefs=[]; if(mode==='individual'){ tabDefs=[{id:'medals',label:'🏅 Medal Distribution'},{id:'states',label:'📊 By State'}]; } else if(mode==='county'){ tabDefs=[{id:'medals',label:'🏅 Medal Distribution'},{id:'states',label:'📊 By State'},{id:'health',label:'❤ Health Overview'},{id:'healthcmp',label:'📊 Health Compare'}]; } else if(mode==='group'){ tabDefs=[{id:'medals',label:'🏅 Medal Distribution'},{id:'states',label:'📊 By State'}]; } else if(mode==='state'){ tabDefs=[{id:'medals',label:'🏅 Medal Distribution'}]; } if(!tabDefs.find(function(t){return t.id===vipChartType;}))vipChartType=tabDefs[0].id; var th=''; tabDefs.forEach(function(t){ th+=''; }); tabs.innerHTML=th; if(vipChartType==='medals')vipChartMedals(el,data,mode); else if(vipChartType==='states')vipChartStates(el,data,mode); else if(vipChartType==='health')vipChartHealth(el,data); else if(vipChartType==='healthcmp')vipChartHealthCompare(el,data); } function vipChartMedals(el,data,mode){ var gold=0,silver=0,bronze=0; var topEarners=[]; data.forEach(function(d){ var g=d.m?d.m.gold||0:0; var s=d.m?d.m.silver||0:0; var b=d.m?d.m.bronze||0:0; gold+=g;silver+=s;bronze+=b; topEarners.push({name:d.name,total:g+s+b,g:g,s:s,b:b}); }); topEarners.sort(function(a,b){return b.total-a.total;}); var top5=topEarners.slice(0,8); var maxMedal=Math.max(gold,silver,bronze,1); var modeLabel=mode==='county'?'counties & tribes':mode==='individual'?'individuals':mode==='group'?'groups':'states'; var h='
Medal Distribution
'; h+='
Total medals across '+data.length+' '+modeLabel+'
'; h+='
'; [{label:'Gold',count:gold,color:'#fbbf24'},{label:'Silver',count:silver,color:'#94a3b8'},{label:'Bronze',count:bronze,color:'#cd7f32'}].forEach(function(m){ var ht=Math.max(20,m.count/maxMedal*140); h+='
'; h+='
'+m.count+'
'; h+='
'; h+='
'+m.label+'
'; h+='
'; }); h+='
'; if(top5.length>0){ h+='
Top Medal Earners
'; var tMax=top5[0].total||1; top5.forEach(function(t){ var pct=t.total/tMax*100; h+='
'; h+='
'+t.name+'
'; h+='
'+t.total+'
'; h+='
'+t.g+'G '+t.s+'S '+t.b+'B
'; h+='
'; }); h+='
'; } el.innerHTML=h; } function vipChartStates(el,data,mode){ var states={}; data.forEach(function(d){ var st=d.state||'Unknown'; if(!states[st])states[st]={name:st,count:0,totalBadges:0,gold:0,silver:0,bronze:0}; states[st].count++; if(d.b)d.b.forEach(function(v){states[st].totalBadges+=v;}); if(d.m){states[st].gold+=(d.m.gold||0);states[st].silver+=(d.m.silver||0);states[st].bronze+=(d.m.bronze||0);} }); var sorted=Object.values(states).sort(function(a,b){return b.totalBadges-a.totalBadges;}); var maxBadges=sorted.length>0?sorted[0].totalBadges:1; var modeLabel=mode==='county'?'counties & tribes':mode==='individual'?'individuals':mode==='group'?'groups':'entries'; var h='
Performance by State
'; h+='
Total badge points by state ('+data.length+' '+modeLabel+')
'; sorted.slice(0,15).forEach(function(s){ var pct=maxBadges>0?(s.totalBadges/maxBadges*100):0; h+='
'; h+='
'+s.name+' ('+s.count+')
'; h+='
'+s.totalBadges+'
'; h+='
'+s.gold+'G '+s.silver+'S '+s.bronze+'B
'; h+='
'; }); el.innerHTML=h; } function vipChartHealth(el,data){ var mode=typeof vipCurrentMode!=='undefined'?vipCurrentMode:'county'; var counties=data.filter(function(d){return d.entityType==='county';}); var tribes=data.filter(function(d){return d.entityType==='tribe';}); var h=''; if(counties.length>0){ var measures=['Diabetes %','Obesity %','Smoking %','Uninsured %','Mental Health %','Physical Inactivity %','Excessive Drinking %','Preventable Hosp','Food Env Index','Income Ratio']; var sums=new Array(10).fill(0); var cnt=0; counties.forEach(function(d){ if(!d.state||!d.name)return; try{ var st=d.state; if(typeof PLACES_FULL!=='undefined'&&PLACES_FULL[st]){ var cname=d.name.replace(/ County$/i,''); var match=PLACES_FULL[st].find(function(r){return r[0].toLowerCase()===cname.toLowerCase()||r[0].toLowerCase()===d.name.toLowerCase();}); if(match){ cnt++; for(var i=0;i<10;i++)sums[i]+=(parseFloat(match[i+2])||0); } } }catch(e){} }); h+='
County Health Overview
'; h+='
Average CDC PLACES indicators across '+cnt+' matched counties
'; if(cnt>0){ var maxVal=0; sums.forEach(function(s){var avg=s/cnt;if(avg>maxVal)maxVal=avg;}); measures.forEach(function(m,i){ var avg=sums[i]/cnt; var pct=maxVal>0?(avg/maxVal*100):0; var color=avg>20?'#ef4444':avg>12?'#f59e0b':'#10b981'; if(i>=7)color='#3b82f6'; h+='
'; h+='
'+m+'
'; h+='
'+avg.toFixed(1)+'
'; h+='
'; }); } else { h+='
No county health data matched
'; } } if(tribes.length>0){ var regions={}; tribes.forEach(function(d){ try{ if(typeof TRIBAL_HEALTH_BY_REGION!=='undefined'){ var reg=null; Object.keys(TRIBAL_HEALTH_BY_REGION).forEach(function(r){if(!reg)TRIBAL_HEALTH_BY_REGION[r].tribes.forEach(function(t){if(t.toLowerCase()===d.name.toLowerCase())reg=r;});}); if(reg){ if(!regions[reg])regions[reg]={name:reg,count:0,data:TRIBAL_HEALTH_BY_REGION[reg]}; regions[reg].count++; } } }catch(e){} }); var rArr=Object.values(regions).sort(function(a,b){return b.count-a.count;}); if(rArr.length>0){ h+='
'; h+='
Tribal Health Overview
'; h+='
SDOH indicators by IHS region for '+tribes.length+' tribes
'; rArr.forEach(function(r){ h+='
'; h+='
'+r.name+' ('+r.count+' tribes)
'; var sd=r.data.sdoh||{}; Object.keys(sd).forEach(function(k){ var val=parseFloat(sd[k])||0; var pct=Math.min(val,100); h+='
'; h+='
'+k+'
'; h+='
'+val.toFixed(1)+'%
'; h+='
'; }); h+='
'; }); h+='
'; } } if(counties.length===0&&tribes.length===0){ h='
Health charts available when counties or tribes are shown
'; } el.innerHTML=h; } function vipChartHealthCompare(el,data){ var measures=['Diabetes %','Obesity %','Smoking %','Uninsured %','Mental Health %','Physical Inactivity %','Excessive Drinking %','Preventable Hosp','Food Env Index','Income Ratio']; var entries=[]; var counties=data.filter(function(d){return d.entityType==='county';}); counties.slice(0,10).forEach(function(d){ if(!d.state||!d.name)return; try{ var st=d.state; if(typeof PLACES_FULL!=='undefined'&&PLACES_FULL[st]){ var cname=d.name.replace(/ County$/i,''); var match=PLACES_FULL[st].find(function(r){return r[0].toLowerCase()===cname.toLowerCase()||r[0].toLowerCase()===d.name.toLowerCase();}); if(match){ var vals=[]; for(var i=0;i<10;i++)vals.push(parseFloat(match[i+2])||0); entries.push({name:d.name,state:st,vals:vals}); } } }catch(e){} }); var h='
County Health Comparison
'; h+='
Side-by-side CDC PLACES indicators for top '+entries.length+' counties
'; if(entries.length<2){ h+='
Need at least 2 counties with health data to compare
'; el.innerHTML=h;return; } var colors=['#f79210','#3b82f6','#10b981','#a855f7','#ef4444','#fbbf24','#ec4899','#06b6d4','#84cc16','#f97316']; measures.forEach(function(m,mi){ var maxVal=0; entries.forEach(function(e){if(e.vals[mi]>maxVal)maxVal=e.vals[mi];}); h+='
'+m+'
'; entries.forEach(function(e,ei){ var pct=maxVal>0?(e.vals[mi]/maxVal*100):0; h+='
'; h+='
'+e.name+'
'; h+='
'+e.vals[mi].toFixed(1)+'
'; h+='
'; }); h+='
'; }); h+='
'; entries.forEach(function(e,i){ h+='
'+e.name+', '+e.state+'
'; }); h+='
'; el.innerHTML=h; } /* --- END VIP CHART VISUALIZATION JS --- */ /* --- VIP Multi-Select Filter JS --- */ var vipGFSelections=[]; function vipGFToggle(){ var dd=document.getElementById('vipGFDropdown'); if(dd)dd.classList.toggle('open'); } function vipGFUpdate(){ var checks=document.querySelectorAll('#vipGFDropdown input[type=checkbox]'); vipGFSelections=[]; checks.forEach(function(c){if(c.checked)vipGFSelections.push(c.value);}); vipGFRenderTags(); vipMatrixRender(vipCurrentMode); } function vipGFRenderTags(){ var el=document.getElementById('vipGFSelected'); if(!el)return; if(vipGFSelections.length===0){ el.innerHTML='All Groups'; return; } var h=''; vipGFSelections.forEach(function(s,i){ var label=s.split(':')[1]||s; h+=''+label+'×'; }); h+=''; el.innerHTML=h; } function vipGFRemove(idx){ var val=vipGFSelections[idx]; var checks=document.querySelectorAll('#vipGFDropdown input[type=checkbox]'); checks.forEach(function(c){if(c.value===val)c.checked=false;}); vipGFSelections.splice(idx,1); vipGFRenderTags(); vipMatrixRender(vipCurrentMode); } function vipGFClear(){ var checks=document.querySelectorAll('#vipGFDropdown input[type=checkbox]'); checks.forEach(function(c){c.checked=false;}); vipGFSelections=[]; vipGFRenderTags(); vipMatrixRender(vipCurrentMode); } /* Close dropdown when clicking outside */ document.addEventListener('click',function(e){ var wrap=document.getElementById('vipGroupFilter'); var dd=document.getElementById('vipGFDropdown'); if(wrap&&dd&&!wrap.contains(e.target)){dd.classList.remove('open');} }); /* --- END VIP Multi-Select Filter JS --- */ /* END:VIP_WIDGET_JS */ // ═══════════════════════════════════════════════════════════════════════════ // SUPABASE INTEGRATION (Hybrid: Cloud + In-Memory) // ═══════════════════════════════════════════════════════════════════════════ /** * FFH Live It Tracker - Supabase Integration Module * Hybrid persistence system with in-memory fallback * * This module provides Supabase integration for the Live It Tracker system * (wellness, weight, pain, treatment, screen time, nutrition, workouts, reactions, serve it, challenges). * * Supports hybrid mode: Demo data stays in memory by default. * When user is logged in via Supabase auth, data syncs to/from the database. * All operations are debounced and async to avoid blocking the UI. * * REQUIREMENTS FOR INJECTION: * 1. Add to head: supabase-js@2 CDN script tag * 2. Initialize on page load after all global variables are declared * 3. Call: await window.ffhLitSupabase.initialize() */ // ═══════════════════════════════════════════════════════════════════════════ // CONSTANTS AND CONFIGURATION // ═══════════════════════════════════════════════════════════════════════════ const FFH_LIT_CONFIG = { supabaseUrl: 'https://kfdwkiarqxlstlsnrilj.supabase.co', supabaseKey: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImtmZHdraWFycXhsc3Rsc25yaWxqIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzI4MjYwOTksImV4cCI6MjA4ODQwMjA5OX0.a6Vlw7QOb96N1ypsrUqVoiGDy7iYz1KudBdOnVSIf0c', syncDebounceMs: 1000, }; // ═══════════════════════════════════════════════════════════════════════════ // GLOBAL STATE // ═══════════════════════════════════════════════════════════════════════════ let ffhLitSupabaseState = { client: null, session: null, isInitialized: false, isConnected: false, userId: null, userDisplayName: null, organizationId: null, syncInProgress: false, lastSyncTime: null, pendingChanges: {}, syncTimeoutId: null, initPromise: null, }; // Make Supabase client accessible to other modules window.ffhSupabaseClient = null; // ═══════════════════════════════════════════════════════════════════════════ // LOGGING UTILITY // ═══════════════════════════════════════════════════════════════════════════ const ffhLitLog = { debug: (msg, data) => { console.log(`[FFH-LIT-SYNC DEBUG] ${msg}`, data || ''); }, info: (msg, data) => { console.info(`[FFH-LIT-SYNC INFO] ${msg}`, data || ''); }, warn: (msg, data) => { console.warn(`[FFH-LIT-SYNC WARN] ${msg}`, data || ''); }, error: (msg, data) => { console.error(`[FFH-LIT-SYNC ERROR] ${msg}`, data || ''); }, }; // ═══════════════════════════════════════════════════════════════════════════ // CONNECTION STATUS INDICATOR // ═══════════════════════════════════════════════════════════════════════════ function initializeLitConnectionIndicator() { const indicator = document.createElement('div'); indicator.id = 'ffh-lit-sync-status'; indicator.style.cssText = ` position: fixed; bottom: 20px; right: 20px; z-index: 999; width: 12px; height: 12px; border-radius: 50%; background: #94a3b8; box-shadow: 0 0 8px rgba(0,0,0,0.2); cursor: pointer; transition: background 0.3s ease; margin: 0; padding: 0; `; indicator.title = 'Sync Status: Offline (Local Mode)'; document.body.appendChild(indicator); indicator.addEventListener('click', () => { const status = ffhLitSupabaseState.isConnected ? 'Connected to Supabase' : 'Local Mode (Offline)'; const lastSync = ffhLitSupabaseState.lastSyncTime ? new Date(ffhLitSupabaseState.lastSyncTime).toLocaleTimeString() : 'Never'; alert(`LIT Sync Status:\n- ${status}\n- Last Sync: ${lastSync}\n- Session: ${ffhLitSupabaseState.userId ? 'Authenticated' : 'Demo Mode'}`); }); window.ffhLitSyncIndicator = indicator; return indicator; } function updateLitConnectionIndicator() { const indicator = window.ffhLitSyncIndicator; if (!indicator) return; if (ffhLitSupabaseState.isConnected && ffhLitSupabaseState.session) { indicator.style.background = '#16a34a'; // green indicator.title = 'Sync Status: Connected to Supabase (LIT)'; } else { indicator.style.background = '#94a3b8'; // gray indicator.title = 'Sync Status: Offline (Local Mode)'; } } // ═══════════════════════════════════════════════════════════════════════════ // INITIALIZATION // ═══════════════════════════════════════════════════════════════════════════ async function initLitSupabase() { if (ffhLitSupabaseState.initPromise) { return ffhLitSupabaseState.initPromise; } ffhLitSupabaseState.initPromise = (async () => { try { ffhLitLog.info('Initializing LIT Supabase integration...'); // Check if Supabase library is loaded if (!window.supabase) { throw new Error('Supabase library not loaded. Add supabase-js@2 CDN script to head.'); } // Initialize Supabase client ffhLitSupabaseState.client = window.supabase.createClient( FFH_LIT_CONFIG.supabaseUrl, FFH_LIT_CONFIG.supabaseKey ); window.ffhSupabaseClient = ffhLitSupabaseState.client; // Check for active session const { data: { session }, error: sessionError } = await ffhLitSupabaseState.client.auth.getSession(); if (sessionError) { ffhLitLog.warn('Session check failed', sessionError.message); ffhLitSupabaseState.isConnected = false; ffhLitSupabaseState.session = null; } else if (session) { ffhLitSupabaseState.session = session; ffhLitSupabaseState.userId = session.user.id; ffhLitSupabaseState.isConnected = true; // Extract display name: prefer user_metadata.full_name or .name, then email first name, fallback to 'Member' const meta = session.user.user_metadata || {}; const rawName = meta.full_name || meta.name || meta.display_name || ''; if (rawName.trim()) { ffhLitSupabaseState.userDisplayName = rawName.trim().split(' ')[0]; // First name only } else if (session.user.email) { // Use part before @ as fallback, capitalize first letter const emailName = session.user.email.split('@')[0].replace(/[._-]/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); ffhLitSupabaseState.userDisplayName = emailName.split(' ')[0]; } else { ffhLitSupabaseState.userDisplayName = 'Member'; } ffhLitLog.debug('Display name resolved', ffhLitSupabaseState.userDisplayName); // Update nav bar with user info if (typeof updateNavUserInfo === 'function') { updateNavUserInfo(ffhLitSupabaseState.userDisplayName, session.user.email); } /* ┌─────────────────────────────────────────────────────────┐ │ PATCH 1: PHI Safe Mode Guard │ └─────────────────────────────────────────────────────────┘ */ // Explicitly clear any stale health data from localStorage try { const healthDataKeys = ['litPainHistory', 'litWeightHistory', 'litTreatmentHistory', 'litNutritionHistory', 'litScreenTimeData', 'litWorkoutHistory', 'litServeItHistory', 'litWeightGoals', 'litNutritionGoals']; healthDataKeys.forEach(key => { const existing = localStorage.getItem(key); if (existing) { localStorage.removeItem(key); ffhLitLog.debug('Cleared stale PHI from localStorage: ' + key); } }); } catch (e) { ffhLitLog.debug('Could not clear localStorage', e.message); } // Set PHI_SAFE_MODE flag: when authenticated, block any localStorage writes for PHI ffhLitSupabaseState.PHI_SAFE_MODE = ffhLitSupabaseState.isConnected; /* ┌─────────────────────────────────────────────────────────────┐ │ PATCH 3: Consent Gate - Check for health_data_collection │ └─────────────────────────────────────────────────────────────┘ */ // Query consent_records for health_data_collection try { const { data: consents, error: consentError } = await ffhLitSupabaseState.client .from('consent_records') .select('id, consent_type, granted, created_at') .eq('user_id', ffhLitSupabaseState.userId) .eq('consent_type', 'health_data_collection') .order('created_at', { ascending: false }) .limit(1); console.info('[FFH-LIT] Consent query result:', { error: consentError ? JSON.stringify(consentError) : null, count: consents ? consents.length : 0, data: consents }); if (!consentError && consents && consents.length > 0) { ffhLitSupabaseState.consentGranted = consents[0].granted === true; console.info('[FFH-LIT] ✅ Consent from Supabase:', ffhLitSupabaseState.consentGranted ? 'GRANTED' : 'denied'); if (ffhLitSupabaseState.consentGranted) { try { localStorage.setItem('ffh_lit_consent_' + ffhLitSupabaseState.userId, 'granted'); } catch(x){} } } else { // Supabase returned no consent record — check localStorage fallback const localConsent = localStorage.getItem('ffh_lit_consent_' + ffhLitSupabaseState.userId); if (localConsent === 'granted') { ffhLitSupabaseState.consentGranted = true; console.info('[FFH-LIT] ✅ Consent from localStorage fallback (Supabase record missing — will retry insert)'); // Retry the consent insert in background ffhLitSupabaseState.client.from('consent_records').insert({ user_id: ffhLitSupabaseState.userId, consent_type: 'health_data_collection', granted: true, granted_by: 'self', user_agent: navigator.userAgent }).then(r => { if (r.error) console.warn('[FFH-LIT] Consent retry insert failed:', r.error.message); else console.info('[FFH-LIT] ✅ Consent record restored to Supabase'); }); } else { ffhLitSupabaseState.consentGranted = false; console.warn('[FFH-LIT] ⚠️ No consent found anywhere — error:', consentError ? consentError.message : 'none'); } } } catch (e) { console.error('[FFH-LIT] ❌ Consent query EXCEPTION:', e.message); // Check localStorage fallback try { const localConsent = localStorage.getItem('ffh_lit_consent_' + ffhLitSupabaseState.userId); if (localConsent === 'granted') { ffhLitSupabaseState.consentGranted = true; console.info('[FFH-LIT] ✅ Consent from localStorage fallback (after exception)'); } else { ffhLitSupabaseState.consentGranted = false; } } catch(x) { ffhLitSupabaseState.consentGranted = false; } } // If no consent, show modal if (!ffhLitSupabaseState.consentGranted) { showLitConsentModal(); } // Try to get organization + verify profile exists try { console.info('[FFH-LIT] 🔍 Profile query starting for userId:', ffhLitSupabaseState.userId); console.info('[FFH-LIT] 🔍 Session user id:', session.user.id, '| email:', session.user.email); // First try .single() const { data: profile, error: profileError } = await ffhLitSupabaseState.client .from('profiles') .select('id, organization_id, display_name') .eq('id', ffhLitSupabaseState.userId) .single(); console.info('[FFH-LIT] 🔍 Profile query result:', { data: profile ? JSON.stringify(profile) : 'null', error: profileError ? JSON.stringify(profileError) : 'null' }); if (!profileError && profile) { ffhLitSupabaseState.organizationId = profile.organization_id; ffhLitSupabaseState.profileExists = true; // Use profile display_name if available (e.g. "Coach Lucy" instead of just "Lucy") if (profile.display_name && profile.display_name.trim()) { ffhLitSupabaseState.userDisplayName = profile.display_name.trim(); if (typeof updateNavUserInfo === 'function') { updateNavUserInfo(profile.display_name.trim(), session.user.email); } } console.info('[FFH-LIT] ✅ Profile verified:', profile.id, '| org:', profile.organization_id, '| name:', profile.display_name); } else { // Retry without .single() to see raw results console.warn('[FFH-LIT] ⚠️ .single() failed, retrying without .single()...'); const { data: rows, error: retryErr } = await ffhLitSupabaseState.client .from('profiles') .select('id, organization_id, display_name') .eq('id', ffhLitSupabaseState.userId); console.info('[FFH-LIT] 🔍 Retry result:', { rows: rows ? JSON.stringify(rows) : 'null', count: rows ? rows.length : 0, error: retryErr ? JSON.stringify(retryErr) : 'null' }); if (!retryErr && rows && rows.length > 0) { ffhLitSupabaseState.organizationId = rows[0].organization_id; ffhLitSupabaseState.profileExists = true; if (rows[0].display_name && rows[0].display_name.trim()) { ffhLitSupabaseState.userDisplayName = rows[0].display_name.trim(); if (typeof updateNavUserInfo === 'function') { updateNavUserInfo(rows[0].display_name.trim(), session.user.email); } } console.info('[FFH-LIT] ✅ Profile verified via retry:', rows[0].id); } else { ffhLitSupabaseState.profileExists = false; console.error('[FFH-LIT] ⛔ Profile NOT found for user:', ffhLitSupabaseState.userId, profileError); toast('⛔ Profile row missing — saves will fail. See console (F12).', '#dc2626'); } } } catch (e) { console.error('[FFH-LIT] ❌ Profile query EXCEPTION:', e.message, e.stack); ffhLitSupabaseState.profileExists = false; } ffhLitLog.info('Authenticated session found', { userId: ffhLitSupabaseState.userId }); } else { ffhLitLog.info('No active session - running in demo mode'); ffhLitSupabaseState.isConnected = false; ffhLitSupabaseState.session = null; } // Initialize connection indicator initializeLitConnectionIndicator(); updateLitConnectionIndicator(); ffhLitSupabaseState.isInitialized = true; ffhLitLog.info('LIT Supabase integration initialized', { connected: ffhLitSupabaseState.isConnected, userId: ffhLitSupabaseState.userId }); // ── Load all cloud data after successful init ── if (ffhLitSupabaseState.isConnected && ffhLitSupabaseState.profileExists) { ffhLitLog.info('Loading cloud data after init...'); await litLoadAll(); // Update the sync count in the status bar const countEl = document.getElementById('ffh-sync-count'); if (countEl) countEl.textContent = (typeof weightLog !== 'undefined' ? weightLog.length : 0); } return true; } catch (error) { ffhLitLog.error('Failed to initialize LIT Supabase', error); ffhLitSupabaseState.isInitialized = false; ffhLitSupabaseState.isConnected = false; initializeLitConnectionIndicator(); return false; } })(); return ffhLitSupabaseState.initPromise; } // ═══════════════════════════════════════════════════════════════════════════ // SYNC ALL DATA TO SUPABASE // ═══════════════════════════════════════════════════════════════════════════ /** * Full sync of all LIT data to Supabase * Called after major data changes */ async function litSyncAll() { if (!ffhLitSupabaseState.isConnected || !ffhLitSupabaseState.client) { ffhLitLog.debug('Sync skipped - not connected to Supabase'); return false; } if (ffhLitSupabaseState.syncInProgress) { ffhLitLog.debug('Sync already in progress, queuing full sync'); ffhLitSupabaseState.pendingChanges.fullSync = true; return null; } ffhLitSupabaseState.syncInProgress = true; try { ffhLitLog.info('Starting full LIT sync to Supabase...'); // Sync pain log if (typeof painLog !== 'undefined' && painLog.length > 0) { const painEntries = painLog.map(entry => ({ user_id: ffhLitSupabaseState.userId, organization_id: ffhLitSupabaseState.organizationId, datetime: entry.datetime ? `${entry.datetime}:00` : new Date().toISOString(), location: entry.location || '', level: entry.level || 5, pain_type: entry.type || '', notes: entry.notes || '', coins_earned: entry.coins_earned || 0, })); const { error } = await ffhLitSupabaseState.client .from('lit_pain_entries') .upsert(painEntries, { onConflict: 'datetime,location' }); if (error) ffhLitLog.warn('Pain sync error', error.message); } // Sync weight entries (one at a time using check-then-insert/update pattern) if (typeof weightLog !== 'undefined' && weightLog.length > 0) { let weightSyncCount = 0; for (const entry of weightLog) { try { const entryDate = entry.date || new Date().toISOString().slice(0, 10); const { data: existing } = await ffhLitSupabaseState.client .from('lit_weight_entries') .select('id') .eq('user_id', ffhLitSupabaseState.userId) .eq('date', entryDate) .limit(1); if (existing && existing.length > 0) { await ffhLitSupabaseState.client .from('lit_weight_entries') .update({ value: entry.value || 0, unit: entry.unit || 'lbs', notes: entry.notes || '' }) .eq('id', existing[0].id); } else { await ffhLitSupabaseState.client .from('lit_weight_entries') .insert([{ user_id: ffhLitSupabaseState.userId, organization_id: ffhLitSupabaseState.organizationId, date: entryDate, value: entry.value || 0, unit: entry.unit || 'lbs', notes: entry.notes || '', }]); } weightSyncCount++; } catch (e) { ffhLitLog.warn('Weight entry sync error for date ' + entry.date, e.message); } } ffhLitLog.info('Weight entries synced', { count: weightSyncCount }); } // Sync weight goal (check-then-insert/update pattern) if (typeof weightGoalData !== 'undefined' && weightGoalData.value) { const { data: existingGoal } = await ffhLitSupabaseState.client .from('lit_weight_goals') .select('id') .eq('user_id', ffhLitSupabaseState.userId) .limit(1); let error; if (existingGoal && existingGoal.length > 0) { const result = await ffhLitSupabaseState.client .from('lit_weight_goals') .update({ target_value: weightGoalData.value, target_unit: weightUnit || 'lbs', target_date: weightGoalData.date }) .eq('id', existingGoal[0].id); error = result.error; } else { const result = await ffhLitSupabaseState.client .from('lit_weight_goals') .insert([{ user_id: ffhLitSupabaseState.userId, organization_id: ffhLitSupabaseState.organizationId, target_value: weightGoalData.value, target_unit: weightUnit || 'lbs', target_date: weightGoalData.date, }]); error = result.error; } if (error) ffhLitLog.warn('Weight goal sync error', error.message); } // Sync treatment entries if (typeof treatmentLog !== 'undefined' && treatmentLog.length > 0) { const txEntries = treatmentLog.map(entry => ({ user_id: ffhLitSupabaseState.userId, organization_id: ffhLitSupabaseState.organizationId, date: entry.date || new Date().toISOString().slice(0, 10), name: entry.name || '', category: entry.cat || '', pain_before: entry.before || 0, pain_after: entry.after || 0, effectiveness: entry.effectiveness || 0, duration: entry.duration || 0, duration_unit: entry.durationUnit || 'min', notes: entry.notes || '', coins_earned: entry.coins_earned || 0, })); const { error } = await ffhLitSupabaseState.client .from('lit_treatment_entries') .upsert(txEntries, { onConflict: 'date,name' }); if (error) ffhLitLog.warn('Treatment sync error', error.message); } // Sync nutrition diary if (typeof nutDiary !== 'undefined') { const today = new Date().toISOString().slice(0, 10); const nutEntries = []; Object.keys(nutDiary).forEach(meal => { if (Array.isArray(nutDiary[meal])) { nutDiary[meal].forEach(food => { nutEntries.push({ user_id: ffhLitSupabaseState.userId, organization_id: ffhLitSupabaseState.organizationId, date: today, meal: meal, food_name: food.name || '', brand: food.brand || '', calories: food.cal || 0, carbs: food.carbs || 0, protein: food.protein || 0, fat: food.fat || 0, fiber: food.fiber || 0, sodium: food.sodium || 0, sugar: food.sugar || 0, quantity: food.qty || 1, unit: food.unit || '', }); }); } }); if (nutEntries.length > 0) { const { error } = await ffhLitSupabaseState.client .from('lit_nutrition_diary') .upsert(nutEntries, { onConflict: 'date,meal,food_name' }); if (error) ffhLitLog.warn('Nutrition diary sync error', error.message); } } // Sync nutrition goals if (typeof NUT_GOALS !== 'undefined') { const { error } = await ffhLitSupabaseState.client .from('lit_nutrition_goals') .upsert([{ user_id: ffhLitSupabaseState.userId, organization_id: ffhLitSupabaseState.organizationId, calories: NUT_GOALS.cal || 1520, carbs: NUT_GOALS.carbs || 190, protein: NUT_GOALS.protein || 95, fat: NUT_GOALS.fat || 50, fiber: NUT_GOALS.fiber || 25, }], { onConflict: 'user_id' }); if (error) ffhLitLog.warn('Nutrition goals sync error', error.message); } // Sync screen time if (typeof stHistory !== 'undefined' && stHistory.length > 0) { const stEntries = stHistory.map(entry => ({ user_id: ffhLitSupabaseState.userId, organization_id: ffhLitSupabaseState.organizationId, date: entry.date || new Date().toISOString().slice(0, 10), app_data: entry.total ? JSON.stringify(entry.total) : '{}', goal_hours: stGoalHrs || 4, notes: entry.notes || '', })); const { error } = await ffhLitSupabaseState.client .from('lit_screen_time') .upsert(stEntries, { onConflict: 'date' }); if (error) ffhLitLog.warn('Screen time sync error', error.message); } // Sync saved workouts if (typeof savedWks !== 'undefined' && savedWks.length > 0) { const wkEntries = savedWks.map(wk => ({ user_id: ffhLitSupabaseState.userId, organization_id: ffhLitSupabaseState.organizationId, name: wk.name || '', workout_type: wk.workout_type || '', exercises: JSON.stringify(wk.exercises || []), total_sets: wk.total_sets || 0, est_minutes: wk.est_minutes || 0, })); const { error } = await ffhLitSupabaseState.client .from('lit_workouts') .upsert(wkEntries, { onConflict: 'id,name' }); if (error) ffhLitLog.warn('Workouts sync error', error.message); } // Sync workout history if (typeof wkHistory !== 'undefined' && wkHistory.length > 0) { const wkHistEntries = wkHistory.map(entry => ({ user_id: ffhLitSupabaseState.userId, organization_id: ffhLitSupabaseState.organizationId, name: entry.name || '', date: entry.date || new Date().toISOString().slice(0, 10), exercises_count: entry.exercises_count || 0, duration_minutes: entry.duration_minutes || 0, xp_earned: entry.xp_earned || 0, coins_earned: entry.coins_earned || 0, })); const { error } = await ffhLitSupabaseState.client .from('lit_workout_history') .upsert(wkHistEntries, { onConflict: 'date,name' }); if (error) ffhLitLog.warn('Workout history sync error', error.message); } // Sync products (baseline and reaction) if (typeof rxBaselineProducts !== 'undefined' && rxBaselineProducts.length > 0) { const products = rxBaselineProducts.map(p => ({ user_id: ffhLitSupabaseState.userId, organization_id: ffhLitSupabaseState.organizationId, product_type: 'baseline', name: p.name || '', brand: p.brand || '', category: p.category || '', ingredients: JSON.stringify(p.ingredients || []), rating: p.rating || 0, notes: p.notes || '', reaction: p.reaction || '', symptoms: p.symptoms || '', severity: p.severity || '', image_url: p.image_url || '', })); if (typeof rxReactionProducts !== 'undefined' && rxReactionProducts.length > 0) { rxReactionProducts.forEach(p => { products.push({ user_id: ffhLitSupabaseState.userId, organization_id: ffhLitSupabaseState.organizationId, product_type: 'reaction', name: p.name || '', brand: p.brand || '', category: p.category || '', ingredients: JSON.stringify(p.ingredients || []), rating: p.rating || 0, notes: p.notes || '', reaction: p.reaction || '', symptoms: p.symptoms || '', severity: p.severity || '', date: p.date || new Date().toISOString().slice(0, 10), image_url: p.image_url || '', }); }); } if (products.length > 0) { const { error } = await ffhLitSupabaseState.client .from('lit_reactions_products') .upsert(products, { onConflict: 'id' }); if (error) ffhLitLog.warn('Products sync error', error.message); } } // Sync serve it entries if (typeof svHistory !== 'undefined' && svHistory.length > 0) { const svEntries = svHistory.map(entry => ({ user_id: ffhLitSupabaseState.userId, organization_id: ffhLitSupabaseState.organizationId, date: entry.date || new Date().toISOString().slice(0, 10), service_type: entry.type || '', label: entry.label || '', category: entry.cat || '', minutes: entry.minutes || 0, dollars: entry.dollars || 0, units: entry.units || 0, who: entry.who || '', score: entry.score || '', notes: entry.note || '', coins_earned: entry.coins || 0, })); const { error } = await ffhLitSupabaseState.client .from('lit_serve_it_entries') .upsert(svEntries, { onConflict: 'date,service_type' }); if (error) ffhLitLog.warn('Serve it sync error', error.message); } ffhLitSupabaseState.lastSyncTime = new Date().toISOString(); ffhLitLog.info('Full LIT sync to Supabase completed'); updateLitConnectionIndicator(); return true; } catch (error) { ffhLitLog.error('Fatal error during LIT sync', error); return false; } finally { ffhLitSupabaseState.syncInProgress = false; // Process pending changes if (ffhLitSupabaseState.pendingChanges.fullSync) { ffhLitSupabaseState.pendingChanges.fullSync = false; await litSyncAll(); } } } // ═══════════════════════════════════════════════════════════════════════════ // LOAD ALL DATA FROM SUPABASE // ═══════════════════════════════════════════════════════════════════════════ /** * Load all LIT data from Supabase and populate in-memory variables */ async function litLoadAll() { if (!ffhLitSupabaseState.isConnected || !ffhLitSupabaseState.client) { ffhLitLog.warn('Load skipped - not connected to Supabase'); return false; } try { ffhLitLog.info('Loading all LIT data from Supabase...'); // IMPORTANT: Clear hardcoded demo data when connected to Supabase // This ensures we show only real cloud data, not demo placeholders window.weightLog = []; window.weightGoalData = { value: null, date: null }; window.painLog = []; window.treatmentLog = []; ffhLitLog.info('Cleared demo data — loading real data from cloud...'); // Load pain entries const { data: painData, error: painError } = await ffhLitSupabaseState.client .from('lit_pain_entries') .select('*') .eq('user_id', ffhLitSupabaseState.userId) .order('datetime', { ascending: false }); if (!painError && painData && painData.length > 0) { window.painLog = painData.map(p => ({ datetime: p.datetime.slice(0, 16), location: p.location, level: p.level, type: p.pain_type, notes: p.notes, coins_earned: p.coins_earned, })); ffhLitLog.info('Loaded pain entries', { count: window.painLog.length }); if (typeof renderPainChart !== 'undefined') renderPainChart(); if (typeof renderPainStats !== 'undefined') renderPainStats(); if (typeof renderPainHeatmap !== 'undefined') renderPainHeatmap(); if (typeof renderPainLog !== 'undefined') renderPainLog(); } // Load weight entries console.info('[FFH-LIT] 📊 Loading weight entries for userId:', ffhLitSupabaseState.userId); const { data: weightData, error: weightError } = await ffhLitSupabaseState.client .from('lit_weight_entries') .select('*') .eq('user_id', ffhLitSupabaseState.userId) .order('date', { ascending: true }); console.info('[FFH-LIT] 📊 Weight query result:', { count: weightData ? weightData.length : 'null', error: weightError ? JSON.stringify(weightError) : 'none', firstRow: weightData && weightData[0] ? JSON.stringify(weightData[0]) : 'none' }); if (!weightError && weightData && weightData.length > 0) { window.weightLog = weightData.map(w => ({ date: w.date, value: w.value, unit: w.unit, notes: w.notes, })); console.info('[FFH-LIT] ✅ Loaded ' + window.weightLog.length + ' weight entries from Supabase'); // Update sync count in status bar const countEl = document.getElementById('ffh-sync-count'); if (countEl) countEl.textContent = window.weightLog.length; } else { console.warn('[FFH-LIT] ⚠️ No weight entries loaded. Error:', weightError ? weightError.message : 'none', '| Data:', JSON.stringify(weightData)); } // Always re-render weight UI (even if 0 entries — shows empty state) if (typeof renderWeightChart !== 'undefined') renderWeightChart(); if (typeof renderWeightStats !== 'undefined') renderWeightStats(); if (typeof renderWeightLog !== 'undefined') renderWeightLog(); // Load weight goal const { data: goalData, error: goalError } = await ffhLitSupabaseState.client .from('lit_weight_goals') .select('*') .eq('user_id', ffhLitSupabaseState.userId) .single(); if (!goalError && goalData) { weightGoalData = { value: goalData.target_value, date: goalData.target_date, }; if (goalData.target_unit) weightUnit = goalData.target_unit; ffhLitLog.info('Loaded weight goal', { value: weightGoalData.value, date: weightGoalData.date }); // Populate the goal input fields so user sees persisted goal const goalInput = document.getElementById('wt-goal'); const goalDateInput = document.getElementById('wt-goal-date'); if (goalInput && weightGoalData.value) goalInput.value = weightGoalData.value; if (goalDateInput && weightGoalData.date) goalDateInput.value = weightGoalData.date; } // Load treatment entries const { data: txData, error: txError } = await ffhLitSupabaseState.client .from('lit_treatment_entries') .select('*') .eq('user_id', ffhLitSupabaseState.userId) .order('date', { ascending: false }); if (!txError && txData && txData.length > 0) { window.treatmentLog = txData.map(t => ({ date: t.date, name: t.name, cat: t.category, before: t.pain_before, after: t.pain_after, effectiveness: t.effectiveness, duration: t.duration, durationUnit: t.duration_unit, notes: t.notes, coins_earned: t.coins_earned, })); ffhLitLog.info('Loaded treatment entries', { count: window.treatmentLog.length }); if (typeof renderTxStats !== 'undefined') renderTxStats(); if (typeof renderTxCharts !== 'undefined') renderTxCharts(); if (typeof renderTxLibrary !== 'undefined') renderTxLibrary(); if (typeof renderTxLog !== 'undefined') renderTxLog(); } // Load nutrition diary const { data: nutData, error: nutError } = await ffhLitSupabaseState.client .from('lit_nutrition_diary') .select('*') .eq('user_id', ffhLitSupabaseState.userId) .order('date', { ascending: false }) .limit(100); if (!nutError && nutData && nutData.length > 0) { const nutByMeal = { breakfast: [], lunch: [], dinner: [], snack: [], }; nutData.forEach(n => { if (nutByMeal[n.meal]) { nutByMeal[n.meal].push({ id: n.id, name: n.food_name, brand: n.brand, cal: n.calories, carbs: n.carbs, protein: n.protein, fat: n.fat, fiber: n.fiber, sodium: n.sodium, sugar: n.sugar, qty: n.quantity, unit: n.unit, }); } }); window.nutDiary = nutByMeal; ffhLitLog.info('Loaded nutrition diary', { entries: nutData.length }); } // Load nutrition goals const { data: nutGoalData, error: nutGoalError } = await ffhLitSupabaseState.client .from('lit_nutrition_goals') .select('*') .eq('user_id', ffhLitSupabaseState.userId) .single(); if (!nutGoalError && nutGoalData) { window.NUT_GOALS = { cal: nutGoalData.calories, carbs: nutGoalData.carbs, protein: nutGoalData.protein, fat: nutGoalData.fat, fiber: nutGoalData.fiber, }; ffhLitLog.info('Loaded nutrition goals'); } // Load screen time const { data: stData, error: stError } = await ffhLitSupabaseState.client .from('lit_screen_time') .select('*') .eq('user_id', ffhLitSupabaseState.userId) .order('date', { ascending: true }); if (!stError && stData && stData.length > 0) { window.stHistory = stData.map(st => ({ date: st.date, total: typeof st.app_data === 'string' ? JSON.parse(st.app_data) : (st.app_data || {}), notes: st.notes, })); if (stData[stData.length - 1].goal_hours) { window.stGoalHrs = stData[stData.length - 1].goal_hours; } ffhLitLog.info('Loaded screen time', { count: window.stHistory.length }); if (typeof renderStStats !== 'undefined') renderStStats(); if (typeof renderStDonut !== 'undefined') renderStDonut(); if (typeof renderStWeekly !== 'undefined') renderStWeekly(); if (typeof renderStTrend !== 'undefined') renderStTrend(); if (typeof renderStGoalBar !== 'undefined') renderStGoalBar(); if (typeof renderStAppList !== 'undefined') renderStAppList(); } // Load workouts const { data: wkData, error: wkError } = await ffhLitSupabaseState.client .from('lit_workouts') .select('*') .eq('user_id', ffhLitSupabaseState.userId) .order('created_at', { ascending: false }); if (!wkError && wkData && wkData.length > 0) { window.savedWks = wkData.map(w => ({ id: w.id, name: w.name, workout_type: w.workout_type, exercises: typeof w.exercises === 'string' ? JSON.parse(w.exercises) : (w.exercises || []), total_sets: w.total_sets, est_minutes: w.est_minutes, })); ffhLitLog.info('Loaded workouts', { count: window.savedWks.length }); if (typeof renderSavedWorkouts !== 'undefined') renderSavedWorkouts(); } // Load workout history const { data: wkHistData, error: wkHistError } = await ffhLitSupabaseState.client .from('lit_workout_history') .select('*') .eq('user_id', ffhLitSupabaseState.userId) .order('date', { ascending: false }); if (!wkHistError && wkHistData && wkHistData.length > 0) { window.wkHistory = wkHistData.map(w => ({ id: w.id, name: w.name, date: w.date, exercises_count: w.exercises_count, duration_minutes: w.duration_minutes, xp_earned: w.xp_earned, coins_earned: w.coins_earned, })); ffhLitLog.info('Loaded workout history', { count: window.wkHistory.length }); } // Load products const { data: prodData, error: prodError } = await ffhLitSupabaseState.client .from('lit_reactions_products') .select('*') .eq('user_id', ffhLitSupabaseState.userId) .order('created_at', { ascending: false }); if (!prodError && prodData && prodData.length > 0) { window.rxBaselineProducts = []; window.rxReactionProducts = []; prodData.forEach(p => { const mapped = { id: p.id, name: p.name, brand: p.brand, category: p.category, ingredients: typeof p.ingredients === 'string' ? JSON.parse(p.ingredients) : (p.ingredients || []), rating: p.rating, notes: p.notes, reaction: p.reaction, symptoms: p.symptoms, severity: p.severity, image_url: p.image_url, }; if (p.product_type === 'baseline') { window.rxBaselineProducts.push(mapped); } else if (p.product_type === 'reaction') { mapped.date = p.date; window.rxReactionProducts.push(mapped); } }); ffhLitLog.info('Loaded products', { baseline: window.rxBaselineProducts.length, reaction: window.rxReactionProducts.length, }); } // Load serve it entries const { data: svData, error: svError } = await ffhLitSupabaseState.client .from('lit_serve_it_entries') .select('*') .eq('user_id', ffhLitSupabaseState.userId) .order('date', { ascending: false }); if (!svError && svData && svData.length > 0) { window.svHistory = svData.map(sv => ({ date: sv.date, type: sv.service_type, label: sv.label, coins: sv.coins_earned, cat: sv.category, who: sv.who, score: sv.score, minutes: sv.minutes, dollars: sv.dollars, units: sv.units, note: sv.notes, })); ffhLitLog.info('Loaded serve it entries', { count: window.svHistory.length }); if (typeof renderSvStats !== 'undefined') renderSvStats(); if (typeof renderSvWeeklyChart !== 'undefined') renderSvWeeklyChart(); if (typeof renderSvStreak !== 'undefined') renderSvStreak(); if (typeof renderSvTrend !== 'undefined') renderSvTrend(); if (typeof renderSvImpact !== 'undefined') renderSvImpact(); if (typeof renderSvActivityLog !== 'undefined') renderSvActivityLog('all'); } ffhLitLog.info('Full LIT data load from Supabase completed'); return true; } catch (error) { ffhLitLog.error('Fatal error loading LIT data from Supabase', error); return false; } } // ═══════════════════════════════════════════════════════════════════════════ // INDIVIDUAL SAVE FUNCTIONS // ═══════════════════════════════════════════════════════════════════════════ /** * Save a pain entry */ async function litSavePainEntry(entry) { if (!ffhLitSupabaseState.consentGranted) { ffhLitLog.warn('litSavePainEntry: consent not granted'); return; } await ffhAuditLog('create/updatepainentry', 'health_record', arguments[0]?.id || 'unknown', {}); if (!ffhLitSupabaseState.isConnected || !ffhLitSupabaseState.client) { ffhLitLog.debug('Pain save skipped - offline'); return false; } try { const { error } = await ffhLitSupabaseState.client .from('lit_pain_entries') .insert([{ user_id: ffhLitSupabaseState.userId, organization_id: ffhLitSupabaseState.organizationId, datetime: entry.datetime || new Date().toISOString(), location: entry.location || '', level: entry.level || 5, pain_type: entry.type || '', notes: entry.notes || '', coins_earned: entry.coins_earned || 0, }]); if (error) { ffhLitLog.warn('Failed to save pain entry', error.message); return false; } return true; } catch (e) { ffhLitLog.error('Error saving pain entry', e); return false; } } /** * Save a weight entry */ async function litSaveWeightEntry(entry) { console.info('[FFH-LIT] litSaveWeightEntry called:', JSON.stringify(entry)); if (!ffhLitSupabaseState.consentGranted) { console.warn('[FFH-LIT] BLOCKED: consent not granted'); return false; } if (ffhLitSupabaseState.profileExists === false) { console.warn('[FFH-LIT] BLOCKED: profileExists is false'); toast('⛔ Cannot save — profile row missing. See console (F12).', '#dc2626'); return false; } if (!ffhLitSupabaseState.isConnected || !ffhLitSupabaseState.client) { console.warn('[FFH-LIT] BLOCKED: not connected'); return false; } try { // Audit log (non-blocking — don't let it kill the save) ffhAuditLog('create/updateweightentry', 'health_record', 'weight', {}).catch(() => {}); const entryDate = entry.date || new Date().toISOString().slice(0, 10); const payload = { user_id: ffhLitSupabaseState.userId, organization_id: ffhLitSupabaseState.organizationId, date: entryDate, value: entry.value || 0, unit: entry.unit || 'lbs', notes: entry.notes || '', }; console.info('[FFH-LIT] Save payload:', JSON.stringify(payload)); // Check if an entry for this date already exists const { data: existing, error: selectError } = await ffhLitSupabaseState.client .from('lit_weight_entries') .select('id') .eq('user_id', ffhLitSupabaseState.userId) .eq('date', entryDate) .limit(1); if (selectError) { console.error('[FFH-LIT] SELECT check failed:', JSON.stringify(selectError)); } let error; if (existing && existing.length > 0) { console.info('[FFH-LIT] Updating existing entry id:', existing[0].id); const result = await ffhLitSupabaseState.client .from('lit_weight_entries') .update({ value: payload.value, unit: payload.unit, notes: payload.notes }) .eq('id', existing[0].id); error = result.error; } else { console.info('[FFH-LIT] Inserting new weight entry for date:', entryDate); const result = await ffhLitSupabaseState.client .from('lit_weight_entries') .insert([payload]); error = result.error; } if (error) { console.error('[FFH-LIT] ❌ SAVE FAILED:', JSON.stringify(error)); toast('⛔ Save error: ' + (error.message || error.code || 'unknown'), '#dc2626'); return false; } console.info('[FFH-LIT] ✅ Weight entry SAVED:', entryDate, entry.value); return true; } catch (e) { console.error('[FFH-LIT] ❌ SAVE EXCEPTION:', e.message, e); toast('⛔ Save exception: ' + e.message, '#dc2626'); return false; } } /** * Save weight goal */ async function litSaveWeightGoal(goal) { if (!ffhLitSupabaseState.consentGranted) { ffhLitLog.warn('litSaveWeightGoal: consent not granted'); return false; } if (ffhLitSupabaseState.profileExists === false) { ffhLitLog.warn('litSaveWeightGoal: no profiles row'); toast('⛔ Cannot save — profile row missing. See console (F12).', '#dc2626'); return false; } await ffhAuditLog('create/updateweightgoal', 'health_record', arguments[0]?.id || 'unknown', {}); if (!ffhLitSupabaseState.isConnected || !ffhLitSupabaseState.client) { ffhLitLog.debug('Weight goal save skipped - offline'); return false; } try { const goalPayload = { user_id: ffhLitSupabaseState.userId, organization_id: ffhLitSupabaseState.organizationId, target_value: goal.value || 0, target_unit: goal.unit || 'lbs', target_date: goal.date, }; // Check if a goal already exists for this user const { data: existing } = await ffhLitSupabaseState.client .from('lit_weight_goals') .select('id') .eq('user_id', ffhLitSupabaseState.userId) .limit(1); let error; if (existing && existing.length > 0) { // Update existing goal const result = await ffhLitSupabaseState.client .from('lit_weight_goals') .update({ target_value: goalPayload.target_value, target_unit: goalPayload.target_unit, target_date: goalPayload.target_date }) .eq('id', existing[0].id); error = result.error; } else { // Insert new goal const result = await ffhLitSupabaseState.client .from('lit_weight_goals') .insert([goalPayload]); error = result.error; } if (error) { ffhLitLog.warn('Failed to save weight goal', error.message); console.error('[FFH-LIT] Weight goal save error detail:', JSON.stringify(error)); return false; } ffhLitLog.info('Weight goal saved to Supabase', { value: goal.value, date: goal.date }); return true; } catch (e) { ffhLitLog.error('Error saving weight goal', e); return false; } } /** * Save treatment entry */ async function litSaveTreatmentEntry(entry) { if (!ffhLitSupabaseState.consentGranted) { ffhLitLog.warn('litSaveTreatmentEntry: consent not granted'); return; } await ffhAuditLog('create/updatetreatmententry', 'health_record', arguments[0]?.id || 'unknown', {}); if (!ffhLitSupabaseState.isConnected || !ffhLitSupabaseState.client) { ffhLitLog.debug('Treatment save skipped - offline'); return false; } try { const { error } = await ffhLitSupabaseState.client .from('lit_treatment_entries') .insert([{ user_id: ffhLitSupabaseState.userId, organization_id: ffhLitSupabaseState.organizationId, date: entry.date || new Date().toISOString().slice(0, 10), name: entry.name || '', category: entry.cat || '', pain_before: entry.before || 0, pain_after: entry.after || 0, effectiveness: entry.effectiveness || 0, duration: entry.duration || 0, duration_unit: entry.durationUnit || 'min', notes: entry.notes || '', coins_earned: entry.coins_earned || 0, }]); if (error) { ffhLitLog.warn('Failed to save treatment entry', error.message); return false; } return true; } catch (e) { ffhLitLog.error('Error saving treatment entry', e); return false; } } /** * Save nutrition entry */ async function litSaveNutritionEntry(meal, entry) { if (!ffhLitSupabaseState.consentGranted) { ffhLitLog.warn('litSaveNutritionEntry: consent not granted'); return; } await ffhAuditLog('create/updatenutritionentry', 'health_record', arguments[0]?.id || 'unknown', {}); if (!ffhLitSupabaseState.isConnected || !ffhLitSupabaseState.client) { ffhLitLog.debug('Nutrition save skipped - offline'); return false; } try { const { error } = await ffhLitSupabaseState.client .from('lit_nutrition_diary') .insert([{ user_id: ffhLitSupabaseState.userId, organization_id: ffhLitSupabaseState.organizationId, date: new Date().toISOString().slice(0, 10), meal: meal, food_name: entry.name || '', brand: entry.brand || '', calories: entry.cal || 0, carbs: entry.carbs || 0, protein: entry.protein || 0, fat: entry.fat || 0, fiber: entry.fiber || 0, sodium: entry.sodium || 0, sugar: entry.sugar || 0, quantity: entry.qty || 1, unit: entry.unit || '', }]); if (error) { ffhLitLog.warn('Failed to save nutrition entry', error.message); return false; } return true; } catch (e) { ffhLitLog.error('Error saving nutrition entry', e); return false; } } /** * Save nutrition goals */ async function litSaveNutritionGoals(goals) { if (!ffhLitSupabaseState.consentGranted) { ffhLitLog.warn('litSaveNutritionGoals: consent not granted'); return; } await ffhAuditLog('create/updatenutritiongoals', 'health_record', arguments[0]?.id || 'unknown', {}); if (!ffhLitSupabaseState.isConnected || !ffhLitSupabaseState.client) { ffhLitLog.debug('Nutrition goals save skipped - offline'); return false; } try { const { error } = await ffhLitSupabaseState.client .from('lit_nutrition_goals') .upsert([{ user_id: ffhLitSupabaseState.userId, organization_id: ffhLitSupabaseState.organizationId, calories: goals.cal || 1520, carbs: goals.carbs || 190, protein: goals.protein || 95, fat: goals.fat || 50, fiber: goals.fiber || 25, }], { onConflict: 'user_id' }); if (error) { ffhLitLog.warn('Failed to save nutrition goals', error.message); return false; } return true; } catch (e) { ffhLitLog.error('Error saving nutrition goals', e); return false; } } /** * Save screen time for a date */ async function litSaveScreenTime(dayData) { if (!ffhLitSupabaseState.consentGranted) { ffhLitLog.warn('litSaveScreenTime: consent not granted'); return; } await ffhAuditLog('create/updatescreentime', 'health_record', arguments[0]?.id || 'unknown', {}); if (!ffhLitSupabaseState.isConnected || !ffhLitSupabaseState.client) { ffhLitLog.debug('Screen time save skipped - offline'); return false; } try { const { error } = await ffhLitSupabaseState.client .from('lit_screen_time') .upsert([{ user_id: ffhLitSupabaseState.userId, organization_id: ffhLitSupabaseState.organizationId, date: dayData.date || new Date().toISOString().slice(0, 10), app_data: JSON.stringify(dayData.total || {}), goal_hours: stGoalHrs || 4, notes: dayData.notes || '', }], { onConflict: 'date' }); if (error) { ffhLitLog.warn('Failed to save screen time', error.message); return false; } return true; } catch (e) { ffhLitLog.error('Error saving screen time', e); return false; } } /** * Save serve it entry */ async function litSaveServeItEntry(entry) { if (!ffhLitSupabaseState.consentGranted) { ffhLitLog.warn('litSaveServeItEntry: consent not granted'); return; } await ffhAuditLog('create/updateserveitentry', 'health_record', arguments[0]?.id || 'unknown', {}); if (!ffhLitSupabaseState.isConnected || !ffhLitSupabaseState.client) { ffhLitLog.debug('Serve it save skipped - offline'); return false; } try { const { error } = await ffhLitSupabaseState.client .from('lit_serve_it_entries') .insert([{ user_id: ffhLitSupabaseState.userId, organization_id: ffhLitSupabaseState.organizationId, date: entry.date || new Date().toISOString().slice(0, 10), service_type: entry.type || '', label: entry.label || '', category: entry.cat || '', minutes: entry.minutes || 0, dollars: entry.dollars || 0, units: entry.units || 0, who: entry.who || '', score: entry.score || '', notes: entry.note || '', coins_earned: entry.coins || 0, }]); if (error) { ffhLitLog.warn('Failed to save serve it entry', error.message); return false; } return true; } catch (e) { ffhLitLog.error('Error saving serve it entry', e); return false; } } /** * Save workout */ async function litSaveWorkout(workout) { if (!ffhLitSupabaseState.consentGranted) { ffhLitLog.warn('litSaveWorkout: consent not granted'); return; } await ffhAuditLog('create/updateworkout', 'health_record', arguments[0]?.id || 'unknown', {}); if (!ffhLitSupabaseState.isConnected || !ffhLitSupabaseState.client) { ffhLitLog.debug('Workout save skipped - offline'); return false; } try { const { error } = await ffhLitSupabaseState.client .from('lit_workouts') .upsert([{ user_id: ffhLitSupabaseState.userId, organization_id: ffhLitSupabaseState.organizationId, name: workout.name || '', workout_type: workout.workout_type || '', exercises: JSON.stringify(workout.exercises || []), total_sets: workout.total_sets || 0, est_minutes: workout.est_minutes || 0, }], { onConflict: 'id' }); if (error) { ffhLitLog.warn('Failed to save workout', error.message); return false; } return true; } catch (e) { ffhLitLog.error('Error saving workout', e); return false; } } /** * Log workout history */ async function litLogWorkoutHistory(historyEntry) { if (!ffhLitSupabaseState.isConnected || !ffhLitSupabaseState.client) { ffhLitLog.debug('Workout history save skipped - offline'); return false; } try { const { error } = await ffhLitSupabaseState.client .from('lit_workout_history') .insert([{ user_id: ffhLitSupabaseState.userId, organization_id: ffhLitSupabaseState.organizationId, name: historyEntry.name || '', date: historyEntry.date || new Date().toISOString().slice(0, 10), exercises_count: historyEntry.exercises_count || 0, duration_minutes: historyEntry.duration_minutes || 0, xp_earned: historyEntry.xp_earned || 0, coins_earned: historyEntry.coins_earned || 0, }]); if (error) { ffhLitLog.warn('Failed to save workout history', error.message); return false; } return true; } catch (e) { ffhLitLog.error('Error saving workout history', e); return false; } } /** * Save product (baseline or reaction) */ async function litSaveProduct(product) { if (!ffhLitSupabaseState.consentGranted) { ffhLitLog.warn('litSaveProduct: consent not granted'); return; } await ffhAuditLog('create/updateproduct', 'health_record', arguments[0]?.id || 'unknown', {}); if (!ffhLitSupabaseState.isConnected || !ffhLitSupabaseState.client) { ffhLitLog.debug('Product save skipped - offline'); return false; } try { const { error } = await ffhLitSupabaseState.client .from('lit_reactions_products') .upsert([{ user_id: ffhLitSupabaseState.userId, organization_id: ffhLitSupabaseState.organizationId, product_type: product.product_type || 'baseline', name: product.name || '', brand: product.brand || '', category: product.category || '', ingredients: JSON.stringify(product.ingredients || []), rating: product.rating || 0, notes: product.notes || '', reaction: product.reaction || '', symptoms: product.symptoms || '', severity: product.severity || '', date: product.date || new Date().toISOString().slice(0, 10), image_url: product.image_url || '', }], { onConflict: 'id' }); if (error) { ffhLitLog.warn('Failed to save product', error.message); return false; } return true; } catch (e) { ffhLitLog.error('Error saving product', e); return false; } } /** * Save challenge */ async function litSaveChallenge(challenge) { if (!ffhLitSupabaseState.consentGranted) { ffhLitLog.warn('litSaveChallenge: consent not granted'); return; } await ffhAuditLog('create/updatechallenge', 'health_record', arguments[0]?.id || 'unknown', {}); if (!ffhLitSupabaseState.isConnected || !ffhLitSupabaseState.client) { ffhLitLog.debug('Challenge save skipped - offline'); return false; } try { const { error } = await ffhLitSupabaseState.client .from('lit_challenges') .upsert([{ user_id: ffhLitSupabaseState.userId, organization_id: ffhLitSupabaseState.organizationId, challenge_type: challenge.challenge_type || '', title: challenge.title || '', start_date: challenge.start_date || new Date().toISOString().slice(0, 10), end_date: challenge.end_date, target_value: challenge.target_value, current_value: challenge.current_value || 0, status: challenge.status || 'active', coins_earned: challenge.coins_earned || 0, }], { onConflict: 'id' }); if (error) { ffhLitLog.warn('Failed to save challenge', error.message); return false; } return true; } catch (e) { ffhLitLog.error('Error saving challenge', e); return false; } } // ═══════════════════════════════════════════════════════════════════════════ // STATUS AND UTILITIES // ═══════════════════════════════════════════════════════════════════════════ /** * Get current sync status */ function getLitSyncStatus() { return { isInitialized: ffhLitSupabaseState.isInitialized, isConnected: ffhLitSupabaseState.isConnected, userId: ffhLitSupabaseState.userId, organizationId: ffhLitSupabaseState.organizationId, lastSyncTime: ffhLitSupabaseState.lastSyncTime, syncInProgress: ffhLitSupabaseState.syncInProgress, mode: ffhLitSupabaseState.isConnected ? 'Supabase' : 'Local Mode (In-Memory)', }; } // ═══════════════════════════════════════════════════════════════════════════ // EXPORTS TO WINDOW // ═══════════════════════════════════════════════════════════════════════════ /* ┌─────────────────────────────────────────────────────────────────┐ │ PATCH 2: Audit Logging Helper (HIPAA/FERPA Compliance) │ └─────────────────────────────────────────────────────────────────┘ */ async function ffhAuditLog(action, resourceType, resourceId, details = {}) { if (!ffhLitSupabaseState.isConnected || !ffhLitSupabaseState.client || !ffhLitSupabaseState.userId) { return; // Silent fail if not authenticated } try { const { error } = await ffhLitSupabaseState.client .from('audit_log') .insert({ user_id: ffhLitSupabaseState.userId, action: action, resource_type: resourceType, resource_id: resourceId, details: details, ip_address: null, // Populated server-side user_agent: navigator.userAgent }); if (error) { ffhLitLog.debug('Audit log insert failed', error); } } catch (e) { ffhLitLog.debug('Audit log exception', e.message); } } /* ┌─────────────────────────────────────────────────────────────┐ │ PATCH 3: Consent Modal Handlers │ └─────────────────────────────────────────────────────────────┘ */ function showLitConsentModal() { const modal = document.getElementById('litConsentModal'); if (modal) { modal.style.display = 'flex'; const acceptBtn = document.getElementById('litConsentAccept'); const cancelBtn = document.getElementById('litConsentCancel'); if (acceptBtn) { acceptBtn.addEventListener('click', async () => { await litGrantConsent(); }); } if (cancelBtn) { cancelBtn.addEventListener('click', () => { litDenyConsent(); }); } } } function hideLitConsentModal() { const modal = document.getElementById('litConsentModal'); if (modal) { modal.style.display = 'none'; } } async function litGrantConsent() { if (!ffhLitSupabaseState.isConnected || !ffhLitSupabaseState.client) { ffhLitLog.error('Cannot grant consent: not connected to Supabase'); // Still grant in-memory + localStorage so user isn't locked out ffhLitSupabaseState.consentGranted = true; try { localStorage.setItem('ffh_lit_consent_' + (ffhLitSupabaseState.userId || 'local'), 'granted'); } catch(x){} hideLitConsentModal(); updateSyncStatusBar(); toast('⚠️ Consent granted locally (not connected to cloud)', '#f59e0b'); return; } // Visual feedback on button const acceptBtn = document.getElementById('litConsentAccept'); if (acceptBtn) { acceptBtn.textContent = 'Saving...'; acceptBtn.disabled = true; } try { const { error } = await ffhLitSupabaseState.client .from('consent_records') .insert({ user_id: ffhLitSupabaseState.userId, consent_type: 'health_data_collection', granted: true, granted_by: 'self', ip_address: null, user_agent: navigator.userAgent }); if (error) { console.error('[FFH-LIT] Consent insert error:', JSON.stringify(error)); // Grant in-memory anyway so user isn't blocked during dev/test // The consent will be retried on next session via localStorage fallback ffhLitSupabaseState.consentGranted = true; try { localStorage.setItem('ffh_lit_consent_' + ffhLitSupabaseState.userId, 'granted'); } catch(x){} hideLitConsentModal(); updateSyncStatusBar(); toast('✅ Consent granted (cloud record pending: ' + (error.message || 'RLS/FK issue') + ')', '#f59e0b'); return; } ffhLitSupabaseState.consentGranted = true; try { localStorage.setItem('ffh_lit_consent_' + ffhLitSupabaseState.userId, 'granted'); } catch(x){} hideLitConsentModal(); toast('✅ Health data consent granted — cloud sync enabled!', '#16a34a'); ffhLitLog.info('Health data collection consent granted and recorded'); // Now load real data and sync any in-memory data that was waiting for consent try { if (window.ffhLitSupabase && typeof window.ffhLitSupabase.loadAll === 'function') { await window.ffhLitSupabase.loadAll(); ffhLitLog.info('Post-consent data load completed'); } if (window.ffhLitSupabase && typeof window.ffhLitSupabase.syncAll === 'function') { await window.ffhLitSupabase.syncAll(); ffhLitLog.info('Post-consent sync completed — all in-memory data pushed to cloud'); toast('☁️ Cloud sync enabled! Your data is now saved.', '#16a34a'); } } catch (syncErr) { ffhLitLog.warn('Post-consent sync failed', syncErr); } updateSyncStatusBar(); } catch (e) { console.error('[FFH-LIT] Consent grant exception:', e); // Grant in-memory + localStorage so user isn't locked out ffhLitSupabaseState.consentGranted = true; try { localStorage.setItem('ffh_lit_consent_' + (ffhLitSupabaseState.userId || 'local'), 'granted'); } catch(x){} hideLitConsentModal(); updateSyncStatusBar(); toast('✅ Consent granted locally (cloud record will retry)', '#f59e0b'); } } function litDenyConsent() { ffhLitSupabaseState.consentGranted = false; hideLitConsentModal(); ffhLitLog.warn('User declined health data collection consent'); } /* ┌─────────────────────────────────────────────────────────────────┐ │ PATCH 4: 30-Minute Inactivity Timeout (WCAG 2.1 AA Compliant) │ └─────────────────────────────────────────────────────────────────┘ */ const FFHInactivityTimer = { warningTimeoutId: null, signoutTimeoutId: null, warningMs: 25 * 60 * 1000, // 25 minutes signoutMs: 30 * 60 * 1000, // 30 minutes isWarningShown: false, reset() { // Clear existing timers if (this.warningTimeoutId) clearTimeout(this.warningTimeoutId); if (this.signoutTimeoutId) clearTimeout(this.signoutTimeoutId); this.isWarningShown = false; // Only set timers if authenticated if (!ffhLitSupabaseState || !ffhLitSupabaseState.isConnected) { return; } // 25-minute warning this.warningTimeoutId = setTimeout(() => { this.showWarning(); }, this.warningMs); // 30-minute signout this.signoutTimeoutId = setTimeout(() => { this.signout(); }, this.signoutMs); }, showWarning() { if (this.isWarningShown) return; this.isWarningShown = true; const msg = document.createElement('div'); msg.id = 'ffhInactivityWarning'; msg.setAttribute('role', 'alert'); msg.setAttribute('aria-live', 'assertive'); msg.style.cssText = 'position:fixed;bottom:24px;right:24px;background:#ff6b35;color:#fff;padding:16px 20px;border-radius:6px;box-shadow:0 4px 12px rgba(0,0,0,0.15);z-index:9998;font-size:14px;max-width:300px;line-height:1.5;'; msg.textContent = 'Your session will expire in 5 minutes due to inactivity. Click to extend your session.'; msg.style.cursor = 'pointer'; msg.addEventListener('click', () => { FFHInactivityTimer.reset(); msg.remove(); this.isWarningShown = false; }); document.body.appendChild(msg); }, async signout() { try { // Show overlay const overlay = document.createElement('div'); overlay.id = 'ffhSessionExpiredOverlay'; overlay.setAttribute('role', 'dialog'); overlay.setAttribute('aria-modal', 'true'); overlay.setAttribute('aria-labelledby', 'ffhSessionExpiredTitle'); overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(15,32,68,0.9);display:flex;justify-content:center;align-items:center;z-index:10000;'; const content = document.createElement('div'); content.style.cssText = 'background:#fff;border-radius:8px;padding:40px;text-align:center;max-width:400px;box-shadow:0 20px 60px rgba(0,0,0,0.3);'; content.innerHTML = '

Session Expired

Your session expired due to 30 minutes of inactivity. Your data has been encrypted and secured. Please log in again to continue.

'; overlay.appendChild(content); document.body.appendChild(overlay); // Sign out from Supabase if (ffhLitSupabaseState && ffhLitSupabaseState.client) { await ffhLitSupabaseState.client.auth.signOut(); } // Clear in-memory PHI clearLitMemoryPHI(); // Redirect on button click const btn = document.getElementById('ffhSessionExpiredBtn'); if (btn) { btn.addEventListener('click', () => { window.location.href = '/'; }); } } catch (e) { ffhLitLog.error('Session signout failed', e.message); } } }; function clearLitMemoryPHI() { // Clear all in-memory health data arrays try { if (typeof ffhLitData !== 'undefined') { ffhLitData.painEntries = []; ffhLitData.weightEntries = []; ffhLitData.treatmentEntries = []; ffhLitData.nutritionHistory = []; ffhLitData.workoutHistory = []; ffhLitData.screenTimeData = []; ffhLitData.serveItEntries = []; } } catch (e) { ffhLitLog.debug('Could not clear memory PHI', e.message); } } // Attach inactivity listeners function initLitInactivityListeners() { const events = ['click', 'keypress', 'scroll', 'touchstart', 'mousemove']; events.forEach(e => { document.addEventListener(e, () => { FFHInactivityTimer.reset(); }, { passive: true }); }); FFHInactivityTimer.reset(); } window.ffhLitSupabase = { initialize: initLitSupabase, syncAll: litSyncAll, loadAll: litLoadAll, savePainEntry: litSavePainEntry, saveWeightEntry: litSaveWeightEntry, saveWeightGoal: litSaveWeightGoal, saveTreatmentEntry: litSaveTreatmentEntry, saveNutritionEntry: litSaveNutritionEntry, saveNutritionGoals: litSaveNutritionGoals, saveScreenTime: litSaveScreenTime, saveServeItEntry: litSaveServeItEntry, saveWorkout: litSaveWorkout, logWorkoutHistory: litLogWorkoutHistory, saveProduct: litSaveProduct, saveChallenge: litSaveChallenge, getStatus: getLitSyncStatus, }; ffhLitLog.info('FFH LIT Supabase Integration module loaded'); // ═══════════════════════════════════════════════════════════════════════════ // DEV LOGIN PANEL (for testing Supabase connection) // Shows when no active session. Remove before production. // ═══════════════════════════════════════════════════════════════════════════ function showDevLoginPanel() { if (document.getElementById('ffh-dev-login-panel')) return; const panel = document.createElement('div'); panel.id = 'ffh-dev-login-panel'; panel.style.cssText = 'position:fixed;top:70px;right:20px;z-index:9999;background:#fff;border:2px solid #0f2044;border-radius:12px;padding:20px;box-shadow:0 8px 32px rgba(0,0,0,0.2);width:320px;font-family:Arial,sans-serif;'; panel.innerHTML = `

🔐 Sign In to Supabase

Dev/Test Mode — connects your tracker data to Supabase cloud storage
`; document.body.appendChild(panel); // Close button document.getElementById('ffh-dev-login-close').addEventListener('click', () => { panel.remove(); }); // Sign in with email/password document.getElementById('ffh-dev-login-btn').addEventListener('click', async () => { const email = document.getElementById('ffh-dev-email').value.trim(); const password = document.getElementById('ffh-dev-password').value; const statusEl = document.getElementById('ffh-dev-login-status'); if (!email || !password) { statusEl.textContent = 'Please enter email and password'; statusEl.style.color = '#dc2626'; return; } statusEl.textContent = 'Signing in...'; statusEl.style.color = '#6b7a99'; try { const { data, error } = await ffhLitSupabaseState.client.auth.signInWithPassword({ email: email, password: password, }); if (error) { statusEl.textContent = 'Error: ' + error.message; statusEl.style.color = '#dc2626'; return; } if (data.session) { ffhLitSupabaseState.session = data.session; ffhLitSupabaseState.userId = data.session.user.id; ffhLitSupabaseState.isConnected = true; ffhLitSupabaseState.PHI_SAFE_MODE = true; // Resolve display name for exports const meta = data.session.user.user_metadata || {}; const rawName = meta.full_name || meta.name || meta.display_name || ''; if (rawName.trim()) { ffhLitSupabaseState.userDisplayName = rawName.trim().split(' ')[0]; } else if (data.session.user.email) { const emailName = data.session.user.email.split('@')[0].replace(/[._-]/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); ffhLitSupabaseState.userDisplayName = emailName.split(' ')[0]; } else { ffhLitSupabaseState.userDisplayName = 'Member'; } console.info('[FFH-LIT] Display name resolved:', ffhLitSupabaseState.userDisplayName); // Update nav bar with user info if (typeof updateNavUserInfo === 'function') { updateNavUserInfo(ffhLitSupabaseState.userDisplayName, data.session.user.email); } // Clear stale PHI from localStorage (same as Patch 1 in initLitSupabase) try { const healthDataKeys = ['litPainHistory', 'litWeightHistory', 'litTreatmentHistory', 'litNutritionHistory', 'litScreenTimeData', 'litWorkoutHistory', 'litServeItHistory', 'litWeightGoals', 'litNutritionGoals']; healthDataKeys.forEach(key => localStorage.removeItem(key)); } catch(e) { /* ignore */ } // Verify profile row exists (required for FK constraints on all LIT tables) try { const { data: profile, error: profileError } = await ffhLitSupabaseState.client .from('profiles') .select('id, organization_id, display_name') .eq('id', ffhLitSupabaseState.userId) .single(); if (profileError || !profile) { // Profile row is MISSING — this will block ALL data saves console.error('[FFH-LIT] ⛔ NO PROFILES ROW for user_id:', ffhLitSupabaseState.userId); console.error('[FFH-LIT] Error:', profileError ? JSON.stringify(profileError) : 'null result'); console.error('[FFH-LIT] Fix: Run this in Supabase SQL Editor:'); console.error(`INSERT INTO profiles (id, email, display_name, first_name, role) VALUES ('${ffhLitSupabaseState.userId}', '${data.session.user.email}', '${ffhLitSupabaseState.userDisplayName}', '${ffhLitSupabaseState.userDisplayName}', 'student');`); toast('⛔ Profile row missing in database — cloud saves will fail! See browser console (F12) for fix.', '#dc2626'); ffhLitSupabaseState.profileExists = false; } else { if (profile.organization_id) ffhLitSupabaseState.organizationId = profile.organization_id; ffhLitSupabaseState.profileExists = true; console.info('[FFH-LIT] ✅ Profile found:', profile.id, '| Display name:', profile.display_name); } } catch(e) { console.warn('Profile verification after login:', e); ffhLitSupabaseState.profileExists = false; } // ── CONSENT GATE (Patch 3 parity) ── // Check for existing health_data_collection consent try { const { data: consents, error: consentError } = await ffhLitSupabaseState.client .from('consent_records') .select('id, consent_type, granted, created_at') .eq('user_id', ffhLitSupabaseState.userId) .eq('consent_type', 'health_data_collection') .order('created_at', { ascending: false }) .limit(1); if (!consentError && consents && consents.length > 0 && consents[0].granted === true) { ffhLitSupabaseState.consentGranted = true; console.info('[FFH-LIT] Consent already on file — writes enabled'); } else { ffhLitSupabaseState.consentGranted = false; console.info('[FFH-LIT] No consent record — showing consent modal'); showLitConsentModal(); } } catch(e) { ffhLitSupabaseState.consentGranted = false; console.warn('[FFH-LIT] Consent check failed after login:', e); showLitConsentModal(); } statusEl.innerHTML = '✅ Connected! Loading your data...'; updateSyncStatusBar(); updateLitConnectionIndicator(); // Start inactivity timer (Patch 4) if (typeof FFHInactivityTimer !== 'undefined') FFHInactivityTimer.reset(); // Load data from Supabase try { await window.ffhLitSupabase.loadAll(); } catch(e) { console.warn('Load after login:', e); } // Sync any existing in-memory data up to Supabase if (ffhLitSupabaseState.consentGranted) { try { await window.ffhLitSupabase.syncAll(); console.info('[FFH-LIT] Synced existing data to Supabase after login'); } catch(e) { console.warn('Sync after login:', e); } } // Remove panel after success setTimeout(() => panel.remove(), 1500); } } catch (e) { statusEl.textContent = 'Connection error: ' + e.message; statusEl.style.color = '#dc2626'; } }); // Magic link option document.getElementById('ffh-dev-magic-link-btn').addEventListener('click', async () => { const email = document.getElementById('ffh-dev-email').value.trim(); const statusEl = document.getElementById('ffh-dev-login-status'); if (!email) { statusEl.textContent = 'Enter your email first'; statusEl.style.color = '#dc2626'; return; } statusEl.textContent = 'Sending magic link...'; statusEl.style.color = '#6b7a99'; try { const { error } = await ffhLitSupabaseState.client.auth.signInWithOtp({ email: email, }); if (error) { statusEl.textContent = 'Error: ' + error.message; statusEl.style.color = '#dc2626'; } else { statusEl.innerHTML = '📧 Magic link sent! Check your inbox.'; } } catch (e) { statusEl.textContent = 'Error: ' + e.message; statusEl.style.color = '#dc2626'; } }); // Allow Enter key to submit document.getElementById('ffh-dev-password').addEventListener('keypress', (e) => { if (e.key === 'Enter') document.getElementById('ffh-dev-login-btn').click(); }); } function hideDevLoginPanel() { const panel = document.getElementById('ffh-dev-login-panel'); if (panel) panel.remove(); } // ── Auto-init on load ── document.addEventListener('DOMContentLoaded', function() { initLitInactivityListeners(); var overviewPanel = document.getElementById('wp-overview'); if(overviewPanel) { overviewPanel.classList.add('active-wp'); overviewPanel.style.display='block'; } // Exercise library loads dynamically when Workouts tab is clicked // Initialize filter UI with empty state (shows body part buttons + equipment, no exercises yet) try { buildWorkoutFilters(); } catch(e) { console.warn('buildWorkoutFilters:', e); } // Wait for Chart.js to load function initWhenReady() { if(typeof Chart !== 'undefined') { try { renderHealthUI(); } catch(e) { console.warn('renderHealthUI:', e); } try { destroyAndRebuildWellnessCharts(); } catch(e) { console.warn('destroyAndRebuildWellnessCharts:', e); } } else { setTimeout(initWhenReady, 100); } } initWhenReady(); // Show initial status bar (will be demo mode until Supabase connects) setTimeout(updateSyncStatusBar, 500); // Async Supabase enhancement: init + load data from cloud if logged in (async function() { try { console.info('[FFH-LIT] 🚀 Boot: calling initialize()...'); await window.ffhLitSupabase.initialize(); console.info('[FFH-LIT] 🚀 Boot: initialize() done. isConnected:', ffhLitSupabaseState.isConnected, '| profileExists:', ffhLitSupabaseState.profileExists); if (ffhLitSupabaseState.isConnected) { console.info('[FFH-LIT] 🚀 Boot: calling loadAll()...'); await window.ffhLitSupabase.loadAll(); console.info('[FFH-LIT] 🚀 Boot: loadAll() done. weightLog length:', typeof weightLog !== 'undefined' ? weightLog.length : 'undefined'); updateSyncStatusBar(); } else { console.info('[FFH-LIT] Running in demo mode — no Supabase session'); // Load demo data for ALL trackers (unauth users only) if (typeof loadDemoWeightData === 'function') loadDemoWeightData(); if (typeof loadDemoPainData === 'function') loadDemoPainData(); if (typeof loadDemoTreatmentData === 'function') loadDemoTreatmentData(); if (typeof renderWeightChart === 'function') renderWeightChart(); if (typeof renderWeightStats === 'function') renderWeightStats(); if (typeof renderWeightLog === 'function') renderWeightLog(); if (typeof renderPainChart === 'function') renderPainChart(); if (typeof renderPainLog === 'function') renderPainLog(); if (typeof renderTxUI === 'function') renderTxUI(); updateSyncStatusBar(); // Show dev login panel in demo mode showDevLoginPanel(); } } catch(e) { console.warn('[FFH-LIT] Supabase async init failed, using demo data:', e); if (typeof loadDemoWeightData === 'function') loadDemoWeightData(); if (typeof loadDemoPainData === 'function') loadDemoPainData(); if (typeof loadDemoTreatmentData === 'function') loadDemoTreatmentData(); if (typeof renderWeightChart === 'function') renderWeightChart(); if (typeof renderWeightStats === 'function') renderWeightStats(); if (typeof renderWeightLog === 'function') renderWeightLog(); if (typeof renderPainChart === 'function') renderPainChart(); if (typeof renderPainLog === 'function') renderPainLog(); if (typeof renderTxUI === 'function') renderTxUI(); showDevLoginPanel(); } })(); });

Virtual Tutor Team

Coach Lucy
Coach LucyHey there! I'm Coach Lucy, your platform guide, motivator, and Joy Seeker coach! You're on the LIVE It Trackers page, your daily wellness check-in hub! Track your movement, nutrition, mood, and sleep. Consistency is the key. Even small daily check-ins earn LIVE It Coins and make a real difference. I can help you set up your trackers, understand your progress, or find tips to stay on track. Have a question about what you're tracking? I'll bring in Dr. Rob. What would you like to log today?