Created
November 2, 2025 04:08
-
-
Save 1337-server/60b272ded062d1db76d9c1e0a2d61cbf to your computer and use it in GitHub Desktop.
MyFitnessPal Diary Smart Widget (v3.0.0) - Fancy, compact diary with animated macro bars per meal, anomaly highlighting, badges, and dark mode. Injected after the dashboard. Uses __NEXT_DATA__ diary array + exercise energy value.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name MyFitnessPal Diary Smart Widget (v3.0.0) | |
| // @namespace mfp-dashboard | |
| // @version 3.0.0 | |
| // @description Fancy, compact diary with animated macro bars per meal, anomaly highlighting, badges, and dark mode. Injected after the dashboard. Uses __NEXT_DATA__ diary array + exercise energy value. | |
| // @author 1337-server | |
| // @match https://www.myfitnesspal.com/* | |
| // @run-at document-end | |
| // @grant none | |
| // ==/UserScript== | |
| (function () { | |
| 'use strict'; | |
| // ---------- Utils ---------- | |
| const clamp = (n, min, max) => Math.max(min, Math.min(max, n)); | |
| const safeNum = (x) => (typeof x === 'number' && isFinite(x) ? x : 0); | |
| async function waitForNextData(retries = 24) { | |
| return new Promise((resolve) => { | |
| const attempt = () => { | |
| const el = document.querySelector('#__NEXT_DATA__'); | |
| if (el) { | |
| try { | |
| const json = JSON.parse(el.textContent); | |
| const queries = json?.props?.pageProps?.dehydratedState?.queries; | |
| if (Array.isArray(queries)) return resolve(queries); | |
| } catch (_) {} | |
| } | |
| if (--retries > 0) setTimeout(attempt, 500); | |
| else resolve(null); | |
| }; | |
| attempt(); | |
| }); | |
| } | |
| function findDiary(queries) { | |
| const today = new Date().toISOString().slice(0, 10); | |
| return ( | |
| queries.find( | |
| (q) => | |
| Array.isArray(q.queryKey) && | |
| q.queryKey[0] === 'diary' && | |
| String(q.queryKey[1]).startsWith(today) | |
| ) || null | |
| ); | |
| } | |
| function groupByMeal(entries) { | |
| const meals = {}; | |
| let steps = 0; | |
| let exerciseCalories = 0; | |
| for (const entry of entries) { | |
| if (entry.type === 'food_entry') { | |
| const meal = entry.meal_name || 'Other'; | |
| if (!meals[meal]) meals[meal] = []; | |
| meals[meal].push(entry); | |
| } else if (entry.type === 'steps_aggregate') { | |
| steps += safeNum(entry.steps); | |
| } else if (entry.type === 'exercise_entry') { | |
| // use actual recorded energy burned | |
| exerciseCalories += safeNum(entry.energy?.value); | |
| } | |
| } | |
| return { meals, steps, exerciseCalories }; | |
| } | |
| function kcalFromMacros(c, p, f) { | |
| return safeNum(c) * 4 + safeNum(p) * 4 + safeNum(f) * 9; | |
| } | |
| function computeMealTotals(items) { | |
| let c = 0, p = 0, f = 0, kc = 0; | |
| for (const it of items) { | |
| const n = it.nutritional_contents || {}; | |
| c += safeNum(n.carbohydrates); | |
| p += safeNum(n.protein); | |
| f += safeNum(n.fat); | |
| kc += safeNum(n.energy?.value); | |
| } | |
| const macroKcal = kcalFromMacros(c, p, f); | |
| // prefer macro-derived calories if present; fall back to sum of energy | |
| const mealKcal = macroKcal > 0 ? macroKcal : kc; | |
| const pct = mealKcal > 0 | |
| ? { | |
| carbs: clamp((c * 4 * 100) / mealKcal, 0, 100), | |
| protein: clamp((p * 4 * 100) / mealKcal, 0, 100), | |
| fat: clamp((f * 9 * 100) / mealKcal, 0, 100), | |
| } | |
| : { carbs: 0, protein: 0, fat: 0 }; | |
| return { | |
| carbs_g: c, protein_g: p, fat_g: f, | |
| kcal: Math.round(mealKcal), | |
| pct, | |
| }; | |
| } | |
| function classifyBadge(pct) { | |
| const { carbs, protein, fat } = pct; | |
| const max = Math.max(carbs, protein, fat); | |
| if (max < 45 && max > 35) return { emoji: '⚡', label: 'Balanced' }; | |
| if (protein >= 35) return { emoji: '🥩', label: 'High Protein' }; | |
| if (fat >= 45) return { emoji: '🧈', label: 'High Fat' }; | |
| if (carbs >= 55) return { emoji: '🍞', label: 'High Carb' }; | |
| // fallback to dominant | |
| if (max === protein) return { emoji: '🥩', label: 'High Protein' }; | |
| if (max === fat) return { emoji: '🧈', label: 'High Fat' }; | |
| return { emoji: '🍞', label: 'High Carb' }; | |
| } | |
| function anomalyClass(food) { | |
| const n = food.nutritional_contents || {}; | |
| const sugar = safeNum(n.sugar); | |
| const fat = safeNum(n.fat); | |
| const sodium = safeNum(n.sodium); | |
| const kcal = safeNum(n.energy?.value); | |
| // red: strong anomaly | |
| if (sugar > 15 || fat > 15 || sodium > 600) return { cls: 'mfp-anom-red', why: `High ${sugar>15?'sugar':fat>15?'fat':'sodium'}` }; | |
| // amber: low protein density (kcal > 200 but protein < 10g) | |
| if (kcal > 200 && safeNum(n.protein) < 10) return { cls: 'mfp-anom-amber', why: 'Low protein density' }; | |
| return null; | |
| } | |
| function cssOnce() { | |
| if (document.getElementById('mfp-diary-css')) return; | |
| const style = document.createElement('style'); | |
| style.id = 'mfp-diary-css'; | |
| style.textContent = ` | |
| :root { | |
| --mfp-bg: #ffffff; | |
| --mfp-text: #111111; | |
| --mfp-sub: #555555; | |
| --mfp-border: #e5e9f0; | |
| --mfp-accent: #007aff; | |
| --mfp-red: #ffd9de; | |
| --mfp-amber: #fff2cc; | |
| --mfp-red-border: #ff5a72; | |
| --mfp-amber-border: #ffcc66; | |
| --mfp-bar-bg: #eef3fb; | |
| --mfp-carb: #5ba6ff; | |
| --mfp-prot: #6bd48d; | |
| --mfp-fat: #f7b267; | |
| } | |
| @media (prefers-color-scheme: dark) { | |
| :root { | |
| --mfp-bg: #1c1c1e; | |
| --mfp-text: #f5f5f7; | |
| --mfp-sub: #bdbdc2; | |
| --mfp-border: #2c2c2e; | |
| --mfp-accent: #4da3ff; | |
| --mfp-red: #3a1f27; | |
| --mfp-amber: #3a2f1f; | |
| --mfp-red-border: #ff6f91; | |
| --mfp-amber-border: #ffc166; | |
| --mfp-bar-bg: #2a2a2c; | |
| --mfp-carb: #5ba6ff; | |
| --mfp-prot: #4ccc85; | |
| --mfp-fat: #f0a85d; | |
| } | |
| } | |
| .mfp-diary { | |
| font-family: 'Inter', 'Helvetica Neue', Helvetica, Arial, sans-serif; | |
| max-width: 760px; | |
| margin: 24px auto; | |
| padding: 18px 20px; | |
| background: var(--mfp-bg); | |
| color: var(--mfp-text); | |
| border-radius: 16px; | |
| border: 1px solid var(--mfp-border); | |
| box-shadow: 0 2px 8px rgba(0,0,0,0.05); | |
| animation: mfpFadeIn 0.4s ease-out; | |
| } | |
| @keyframes mfpFadeIn { from {opacity: 0; transform: translateY(4px);} to {opacity: 1; transform: translateY(0);} } | |
| .mfp-diary h3 { | |
| display: flex; align-items: center; gap: 8px; | |
| font-weight: 700; font-size: 18px; color: var(--mfp-accent); | |
| margin: 0 0 12px 0; | |
| } | |
| .mfp-meal { border-top: 1px solid var(--mfp-border); padding: 10px 0; } | |
| .mfp-meal:first-child { border-top: none; padding-top: 0; } | |
| .mfp-meal-header { | |
| display: flex; align-items: center; justify-content: space-between; gap: 8px; | |
| margin-bottom: 6px; | |
| } | |
| .mfp-meal-title { | |
| font-weight: 700; font-size: 15px; color: var(--mfp-text); display: inline-flex; align-items: center; gap: 6px; | |
| } | |
| .mfp-badge { | |
| display: inline-flex; align-items: center; gap: 6px; | |
| padding: 3px 8px; border-radius: 999px; font-size: 11px; font-weight: 600; color: var(--mfp-accent); | |
| background: rgba(0,120,255,0.12); border: 1px solid rgba(0,120,255,0.25); | |
| text-transform: none; | |
| } | |
| .mfp-meal-item { | |
| display: flex; justify-content: space-between; gap: 12px; | |
| font-size: 13px; color: var(--mfp-sub); padding: 2px 0; | |
| } | |
| .mfp-meal-item.mfp-anom-red { | |
| background: var(--mfp-red); border-left: 3px solid var(--mfp-red-border); color: #7a0014; | |
| border-radius: 6px; padding: 4px 6px; margin: 2px 0; | |
| } | |
| .mfp-meal-item.mfp-anom-amber { | |
| background: var(--mfp-amber); border-left: 3px solid var(--mfp-amber-border); color: #8a5a00; | |
| border-radius: 6px; padding: 4px 6px; margin: 2px 0; | |
| } | |
| /* Bars */ | |
| .mfp-bars { display: grid; grid-template-columns: 1fr; gap: 6px; margin-top: 8px; } | |
| .mfp-bar { | |
| width: 100%; height: 10px; background: var(--mfp-bar-bg); border-radius: 999px; overflow: hidden; position: relative; | |
| } | |
| .mfp-bar > span { | |
| display: block; height: 100%; width: 0%; | |
| border-radius: 999px; transform-origin: left center; | |
| animation: mfpFill 900ms ease forwards; | |
| } | |
| @keyframes mfpFill { from { width: 0%; } to { width: var(--to, 0%); } } | |
| .mfp-carb { background: var(--mfp-carb); } | |
| .mfp-prot { background: var(--mfp-prot); } | |
| .mfp-fat { background: var(--mfp-fat); } | |
| .mfp-bar-labels { | |
| display: flex; justify-content: space-between; font-size: 11px; color: var(--mfp-sub); margin-top: 2px; | |
| } | |
| .mfp-totals { | |
| border-top: 2px solid var(--mfp-accent); | |
| margin-top: 12px; padding-top: 8px; font-weight: 700; | |
| display: flex; justify-content: space-between; color: var(--mfp-accent); | |
| } | |
| .mfp-extra { | |
| margin-top: 6px; font-size: 13px; color: var(--mfp-sub); | |
| display: flex; justify-content: space-between; | |
| } | |
| `; | |
| document.head.appendChild(style); | |
| } | |
| function renderMeal(mealName, items) { | |
| const totals = computeMealTotals(items); | |
| const badge = classifyBadge(totals.pct); | |
| const wrap = document.createElement('div'); | |
| wrap.className = 'mfp-meal'; | |
| const header = document.createElement('div'); | |
| header.className = 'mfp-meal-header'; | |
| header.innerHTML = ` | |
| <div class="mfp-meal-title">${mealName} | |
| <span class="mfp-badge">${badge.emoji} ${badge.label}</span> | |
| </div> | |
| <div class="mfp-meal-kcal">${totals.kcal} kcal</div> | |
| `; | |
| wrap.appendChild(header); | |
| // Items list with anomaly highlighting | |
| for (const f of items) { | |
| const brand = f.food?.brand_name ? `${f.food.brand_name} - ` : ''; | |
| const desc = f.food?.description || 'Food Item'; | |
| const kcal = safeNum(f.nutritional_contents?.energy?.value); | |
| const anomaly = anomalyClass(f); | |
| const row = document.createElement('div'); | |
| row.className = 'mfp-meal-item' + (anomaly ? ` ${anomaly.cls}` : ''); | |
| if (anomaly) row.title = anomaly.why; | |
| row.innerHTML = ` | |
| <span>${brand}${desc}</span> | |
| <span>${Math.round(kcal)} kcal</span> | |
| `; | |
| wrap.appendChild(row); | |
| } | |
| // Animated nutrient bars (percent of this meal’s calories) | |
| const bars = document.createElement('div'); | |
| bars.className = 'mfp-bars'; | |
| bars.innerHTML = ` | |
| <div class="mfp-bar" aria-label="Carbs"> | |
| <span class="mfp-carb" style="--to:${totals.pct.carbs.toFixed(1)}%"></span> | |
| <div class="mfp-bar-labels"> | |
| <span>Carbs ${totals.carbs_g.toFixed(1)}g</span> | |
| <span>${totals.pct.carbs.toFixed(0)}%</span> | |
| </div> | |
| </div> | |
| <div class="mfp-bar" aria-label="Protein"> | |
| <span class="mfp-prot" style="--to:${totals.pct.protein.toFixed(1)}%"></span> | |
| <div class="mfp-bar-labels"> | |
| <span>Protein ${totals.protein_g.toFixed(1)}g</span> | |
| <span>${totals.pct.protein.toFixed(0)}%</span> | |
| </div> | |
| </div> | |
| <div class="mfp-bar" aria-label="Fat"> | |
| <span class="mfp-fat" style="--to:${totals.pct.fat.toFixed(1)}%"></span> | |
| <div class="mfp-bar-labels"> | |
| <span>Fat ${totals.fat_g.toFixed(1)}g</span> | |
| <span>${totals.pct.fat.toFixed(0)}%</span> | |
| </div> | |
| </div> | |
| `; | |
| wrap.appendChild(bars); | |
| return wrap; | |
| } | |
| function renderDiaryWidget(entries) { | |
| const { meals, steps, exerciseCalories } = groupByMeal(entries); | |
| const card = document.createElement('div'); | |
| card.className = 'mfp-diary'; | |
| card.innerHTML = `<h3>🍽️ Today’s Food Diary</h3>`; | |
| // Per-meal sections | |
| for (const [mealName, items] of Object.entries(meals)) { | |
| card.appendChild(renderMeal(mealName, items)); | |
| } | |
| // Totals across all meals (simple, keeps original footer) | |
| let tCal = 0, tC = 0, tP = 0, tF = 0; | |
| for (const items of Object.values(meals)) { | |
| for (const it of items) { | |
| const n = it.nutritional_contents || {}; | |
| tCal += safeNum(n.energy?.value); | |
| tC += safeNum(n.carbohydrates); | |
| tP += safeNum(n.protein); | |
| tF += safeNum(n.fat); | |
| } | |
| } | |
| const totals = document.createElement('div'); | |
| totals.className = 'mfp-totals'; | |
| totals.innerHTML = ` | |
| <span>Total</span> | |
| <span>${Math.round(tCal)} kcal • C:${Math.round(tC)}g P:${Math.round(tP)}g F:${Math.round(tF)}g</span> | |
| `; | |
| card.appendChild(totals); | |
| const extra = document.createElement('div'); | |
| extra.className = 'mfp-extra'; | |
| extra.innerHTML = ` | |
| <span>🚶 Steps: ${steps.toLocaleString()}</span> | |
| <span>🔥 Exercise: ${Math.round(exerciseCalories)} kcal</span> | |
| `; | |
| card.appendChild(extra); | |
| return card; | |
| } | |
| async function inject() { | |
| cssOnce(); | |
| const queries = await waitForNextData(); | |
| if (!queries) return console.warn('❌ __NEXT_DATA__ not found'); | |
| const diaryQ = findDiary(queries); | |
| const diaryData = diaryQ?.state?.data; | |
| if (!Array.isArray(diaryData)) return console.warn('⚠️ Diary data not in expected array form'); | |
| const card = renderDiaryWidget(diaryData); | |
| if (!card) return console.warn('⚠️ Failed to render diary card'); | |
| // inject AFTER the last dashboard section to avoid nesting into grid | |
| const sections = document.querySelectorAll('[data-testid^="qa-regression-"]'); | |
| const last = sections[sections.length - 1]; | |
| if (last) { | |
| last.insertAdjacentElement('afterend', card); | |
| console.log('✅ Injected diary after last dashboard section'); | |
| } else { | |
| document.body.appendChild(card); | |
| console.log('✅ Injected diary at body (fallback)'); | |
| } | |
| } | |
| window.addEventListener('load', () => setTimeout(inject, 1800)); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment