Files
client/www/cal.html
T

1354 lines
44 KiB
HTML

<!DOCTYPE html>
<?xml version="1.0" encoding="UTF-8"?>
<html lang="en" dir="ltr">
<head>
<meta charset="utf-8" />
<title>CALORIES</title>
<link rel="stylesheet" href="./css/client.css" />
<!-- Initialize theme BEFORE any components load -->
<script>
(function () {
const savedTheme = localStorage.getItem('theme');
if (savedTheme === 'dark') {
document.documentElement.classList.add('dark-mode');
if (document.body) {
document.body.classList.add('dark-mode');
}
}
})();
</script>
<link rel="shortcut icon" type="image/x-icon" href="./favicon/favicon-dots2.ico" />
<!-- SVG.js library (required by HamburgerMorphing) -->
<script src="./js/vendor/svg.min.js"></script>
<style media="screen">
#divBody {
display: flex;
flex-wrap: wrap;
flex-direction: column;
padding: 20px;
font-family: var(--font-family);
overflow-y: auto;
}
#divQuickButtons {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 10px;
margin: 5px;
padding-bottom: 6px;
}
#divSearchContainer {
display: flex;
flex-wrap: nowrap;
flex-direction: row;
border: var(--border-width) solid var(--muted-color);
height: 2em;
margin-bottom: 10px;
background-color: var(--secondary-color);
border-radius: var(--border-radius);
}
#divSearch {
height: 2em;
padding: 0.2em;
width: 80%;
font-family: var(--font-family);
color: var(--primary-color);
background-color: var(--secondary-color);
}
#divGrams {
border-left: var(--border-width) solid var(--muted-color);
height: 2em;
padding: 0.2em;
width: 20%;
font-family: var(--font-family);
color: var(--primary-color);
background-color: var(--secondary-color);
}
#divFoodList {
display: flex;
flex-direction: column;
flex-wrap: wrap;
height: 40vh;
overflow-y: scroll;
border: var(--border-width) solid var(--muted-color);
border-radius: var(--border-radius);
padding: 10px;
background-color: var(--secondary-color);
}
#divDiaryScroll {
display: flex;
flex-direction: row;
justify-content: space-between;
margin-top: 25px;
height: 2em;
font-size: 120%;
overflow-y: hidden;
color: var(--primary-color);
}
#divDiary {
height: 25vh;
border: var(--border-width) solid var(--muted-color);
border-radius: var(--border-radius);
padding: 10px;
background-color: var(--secondary-color);
overflow-y: auto;
}
.divQuickButton {
border: 2px solid grey;
background-color: var(--secondary-color);
border-radius: 4px;
margin-left: 4px;
margin-right: 4px;
padding-left: 4px;
padding-right: 4px;
padding-top: 2px;
cursor: pointer;
}
.divQuickButton:hover {
background-color: red;
}
.divFood {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
border: var(--border-width) solid var(--muted-color);
padding: 8px;
margin: 3px;
border-radius: var(--border-radius);
background-color: var(--secondary-color);
cursor: pointer;
font-family: var(--font-family);
}
.divFood:hover {
background-color: var(--button-hover-color);
}
.divFoodFlexLeft {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
justify-content: flex-start;
align-items: center;
left: 0px;
width: 70%;
}
.divFoodFlexRight {
display: flex;
flex-direction: row-reverse;
flex-wrap: nowrap;
justify-content: flex-start;
align-items: center;
right: 0px;
width: 30%;
}
.divServing {
display: flex;
flex-direction: row;
align-items: center;
background-color: var(--muted-color);
border: 1px solid var(--muted-color);
margin-bottom: 4px;
font-size: 75%;
margin-left: 4px;
margin-right: 4px;
}
.divServing:hover {
border: 1px solid black;
cursor: pointer;
}
.divServingSelected {
border: 1px solid black;
background-color: var(--muted-color);
}
.divServingName {
border: 1px solid var(--muted-color);
margin-left: 4px;
margin-right: 2px;
}
.divServingButton {
border: 1px solid grey;
background-color: var(--secondary-color);
border-radius: 4px;
margin-left: 4px;
margin-right: 4px;
padding-left: 2px;
padding-right: 2px;
}
.divServingButton:hover {
background-color: red;
cursor: pointer;
}
/* Sidenav food editing styles */
.divSideTitle {
font-weight: bold;
text-align: center;
color: var(--primary-color);
margin-bottom: 10px;
}
.inDiv {
background-color: var(--secondary-color);
font-size: 85%;
border: var(--border-width) solid var(--muted-color);
min-height: 2em;
padding: 0.5em;
margin-bottom: 5px;
border-radius: var(--border-radius);
color: var(--primary-color);
font-family: var(--font-family);
height: 2.5em;
box-sizing: border-box;
}
.outDiv {
font-size: 85%;
border: var(--border-width) solid var(--muted-color);
min-height: 2em;
padding: 0.5em;
margin-bottom: 5px;
overflow: auto;
height: 10em;
background-color: var(--secondary-color);
border-radius: var(--border-radius);
color: var(--primary-color);
font-family: var(--font-family);
}
.divButton {
display: flex;
flex-direction: row;
align-items: center;
border: var(--border-width) solid var(--primary-color);
justify-content: center;
margin-top: 2em;
height: 4em;
border-radius: var(--border-radius);
background-color: var(--secondary-color);
cursor: pointer;
font-family: var(--font-family);
}
.divButton:hover {
background-color: var(--button-hover-color);
}
.tblDB {
font-family: var(--font-family);
border: var(--border-width) solid var(--muted-color);
border-collapse: collapse;
white-space: nowrap;
width: 100%;
font-size: 80%;
}
.tblDB th {
border: var(--border-width) solid var(--muted-color);
padding: 8px;
font-weight: bold;
vertical-align: center;
text-align: center;
background-color: var(--secondary-color);
color: var(--primary-color);
}
.tblDB td {
border: var(--border-width) solid var(--muted-color);
padding: 8px;
max-width: 400px;
overflow: hidden;
background-color: var(--secondary-color);
color: var(--primary-color);
}
.tblDB tr:hover {
background-color: var(--muted-color);
}
.divTool {
border: var(--border-width) solid var(--primary-color);
border-radius: var(--border-radius);
margin: 10px 0;
padding: 15px;
background-color: var(--secondary-color);
}
.AddFood, .EditFood {
width: 1.5em;
height: 1.5em;
cursor: pointer;
display: inline-block;
text-align: center;
line-height: 1.5em;
}
.AddFood:hover, .EditFood:hover {
background-color: green;
}
.DelFood {
width: 1.5em;
height: 1.5em;
display: inline-block;
cursor: pointer;
}
.DelFood:hover {
background-color: red;
}
.divQuickToggle {
border: 2px solid grey;
background-color: var(--secondary-color);
border-radius: 4px;
margin: 2px;
padding: 2px 4px;
font-size: 12px;
cursor: pointer;
display: inline-block;
}
.divQuickToggle:hover {
background-color: var(--muted-color);
}
.divQuickToggleActive {
border-color: var(--accent-color);
background-color: color-mix(in srgb, var(--accent-color) 15%, var(--secondary-color));
}
/* Sidenav layout - ensure relay section stays at bottom */
#divSideNav {
display: flex;
flex-direction: column;
}
#divSideNavBody {
flex: 1;
overflow-y: auto;
padding: 20px;
}
#divRelaySection {
flex-shrink: 0;
border-top: 1px solid var(--muted-color);
background-color: var(--secondary-color);
}
#divVersionBar {
flex-shrink: 0;
}
/* Modal styles */
#divImportModal {
display: none;
position: fixed;
z-index: 100;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
}
#divImportModalContent {
background-color: var(--secondary-color);
color: var(--primary-color);
margin: 15% auto;
padding: 20px;
border: 1px solid var(--primary-color);
width: 80%;
max-width: 400px;
text-align: center;
}
#divImportModalButtons {
margin-top: 20px;
display: flex;
gap: 10px;
justify-content: center;
}
</style>
</head>
<body>
<!-- Hamburger Button -->
<div id="divSvgHam" class="divHeaderButtons"></div>
<!-- Header -->
<div id="divHeader">
<div id="divHeaderFlexLeft"></div>
<div id="divHeaderFlexCenter">
<div class="divHeaderText">CALORIES</div>
</div>
<div id="divHeaderFlexRight"></div>
</div>
<!-- Body -->
<div id="divBody">
<div id="divQuickButtons"></div>
<div id="divSearchContainer">
<div id="divSearch" tabindex="1" contenteditable="true"></div>
<div id="divGrams" tabindex="2" contenteditable="true"></div>
</div>
<div id="divFoodList"></div>
<div id="divDiaryScroll"></div>
<div id="divDiary"></div>
</div>
<!-- Footer -->
<div id="divFooter">
<div id="divFooterLeft" class="divFooterBox"></div>
<div id="divFooterCenter" class="divFooterBox"></div>
<div id="divFooterRight" class="divFooterBox"></div>
<div id="divFooterBalance" class="divFooterBox">0 sats</div>
</div>
<!-- Sidenav -->
<div id="divSideNav">
<div id="divSideNavHeader"></div>
<div id="divSideNavBody">
<!-- Food editing content will be loaded here -->
</div>
<div id="divAiSection" class="sidenavSection">
<div id="divAiSectionTitle" class="sidenavSectionTitle">AI</div>
<div id="divAiList" class="sidenavSectionList">
<div id="divAiProvidersList">No saved providers yet.</div>
</div>
</div>
<div id="divRelaySection">
<div id="divRelaySectionTitle">リレー</div>
<div id="divRelayList">Loading relays...</div>
</div>
<div id="divBlossomSection">
<div id="divBlossomSectionTitle">ブロッサム</div>
<div id="divBlossomList">Loading blossom servers...</div>
</div>
<div id="divVersionBar">
<span id="versionDisplay">loading...</span>
<div id="divVersionBarButtons">
<button id="themeToggleButton" title="Toggle Dark/Light Mode">
<div id="themeToggleHamburgerContainer"></div>
</button>
<button id="logoutButton" title="Logout">
<div id="logoutHamburgerContainer"></div>
</button>
</div>
</div>
</div>
<!-- Import Modal -->
<div id="divImportModal">
<div id="divImportModalContent">
<h3>Food Database Not Found</h3>
<p>No food database found in Nostr.</p>
<p>Would you like to import the default food database?</p>
<div id="divImportModalButtons">
<div id="btnImportYes" class="btn divButton" style="flex: 1;">Yes, Import</div>
<div id="btnImportNo" class="btn divButton" style="flex: 1;">No</div>
</div>
</div>
</div>
<script src="./nostr.bundle.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="module">
import { initNDKPage, getPubkey, injectHeaderAvatar, subscribe, publishEvent, disconnect, encryptContent, decryptContent } from './js/init-ndk.mjs';
import { HamburgerMorphing } from "./hamburger_morphing/hamburger.mjs";
import { initFooterRelayStatus, updateFooterRelayStatus, initSidenavRelaySection, updateSidenavRelaySection, setRelayActivityState } from './js/relay-ui.mjs';
import { initBlossomSection, updateBlossomSection } from './js/blossom-ui.mjs';
import { initAiSectionWithLocalConfig } from './js/ai-ui.mjs';
import * as UT from "./js/utilities.mjs";
// Fetch version from JSON
const versionResponse = await fetch('./js/version.json');
const versionData = await versionResponse.json();
const VERSION = versionData.VERSION;
console.log(`[cal.html ${VERSION}] Loading...`);
/* ================================================================
GLOBAL VARIABLES
================================================================ */
let updateIntervalId = null;
let currentPubkey = null;
let hamburgerInstance = null;
let isNavOpen = false;
let logoutHamburger = null;
let themeToggleHamburger = null;
let isDarkMode = false;
// Calorie tracker data
let OBJ_FOODS = { rows: [] };
let OBJ_DIARY = { rows: [] };
let START_DATE = new Date();
let END_DATE = new Date();
START_DATE.setHours(0, 0, 0, 0);
END_DATE.setDate(START_DATE.getDate() + 1);
END_DATE.setHours(0, 0, 0, 0);
/* ================================================================
DOM VARIABLES
================================================================ */
const divBody = document.getElementById("divBody");
const divSideNav = document.getElementById("divSideNav");
const divSideNavBody = document.getElementById("divSideNavBody");
const divFooterCenter = document.getElementById("divFooterCenter");
const divFooterRight = document.getElementById("divFooterRight");
const divSearch = document.getElementById("divSearch");
const divGrams = document.getElementById("divGrams");
const divFoodList = document.getElementById("divFoodList");
const divDiary = document.getElementById("divDiary");
const divDiaryScroll = document.getElementById("divDiaryScroll");
const divQuickButtons = document.getElementById("divQuickButtons");
/* ================================================================
HAMBURGER MENU
================================================================ */
function initHamburgerMenu() {
hamburgerInstance = new HamburgerMorphing('#divSvgHam', {
foreground: 'var(--primary-color)',
background: 'var(--secondary-color)',
hover: 'var(--accent-color)'
});
hamburgerInstance.animateTo('burger');
}
function openNav() {
divSideNav.style.zIndex = 3;
divSideNav.style.width = "clamp(400px, 50vw, 600px)";
isNavOpen = true;
if (hamburgerInstance) hamburgerInstance.animateTo('arrow_left');
LoadSidenav();
if (!logoutHamburger) {
logoutHamburger = new HamburgerMorphing('#logoutHamburgerContainer', {
size: 24,
foreground: 'var(--primary-color)',
background: 'var(--secondary-color)',
hover: 'var(--accent-color)'
});
logoutHamburger.animateTo('x');
}
if (!themeToggleHamburger) {
themeToggleHamburger = new HamburgerMorphing('#themeToggleHamburgerContainer', {
size: 24,
foreground: 'var(--primary-color)',
background: 'var(--secondary-color)',
hover: 'var(--accent-color)'
});
const savedTheme = localStorage.getItem('theme');
isDarkMode = savedTheme === 'dark' || document.body.classList.contains('dark-mode');
themeToggleHamburger.animateTo(isDarkMode ? 'moon' : 'circle');
}
}
function closeNav() {
divSideNav.style.width = "0vw";
divSideNav.style.zIndex = -1;
isNavOpen = false;
if (hamburgerInstance) hamburgerInstance.animateTo('burger');
}
function toggleNav() {
isNavOpen ? closeNav() : openNav();
}
/* ================================================================
SIDENAV - FOOD EDITING
================================================================ */
const LoadSidenav = async () => {
let htmlOut = "";
htmlOut += `<div class="divTool">
<div class="divSideTitle">ADD FOOD</div>
<div>Name:</div>
<div id="inpFoodName" class="inDiv" contenteditable="true"></div>
<div style="display: flex; gap: 10px;">
<div style="flex: 1;">
<div>KCal:</div>
<div id="inpKcal" class="inDiv" contenteditable="true" inputmode="numeric"></div>
</div>
<div style="flex: 1;">
<div>Grams:</div>
<div id="inpGrams" class="inDiv" contenteditable="true" inputmode="numeric"></div>
</div>
</div>
<div>Servings (optional):</div>
<div style="display: flex; gap: 10px;">
<div style="flex: 1;"><div id="inpName1" class="inDiv" contenteditable="true" placeholder="Name"></div></div>
<div style="flex: 1;"><div id="inpGServ1" class="inDiv" contenteditable="true" inputmode="numeric" placeholder="Grams"></div></div>
</div>
<div style="display: flex; gap: 10px;">
<div style="flex: 1;"><div id="inpName2" class="inDiv" contenteditable="true" placeholder="Name"></div></div>
<div style="flex: 1;"><div id="inpGServ2" class="inDiv" contenteditable="true" inputmode="numeric" placeholder="Grams"></div></div>
</div>
<div style="display: flex; gap: 10px;">
<div style="flex: 1;"><div id="inpName3" class="inDiv" contenteditable="true" placeholder="Name"></div></div>
<div style="flex: 1;"><div id="inpGServ3" class="inDiv" contenteditable="true" inputmode="numeric" placeholder="Grams"></div></div>
</div>
<div id="btnNewFood" class="btn divButton">ADD FOOD</div>
</div>`;
// Food list table
htmlOut += `<table id="tblFoods" class="tblDB">
<tr>
<th></th>
<th>Food</th>
<th>Cal/g</th>
<th>Quick</th>
</tr>`;
for (let F of OBJ_FOODS.rows) {
let servingButtons = '';
for (let i = 0; i < F.Servings.length; i++) {
const serving = F.Servings[i];
const isActive = F.QuickButtons && F.QuickButtons.includes(i);
const buttonClass = isActive ? 'divQuickToggle divQuickToggleActive' : 'divQuickToggle';
servingButtons += `<div class="${buttonClass}" data-food-id="${F.id}" data-serving-index="${i}">${serving[0]}</div>`;
}
htmlOut += `<tr>
<td><span class="EditFood" data-food-id="${F.id}">E</span><span class="DelFood" data-id="${F.id}">X</span></td>
<td>${F.Food}</td>
<td>${F.CalPerGram}</td>
<td>${servingButtons}</td>
</tr>`;
}
htmlOut += `</table>`;
// Export/Import
htmlOut += `<div class="divTool">
<div class="divSideTitle">EXPORT/IMPORT</div>
<div id="expFood" class="outDiv">&nbsp;</div>
<div id="btnCopyFood" class="btn divButton" style="margin-top:10px;height:2.5em;">Copy Food JSON</div>
<div id="inpFood" class="outDiv" contenteditable="true" style="margin-top:10px;">&nbsp;</div>
<div id="btnSaveFood" class="btn divButton" style="margin-top:10px;height:2.5em;">Import Foods</div>
</div>`;
divSideNavBody.innerHTML = htmlOut;
// Update export area
document.getElementById("expFood").innerText = JSON.stringify(OBJ_FOODS, null, 2);
// Event listeners
document.getElementById("btnNewFood").addEventListener("click", SaveNewFood);
document.getElementById("btnCopyFood").addEventListener("click", () => copyToClipboard("expFood"));
document.getElementById("btnSaveFood").addEventListener("click", InitialSaveFood);
document.querySelectorAll(".DelFood").forEach(el => {
el.addEventListener("click", function() { DeleteFoodItem(this); });
});
document.querySelectorAll(".EditFood").forEach(el => {
el.addEventListener("click", function() { EditFood(this); });
});
document.querySelectorAll(".divQuickToggle").forEach(el => {
el.addEventListener("click", function() { ToggleQuickButton(this); });
});
};
/* ================================================================
CALORIE TRACKER FUNCTIONS
================================================================ */
const LoadQuickButtons = async () => {
divQuickButtons.innerHTML = '';
for (const food of OBJ_FOODS.rows) {
if (food.QuickButtons && food.QuickButtons.length > 0) {
for (const servingIndex of food.QuickButtons) {
if (servingIndex < food.Servings.length) {
const serving = food.Servings[servingIndex];
let divServing = document.createElement("div");
divServing.setAttribute("class", "divQuickButton");
divServing.dataset.Food = food.Food;
divServing.dataset.Grams = serving[1];
divServing.dataset.KCals = (food.CalPerGram * serving[1]).toFixed(0);
divServing.innerHTML = `${food.Food} ${serving[0]}`;
divQuickButtons.appendChild(divServing);
}
}
}
}
// Default buttons if none exist
if (divQuickButtons.children.length === 0) {
const defaults = [
{ Food: "Cream, Heavy", Grams: 10, KCals: 34, Label: "Heavy Cream 10g" },
{ Food: "Egg", Grams: 45, KCals: 71, Label: "Large Egg" },
{ Food: "Sausage, Andouille", Grams: 85, KCals: 200, Label: "Sausage Link" },
{ Food: "Chicken, Breast", Grams: 50, KCals: 86, Label: "Chicken 50g" },
{ Food: "Beef, Ribeye", Grams: 50, KCals: 88, Label: "Steak 50g" },
{ Food: "Butter", Grams: 5, KCals: 36, Label: "Butter 5g" },
];
defaults.forEach(d => {
let divServing = document.createElement("div");
divServing.setAttribute("class", "divQuickButton");
divServing.dataset.Food = d.Food;
divServing.dataset.Grams = d.Grams;
divServing.dataset.KCals = d.KCals;
divServing.innerHTML = d.Label;
divQuickButtons.appendChild(divServing);
});
}
document.querySelectorAll(".divQuickButton").forEach(el => {
el.addEventListener("click", function() { AddDiaryItem(this); });
});
};
const DisplayFoodList = async () => {
divFoodList.innerHTML = "";
let SortArr = await UT.arrOfObjSort(OBJ_FOODS.rows, 'Food');
for (const [row, Each] of SortArr.entries()) {
if (Each.Selected > 0) {
let divFood = document.createElement("div");
divFood.setAttribute("class", Each.Selected == 2 ? "divServing divServingSelected" : "divServing");
divFood.dataset.row = row;
let divFoodName = document.createElement("div");
divFoodName.setAttribute("class", "divServingName");
divFoodName.innerHTML = `${Each.Food}`;
divFood.appendChild(divFoodName);
for (const Serv of Each.Servings) {
let divServing = document.createElement("div");
divServing.setAttribute("class", "divServingButton");
divServing.dataset.Food = Each.Food;
divServing.dataset.Grams = Serv[1];
divServing.dataset.KCals = (Each.CalPerGram * Serv[1]).toFixed(0);
divServing.innerHTML = `${Serv[0]}`;
divFood.appendChild(divServing);
}
divFoodList.appendChild(divFood);
}
}
document.querySelectorAll(".divServingButton").forEach(el => {
el.addEventListener("click", function() { AddDiaryItem(this); });
});
document.querySelectorAll(".divServing").forEach(el => {
el.addEventListener("click", function(e) {
if (e.target.classList.contains('divServing')) {
SelectFood(this);
}
});
});
};
const FilterFoodList = async (FoodFilter = "") => {
let OneSelected = false;
for (const [row, Each] of OBJ_FOODS.rows.entries()) {
if (FoodFilter == "") {
Each.Selected = 1;
} else if (Each.Food.toLowerCase().startsWith(FoodFilter.toLowerCase())) {
Each.Selected = OneSelected ? 1 : 2;
OneSelected = true;
} else {
Each.Selected = 0;
}
}
DisplayFoodList();
};
const SelectFood = async (t) => {
let Row = t.dataset.row;
for (const [row, Each] of OBJ_FOODS.rows.entries()) {
if (row == Row) {
Each.Selected = 2;
divSearch.innerText = Each.Food;
} else {
if (Each.Selected == 2) Each.Selected = 1;
}
}
DisplayFoodList();
};
const UpdateFoodDiary = async () => {
divDiary.innerHTML = "";
let TotalCal = 0;
for (const [row, Each] of OBJ_DIARY.rows.entries()) {
if (Each.Date >= START_DATE.toISOString() && Each.Date < END_DATE.toISOString()) {
TotalCal += Each.KCals;
let divFood = document.createElement("div");
divFood.setAttribute("class", "divFood");
let divFoodLeft = document.createElement("div");
divFoodLeft.setAttribute("class", "divFoodFlexLeft");
let divFoodRight = document.createElement("div");
divFoodRight.setAttribute("class", "divFoodFlexRight");
let svgX = await UT.svgIcon("X");
svgX.setAttribute("class", "svgIcon");
svgX.style.cursor = "pointer";
svgX.addEventListener("click", function() { DeleteDiaryItem(row); });
divFoodLeft.appendChild(svgX);
let divFoodName = document.createTextNode(`${Each.Food} ${Each.Grams}g `);
divFoodLeft.appendChild(divFoodName);
let divFoodCal = document.createTextNode(`${Each.KCals} KCal`);
divFoodRight.appendChild(divFoodCal);
divFood.appendChild(divFoodLeft);
divFood.appendChild(divFoodRight);
divDiary.appendChild(divFood);
}
}
// Total
let divFood = document.createElement("div");
divFood.setAttribute("class", "divFood");
let divFoodLeft = document.createElement("div");
divFoodLeft.setAttribute("class", "divFoodFlexLeft");
let divFoodRight = document.createElement("div");
divFoodRight.setAttribute("class", "divFoodFlexRight");
let divFoodCal = document.createTextNode(`${TotalCal} KCal`);
divFoodRight.appendChild(divFoodCal);
divFood.appendChild(divFoodLeft);
divFood.appendChild(divFoodRight);
divDiary.appendChild(divFood);
};
const LoadDiaryScroll = async () => {
divDiaryScroll.innerHTML = "";
let svgLeft = await UT.svgIcon("carrot_left");
svgLeft.setAttribute("class", "svgIconMedium");
svgLeft.style.cursor = "pointer";
svgLeft.addEventListener("click", function() { IncrementDate(-1); });
divDiaryScroll.appendChild(svgLeft);
let txtDate = document.createTextNode(START_DATE.toLocaleDateString());
divDiaryScroll.appendChild(txtDate);
let svgRight = await UT.svgIcon("carrot_right");
svgRight.setAttribute("class", "svgIconMedium");
svgRight.style.cursor = "pointer";
svgRight.addEventListener("click", function() { IncrementDate(1); });
divDiaryScroll.appendChild(svgRight);
};
const IncrementDate = async (incDays) => {
START_DATE.setDate(START_DATE.getDate() + incDays);
END_DATE.setDate(END_DATE.getDate() + incDays);
LoadDiaryScroll();
UpdateFoodDiary();
};
const DeleteDiaryItem = async (row) => {
OBJ_DIARY.rows.splice(row, 1);
await saveToStorage();
UpdateFoodDiary();
};
const AddDiaryItem = async (t) => {
let AddItem = true;
for (let Each of OBJ_DIARY.rows) {
if (Each.Food === t.dataset.Food && Each.Date >= START_DATE.toISOString() && Each.Date < END_DATE.toISOString()) {
let KcalPerG = Each.KCals / Each.Grams;
Each.Grams += Number(t.dataset.Grams);
Each.KCals += Number((Number(t.dataset.Grams) * KcalPerG).toFixed(0));
AddItem = false;
break;
}
}
if (AddItem) {
let objDiaryEntry = {};
let Cur_id = (await UT.numMax_id(OBJ_DIARY.rows)) + 1;
objDiaryEntry.id = Cur_id;
objDiaryEntry.Date = START_DATE.toISOString();
objDiaryEntry.Food = t.dataset.Food;
objDiaryEntry.Grams = Number(t.dataset.Grams);
objDiaryEntry.KCals = Number(t.dataset.KCals);
OBJ_DIARY.rows.push(objDiaryEntry);
}
await saveToStorage();
UpdateFoodDiary();
};
/* ================================================================
NOSTR STORAGE FUNCTIONS (Kind 30078)
These functions store calorie data on Nostr using kind 30078
application-specific data events with NIP-04 encryption.
Data is stored in one object event:
- "calorie": { foods: {...}, diary: {...} }
On first load, if no data exists on Nostr, a popup asks the user
to import the default food database from food.json.
================================================================ */
// Pending 30078 loads waiting for EOSE
let pending30078Loads = new Map();
// Set up global event listener for 30078 events
window.addEventListener('ndkEvent', (e) => {
const evt = e.detail;
if (evt.kind !== 30078 || evt.pubkey !== currentPubkey) return;
// Extract d tag
const dTag = evt.tags.find(t => t[0] === 'd')?.[1];
if (!dTag) return;
// Check if we're waiting for this load
const pending = pending30078Loads.get(dTag);
if (pending) {
// Keep the most recent event
if (!pending.event || evt.created_at > pending.event.created_at) {
pending.event = evt;
}
}
});
// Handle EOSE events - worker sends { subId } not { data }
window.addEventListener('ndkEose', (e) => {
console.log('[cal.html] ndkEose received:', e.detail);
const subId = e.detail?.subId;
if (!subId) {
console.log('[cal.html] ndkEose: no subId, ignoring');
return;
}
// Check pending loads
for (const [dTag, pending] of pending30078Loads) {
if (pending.subId === subId) {
if (pending.event) {
// Decrypt and resolve
(async () => {
try {
const pubkey = await getPubkey();
const decryptedResp = await decryptContent(pubkey, pending.event.content, 'auto');
const decrypted = String(decryptedResp?.plaintext || '');
const obj = JSON.parse(decrypted);
console.log(`[cal.html] Loaded ${dTag} from Nostr:`, obj);
pending.resolve({ found: true, data: obj });
} catch (error) {
console.error(`[cal.html] Failed to decrypt ${dTag}:`, error);
pending.resolve({ found: false, error: error.message });
}
})();
} else {
console.log(`[cal.html] No ${dTag} found in Nostr`);
pending.resolve({ found: false });
}
pending30078Loads.delete(dTag);
break;
}
}
});
// Save object to Nostr kind 30078
const saveObj30078 = async (name, obj) => {
console.log(`[cal.html] Saving ${name} to Nostr...`);
try {
const pubkey = await getPubkey();
const encryptedResp = await encryptContent(pubkey, JSON.stringify(obj), 'nip44');
const encrypted = String(encryptedResp?.ciphertext || '');
const event = {
kind: 30078,
created_at: Math.floor(Date.now() / 1000),
tags: [['d', name]],
content: encrypted
};
const result = await publishEvent(event);
console.log(`[cal.html] Saved ${name} to Nostr:`, result);
return result;
} catch (error) {
console.error(`[cal.html] Failed to save ${name}:`, error);
throw error;
}
};
// Load object from Nostr kind 30078
const loadObj30078 = async (name) => {
console.log(`[cal.html] Loading ${name} from Nostr...`);
return new Promise((resolve) => {
// Create subscription
const sub = subscribe(
{ kinds: [30078], '#d': [name], authors: [currentPubkey] },
{ closeOnEose: true, cacheUsage: 'CACHE_FIRST' }
);
// Store pending load
pending30078Loads.set(name, {
subId: sub.subId,
event: null,
resolve: resolve
});
// Timeout after 5 seconds
setTimeout(() => {
const pending = pending30078Loads.get(name);
if (pending) {
pending30078Loads.delete(name);
sub.close();
resolve({ found: false, timeout: true });
}
}, 5000);
});
};
// Show import modal
const showImportModal = () => {
return new Promise((resolve) => {
const modal = document.getElementById('divImportModal');
const btnYes = document.getElementById('btnImportYes');
const btnNo = document.getElementById('btnImportNo');
modal.style.display = 'block';
const handleYes = () => {
modal.style.display = 'none';
btnYes.removeEventListener('click', handleYes);
btnNo.removeEventListener('click', handleNo);
resolve(true);
};
const handleNo = () => {
modal.style.display = 'none';
btnYes.removeEventListener('click', handleYes);
btnNo.removeEventListener('click', handleNo);
resolve(false);
};
btnYes.addEventListener('click', handleYes);
btnNo.addEventListener('click', handleNo);
});
};
// Load default food.json
const loadDefaultFoods = async () => {
console.log('[cal.html] Loading default food.json...');
try {
const response = await fetch('./food.json');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
console.log('[cal.html] Loaded default foods:', data);
return data;
} catch (error) {
console.error('[cal.html] Failed to load food.json:', error);
return { rows: [] };
}
};
// Initialize data - try Nostr first, fallback to import
const initializeData = async () => {
console.log('[cal.html] Initializing data...');
// Preferred v2 layout: single d:calorie event
const combinedResult = await loadObj30078('calorie');
if (combinedResult.found) {
const payload = combinedResult.data || {};
OBJ_FOODS = payload?.foods && typeof payload.foods === 'object' ? payload.foods : { rows: [] };
OBJ_DIARY = payload?.diary && typeof payload.diary === 'object' ? payload.diary : { rows: [] };
console.log('[cal.html] Using combined calorie payload from Nostr');
return;
}
// Legacy fallback: read old split d-tags, then migrate to combined payload.
const foodsResult = await loadObj30078('calorie_foods');
if (foodsResult.found) {
OBJ_FOODS = foodsResult.data;
console.log('[cal.html] Using legacy foods from Nostr');
} else {
const shouldImport = await showImportModal();
if (shouldImport) {
OBJ_FOODS = await loadDefaultFoods();
} else {
OBJ_FOODS = { rows: [] };
}
}
const diaryResult = await loadObj30078('calorie_diary');
if (diaryResult.found) {
OBJ_DIARY = diaryResult.data;
console.log('[cal.html] Using legacy diary from Nostr');
} else {
OBJ_DIARY = { rows: [] };
}
// One-time migration write to v2 combined event.
await saveObj30078('calorie', { foods: OBJ_FOODS, diary: OBJ_DIARY });
};
// Wrapper functions for compatibility
const saveToStorage = async () => {
await saveObj30078('calorie', { foods: OBJ_FOODS, diary: OBJ_DIARY });
};
const loadFromStorage = () => {
// No-op - data loaded via initializeData
console.log('[cal.html] loadFromStorage called (no-op, use initializeData)');
};
/* ================================================================
FOOD MANAGEMENT
================================================================ */
const SaveNewFood = async () => {
let Food = document.getElementById("inpFoodName").innerText.trim();
let Kcal = Number(document.getElementById("inpKcal").innerText.trim());
let Grams = Number(document.getElementById("inpGrams").innerText.trim());
if (!Food || !Kcal || !Grams) {
alert("Please fill in food name, calories, and grams");
return;
}
let objNewFood = {};
objNewFood.id = Date.now();
objNewFood.Food = Food;
objNewFood.Selected = 0;
objNewFood.CalPerGram = Number((Kcal / Grams).toFixed(3));
objNewFood.Servings = [["100g", 100]];
objNewFood.QuickButtons = [];
// Add optional servings
for (let i = 1; i <= 3; i++) {
let Name = document.getElementById(`inpName${i}`).innerText.trim();
let Gram = Number(document.getElementById(`inpGServ${i}`).innerText.trim());
if (Name.length > 0 && Gram > 0) {
objNewFood.Servings.push([Name, Gram]);
}
}
OBJ_FOODS.rows.push(objNewFood);
await saveToStorage();
await FilterFoodList();
DisplayFoodList();
LoadQuickButtons();
LoadSidenav();
};
const DeleteFoodItem = async (t) => {
for (const [i, Each] of OBJ_FOODS.rows.entries()) {
if (Number(Each.id) == Number(t.dataset.id)) {
OBJ_FOODS.rows.splice(i, 1);
await saveToStorage();
DisplayFoodList();
LoadQuickButtons();
LoadSidenav();
break;
}
}
};
const ToggleQuickButton = async (t) => {
const foodId = t.dataset.foodId;
const servingIndex = parseInt(t.dataset.servingIndex);
for (const food of OBJ_FOODS.rows) {
if (food.id == foodId) {
if (!food.QuickButtons) food.QuickButtons = [];
const index = food.QuickButtons.indexOf(servingIndex);
if (index > -1) {
food.QuickButtons.splice(index, 1);
} else {
food.QuickButtons.push(servingIndex);
}
await saveToStorage();
LoadQuickButtons();
LoadSidenav();
break;
}
}
};
const EditFood = async (t) => {
// Simple edit - just alert for now, can be expanded
alert("Edit functionality - click Quick buttons to toggle quick add");
};
const copyToClipboard = async (elementId) => {
const element = document.getElementById(elementId);
try {
await navigator.clipboard.writeText(element.innerText);
console.log("JSON copied to clipboard");
} catch (err) {
console.error("Failed to copy:", err);
}
};
const InitialSaveFood = async () => {
try {
let objF = JSON.parse(document.getElementById("inpFood").innerText.trim());
OBJ_FOODS = objF;
await saveToStorage();
FilterFoodList();
DisplayFoodList();
LoadQuickButtons();
LoadSidenav();
} catch (e) {
alert("Invalid JSON");
}
};
/* ================================================================
SEARCH
================================================================ */
const SearchBarKeyDown = async (evt, t) => {
if (evt.key.length == 1) {
let FullWord = (t.innerText + evt.key).trim().toLowerCase();
FilterFoodList(FullWord);
} else if (evt.key == "Backspace") {
let FullWord = t.innerText.trim().toLowerCase().slice(0, -1);
FilterFoodList(FullWord);
}
};
const NewDiaryLostFocus = async () => {
if (!Number.isNaN(divGrams.innerText)) {
for (const [row, Each] of OBJ_FOODS.rows.entries()) {
if (Each.Selected == 2) {
let AddItem = true;
for (let F of OBJ_DIARY.rows) {
if (F.Food === Each.Food && F.Date >= START_DATE.toISOString() && F.Date < END_DATE.toISOString()) {
let KcalPerG = Number(F.KCals / F.Grams);
F.Grams += Number(divGrams.innerText);
F.KCals += Number((Number(divGrams.innerText) * KcalPerG).toFixed(0));
AddItem = false;
break;
}
}
if (AddItem) {
let objDiaryEntry = {};
let Cur_id = (await UT.numMax_id(OBJ_DIARY.rows)) + 1;
objDiaryEntry.id = Cur_id;
objDiaryEntry.Date = START_DATE.toISOString();
objDiaryEntry.Food = Each.Food;
objDiaryEntry.Grams = Number(divGrams.innerText);
objDiaryEntry.KCals = Number((Each.CalPerGram * Number(divGrams.innerText)).toFixed(0));
OBJ_DIARY.rows.push(objDiaryEntry);
}
await saveToStorage();
UpdateFoodDiary();
divGrams.innerText = "";
divSearch.innerText = "";
FilterFoodList();
return;
}
}
}
};
/* ================================================================
FOOTER & LOGOUT
================================================================ */
const UpdateFooter = async () => {
try {
await updateFooterRelayStatus();
await updateSidenavRelaySection();
await updateBlossomSection();
divFooterCenter.innerHTML = '';
divFooterRight.innerHTML = '';
} catch (error) {
console.error('[cal.html] Error updating footer:', error);
}
};
const Logout = async () => {
if (updateIntervalId) {
clearInterval(updateIntervalId);
updateIntervalId = null;
}
disconnect();
if (window.NOSTR_LOGIN_LITE?.logout) {
await window.NOSTR_LOGIN_LITE.logout();
}
localStorage.clear();
sessionStorage.clear();
location.reload(true);
};
/* ================================================================
INITIALIZATION
================================================================ */
(async function main() {
try {
initHamburgerMenu();
document.getElementById('divSvgHam').addEventListener('click', toggleNav);
document.getElementById('themeToggleButton')?.addEventListener('click', () => {
isDarkMode = !isDarkMode;
localStorage.setItem('theme', isDarkMode ? 'dark' : 'light');
document.documentElement.classList.toggle('dark-mode', isDarkMode);
document.body.classList.toggle('dark-mode', isDarkMode);
if (themeToggleHamburger) {
themeToggleHamburger.animateTo(isDarkMode ? 'moon' : 'circle');
}
});
document.getElementById('logoutButton')?.addEventListener('click', Logout);
await initNDKPage();
currentPubkey = await getPubkey();
await injectHeaderAvatar(currentPubkey);
console.log('[cal.html] Authenticated as:', currentPubkey);
initFooterRelayStatus();
initSidenavRelaySection();
await initBlossomSection();
initAiSectionWithLocalConfig();
// Load calorie data from Nostr (or import default)
await initializeData();
// Set up search
divSearch.addEventListener("keydown", function() { SearchBarKeyDown(event, this); });
divGrams.addEventListener("blur", function() { NewDiaryLostFocus(); });
// Initialize display
LoadQuickButtons();
await FilterFoodList();
DisplayFoodList();
LoadDiaryScroll();
UpdateFoodDiary();
// Start update loop
updateIntervalId = setInterval(UpdateFooter, 1000);
document.getElementById('versionDisplay').textContent = VERSION;
console.log('[cal.html] Initialization complete');
} catch (error) {
console.error('[cal.html] Initialization failed:', error);
}
})();
</script>
</body>
</html>