HTML preprocessors can make writing HTML more powerful or convenient. For instance, Markdown is designed to be easier to write and read for text documents and you could write a loop in Pug.
In CodePen, whatever you write in the HTML editor is what goes within the <body>
tags in a basic HTML5 template. So you don't have access to higher-up elements like the <html>
tag. If you want to add classes there that can affect the whole document, this is the place to do it.
In CodePen, whatever you write in the HTML editor is what goes within the <body>
tags in a basic HTML5 template. If you need things in the <head>
of the document, put that code here.
The resource you are linking to is using the 'http' protocol, which may not work when the browser is using https.
CSS preprocessors help make authoring CSS easier. All of them offer things like variables and mixins to provide convenient abstractions.
It's a common practice to apply CSS to a page that styles elements such that they are consistent across all browsers. We offer two of the most popular choices: normalize.css and a reset. Or, choose Neither and nothing will be applied.
To get the best cross-browser support, it is a common practice to apply vendor prefixes to CSS properties and values that require them to work. For instance -webkit-
or -moz-
.
We offer two popular choices: Autoprefixer (which processes your CSS server-side) and -prefix-free (which applies prefixes via a script, client-side).
Any URLs added here will be added as <link>
s in order, and before the CSS in the editor. You can use the CSS from another Pen by using its URL and the proper URL extension.
You can apply CSS to your Pen from any stylesheet on the web. Just put a URL to it here and we'll apply it, in the order you have them, before the CSS in the Pen itself.
You can also link to another Pen here (use the .css
URL Extension) and we'll pull the CSS from that Pen and include it. If it's using a matching preprocessor, use the appropriate URL Extension and we'll combine the code before preprocessing, so you can use the linked Pen as a true dependency.
JavaScript preprocessors can help make authoring JavaScript easier and more convenient.
Babel includes JSX processing.
Any URL's added here will be added as <script>
s in order, and run before the JavaScript in the editor. You can use the URL of any other Pen and it will include the JavaScript from that Pen.
You can apply a script from anywhere on the web to your Pen. Just put a URL to it here and we'll add it, in the order you have them, before the JavaScript in the Pen itself.
If the script you link to has the file extension of a preprocessor, we'll attempt to process it before applying.
You can also link to another Pen here, and we'll pull the JavaScript from that Pen and include it. If it's using a matching preprocessor, we'll combine the code before preprocessing, so you can use the linked Pen as a true dependency.
Search for and use JavaScript packages from npm here. By selecting a package, an import
statement will be added to the top of the JavaScript editor for this package.
Using packages here is powered by esm.sh, which makes packages from npm not only available on a CDN, but prepares them for native JavaScript ESM usage.
All packages are different, so refer to their docs for how they work.
If you're using React / ReactDOM, make sure to turn on Babel for the JSX processing.
If active, Pens will autosave every 30 seconds after being saved once.
If enabled, the preview panel updates automatically as you code. If disabled, use the "Run" button to update.
If enabled, your code will be formatted when you actively save your Pen. Note: your code becomes un-folded during formatting.
Visit your global Editor Settings.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Fasting Tracker โ 3-Month Calendar</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<style>
html, body { height: 100%; font-family: 'Inter', sans-serif; }
.day-box {
transition: transform 0.1s;
position: relative;
width: 100%;
height: 110px; /* Adjusted height for better content fit */
overflow: hidden;
border-radius: 0.375rem; /* rounded-md */
border: 1px solid transparent;
}
.day-box:active { transform: scale(0.95); }
/* Style for weekend days */
.day-box.weekend::before {
content: "";
position: absolute; top: 0; left: 0; right: 0; bottom: 0;
background-color: rgba(0, 0, 0, 0.08); /* Subtle overlay */
border-radius: inherit;
pointer-events: none; z-index: 0;
}
.day-box > * { position: relative; z-index: 1; } /* Ensure content is above overlay */
.day-number {
display: block;
font-weight: 600; /* semibold */
font-size: 10px; /* Smaller day number */
margin-bottom: 2px;
color: #E5E7EB; /* gray-200 */
}
.notes-preview-container {
font-size: 0.7rem; /* Slightly smaller icons */
line-height: 1;
margin-top: 1px;
max-height: 3.5em; /* Limit height */
overflow: hidden;
display: flex;
flex-wrap: wrap;
gap: 2px;
}
.stats-container {
position: absolute;
bottom: 4px;
left: 4px;
right: 4px;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 1px;
font-size: 12px; /* Small text for stats */
line-height: 1.1;
color: rgba(255, 255, 255, 0.85); /* Slightly transparent white */
}
.stat-steps { font-size:16px; font-weight: bold; } /* Make steps stand out */
/* Ensure chat messages wrap correctly */
#chatContainer > div {
word-wrap: break-word;
overflow-wrap: break-word;
white-space: pre-wrap;
}
/* Styling for the AI thinking indicator */
.thinking-indicator {
background-color: #4a5568; /* gray-700 */
padding: 0.5rem;
border-radius: 0.375rem; /* rounded-md */
margin-bottom: 0.5rem;
align-self: flex-start;
animation: pulse 1.5s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: .5; }
}
/* Fade out animation for save confirmation */
.fade-out {
animation: fadeOut 1.5s ease-out forwards;
}
@keyframes fadeOut {
from { opacity: 1; }
to { opacity: 0; }
}
/* Ensure calendar grid takes available space */
#calendarContainer > div {
display: flex;
flex-direction: column;
min-height: 0; /* Important for flex-grow in child */
}
#calendarContainer .grid {
flex-grow: 1; /* Allow grid to fill space */
}
/* Custom scrollbar styling */
::-webkit-scrollbar { width: 8px; height: 8px;}
::-webkit-scrollbar-track { background: #2d3748; border-radius: 10px;} /* gray-800 */
::-webkit-scrollbar-thumb { background: #4a5568; border-radius: 10px;} /* gray-700 */
::-webkit-scrollbar-thumb:hover { background: #718096;} /* gray-600 */
</style>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
</head>
<body class="bg-gray-900 text-white flex">
<div class="flex h-screen w-full">
<div id="sidebar" class="w-[250px] border-r border-gray-700 p-4 flex flex-col justify-between bg-gray-800 shadow-lg overflow-y-auto">
<div>
<h2 class="text-xl font-bold mb-4 text-gray-100">Day Details</h2>
<div id="sidebarContent" class="text-sm text-gray-300"><p>Select a day from the calendar.</p></div>
<div id="globalSaveContainer" class="mt-4"></div>
</div>
<div class="text-xs mt-auto pt-4 border-t border-gray-700">
<div class="flex justify-between items-center mb-3">
<a href="#" id="resetLink" class="text-red-400 hover:text-red-300 hover:underline">Reset All Data</a>
<a href="#" id="settingsLink" class="text-blue-400 hover:text-blue-300 hover:underline">Settings</a>
</div>
<div class="flex justify-between items-center mb-3">
<a href="#" id="exportLink" class="text-green-400 hover:text-green-300 hover:underline">Export Data</a>
<a href="#" id="importLink" class="text-yellow-400 hover:text-yellow-300 hover:underline">Import Data</a>
</div>
<div id="exportArea" class="mt-2 hidden">
<textarea id="exportTextarea" class="w-full bg-gray-700 border border-gray-600 rounded p-2 text-xs text-gray-200" rows="5" readonly placeholder="JSON data will appear here..."></textarea>
</div>
<div id="importArea" class="mt-2 hidden">
<textarea id="importTextarea" class="w-full bg-gray-700 border border-gray-600 rounded p-2 text-xs text-gray-200" rows="5" placeholder="Paste JSON data here"></textarea>
<button id="importButton" class="w-full mt-2 px-3 py-1 bg-yellow-600 rounded hover:bg-yellow-700 text-white text-xs font-semibold">Import Data Now</button>
</div>
<div id="settingsArea" class="mt-2 hidden bg-gray-700 p-3 rounded border border-gray-600">
<label class="block mb-1 text-xs font-medium text-gray-300">OpenAI API Key:</label>
<input id="apiKeyInput" type="password" placeholder="Enter OpenAI API Key" class="w-full bg-gray-600 border border-gray-500 rounded p-1 mb-2 text-xs text-white" />
<button id="saveApiKeyBtn" class="w-full px-3 py-1 bg-blue-600 rounded hover:bg-blue-700 text-xs text-white font-semibold mb-3">Save API Key</button>
<label class="block mb-1 text-xs font-medium text-gray-300">AI System Prompt:</label>
<textarea id="systemPromptInput" placeholder="Enter system prompt for AI (optional)" class="w-full bg-gray-600 border border-gray-500 rounded p-1 mb-2 text-xs text-white" rows="3"></textarea>
<button id="saveSystemPromptBtn" class="w-full px-3 py-1 bg-blue-600 rounded hover:bg-blue-700 text-xs text-white font-semibold">Save Prompt</button>
</div>
</div>
</div>
<div class="flex-1 p-4 flex flex-col h-full overflow-hidden">
<div class="mb-4">
<p class="text-xs text-center text-gray-400 mb-1">Fasting Progress (Visible Months)</p>
<div class="w-full bg-gray-700 rounded-full h-2.5 overflow-hidden shadow-inner">
<div id="percentageBar" class="h-full bg-gradient-to-r from-blue-500 to-cyan-500 rounded-full transition-all duration-500 ease-out" style="width:0%;"></div>
</div>
</div>
<div class="mb-4 flex items-center justify-between">
<button id="prevMonth" class="px-4 py-1.5 bg-gray-700 rounded-md hover:bg-gray-600 text-xs font-semibold shadow">Prev</button>
<div class="flex space-x-8 text-center">
<h1 id="monthTitle0" class="text-lg font-bold text-gray-100 w-48 truncate"></h1>
<h1 id="monthTitle1" class="text-lg font-bold text-gray-100 w-48 truncate"></h1>
<h1 id="monthTitle2" class="text-lg font-bold text-gray-100 w-48 truncate"></h1>
</div>
<button id="nextMonth" class="px-4 py-1.5 bg-gray-700 rounded-md hover:bg-gray-600 text-xs font-semibold shadow">Next</button>
</div>
<div id="calendarContainer" class="flex-1 overflow-y-auto flex flex-col space-y-6 pb-4">
</div>
</div>
<div id="chatSidebar" class="w-[400px] border-l border-gray-700 p-4 flex flex-col bg-gray-800 shadow-lg h-full">
<div class="flex justify-between items-center mb-3 pb-3 border-b border-gray-700">
<h2 class="text-xl font-bold text-gray-100">AI Assistant</h2>
<div class="flex space-x-2">
<button id="newChatButton" title="Start New Chat" class="px-2 py-1 bg-blue-600 rounded-md hover:bg-blue-700 text-xs font-bold text-white shadow">+</button>
<button id="deleteChatButton" title="Delete Current Chat" class="px-2 py-1 bg-red-600 rounded-md hover:bg-red-700 text-xs font-bold text-white shadow disabled:opacity-50 disabled:cursor-not-allowed" disabled>Delete</button>
</div>
</div>
<div id="chatPagination" class="flex justify-between mb-2">
<button id="prevChat" class="px-3 py-1 bg-gray-700 rounded-md hover:bg-gray-600 text-xs font-semibold shadow disabled:opacity-50 disabled:cursor-not-allowed" disabled>Prev Chat</button>
<span id="chatSessionIndicator" class="text-xs text-gray-400 self-center">Chat 1 / 1</span>
<button id="nextChat" class="px-3 py-1 bg-gray-700 rounded-md hover:bg-gray-600 text-xs font-semibold shadow disabled:opacity-50 disabled:cursor-not-allowed" disabled>Next Chat</button>
</div>
<div id="chatContainer" class="flex-1 overflow-y-auto mb-3 bg-gray-900 p-3 rounded-lg shadow-inner flex flex-col space-y-2" style="font-size: 0.875rem;">
</div>
<textarea id="chatInput" placeholder="Ask AI about your fasting data..." class="w-full bg-gray-700 border border-gray-600 rounded-lg p-2 text-sm text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none" rows="3"></textarea>
</div>
</div>
<script>
// --- Global State ---
let today = new Date();
let currentMonth = today.getMonth();
let currentYear = today.getFullYear();
let selected = { monthIndex: null, dayIndex: null }; // Tracks selected day { view index, day index }
let fastingData = {}; // Main data store: { "YYYY-MM": [dayData1, dayData2, ...] }
let openaiApiKey = ""; // User's OpenAI API key
let systemPrompt = ""; // Custom system prompt for the AI
let chatSessions = []; // Array of chat sessions: [{ id: timestamp, messages: [{ sender, text }] }]
let currentChatIndex = 0; // Index of the currently viewed chat session
let dataCheckInterval = null; // Interval timer for checking localStorage changes (for multi-tab sync)
let lastKnownStorageState = { fastingData: null, chatSessions: null, settings: null }; // For multi-tab sync check
// --- DOM Elements ---
const sidebarContent = document.getElementById("sidebarContent");
const globalSaveContainer = document.getElementById("globalSaveContainer");
const percentageBar = document.getElementById("percentageBar");
const calendarContainer = document.getElementById("calendarContainer");
const monthTitles = [
document.getElementById("monthTitle0"),
document.getElementById("monthTitle1"),
document.getElementById("monthTitle2")
];
const prevMonthBtn = document.getElementById("prevMonth");
const nextMonthBtn = document.getElementById("nextMonth");
const resetLink = document.getElementById("resetLink");
const exportLink = document.getElementById("exportLink");
const importLink = document.getElementById("importLink");
const settingsLink = document.getElementById("settingsLink");
const exportArea = document.getElementById("exportArea");
const exportTextarea = document.getElementById("exportTextarea");
const importArea = document.getElementById("importArea");
const importTextarea = document.getElementById("importTextarea");
const importButton = document.getElementById("importButton");
const settingsArea = document.getElementById("settingsArea");
const apiKeyInput = document.getElementById("apiKeyInput");
const saveApiKeyBtn = document.getElementById("saveApiKeyBtn");
const systemPromptInput = document.getElementById("systemPromptInput");
const saveSystemPromptBtn = document.getElementById("saveSystemPromptBtn");
const chatContainer = document.getElementById("chatContainer");
const chatInput = document.getElementById("chatInput");
const newChatButton = document.getElementById("newChatButton");
const deleteChatButton = document.getElementById("deleteChatButton");
const prevChatBtn = document.getElementById("prevChat");
const nextChatBtn = document.getElementById("nextChat");
const chatSessionIndicator = document.getElementById("chatSessionIndicator");
// --- Data Handling ---
/**
* Loads data (fasting records, settings, chat history) from localStorage.
* Initializes data structures if they don't exist or are invalid.
*/
function loadData() {
try {
fastingData = JSON.parse(localStorage.getItem("fastingData")) || {};
// Basic validation: ensure it's an object, not an array or primitive
if (typeof fastingData !== 'object' || Array.isArray(fastingData) || fastingData === null) {
fastingData = {};
}
} catch {
fastingData = {}; // Reset if parsing fails
console.error("Failed to parse fastingData from localStorage.");
}
openaiApiKey = localStorage.getItem("openaiApiKey") || "";
// Default system prompt includes the current date/time for context
const dynamicDate = new Date().toLocaleDateString('en-US',{weekday:'long',month:'long',day:'numeric',year:'numeric',hour:'numeric',minute:'numeric',hour12:false});
// ** Added explicit instruction about fastingHours and summaries **
systemPrompt = localStorage.getItem("systemPrompt") || `You are a helpful assistant analyzing fasting data. Be concise and encouraging. Today is ${dynamicDate}. The 'fastingHours' field represents the duration of the fast in hours for that specific day (use this for calculations). Summaries for the relevant period are provided first, followed by daily details.`;
try {
chatSessions = JSON.parse(localStorage.getItem("chatSessions")) || [];
if (!Array.isArray(chatSessions)) chatSessions = []; // Ensure it's an array
} catch {
chatSessions = []; // Reset if parsing fails
console.error("Failed to parse chatSessions from localStorage.");
}
// Ensure there's at least one chat session
if (!chatSessions.length || chatSessions.some(s => !s || !Array.isArray(s.messages))) {
chatSessions = [{ id: Date.now(), messages: [] }];
}
currentChatIndex = chatSessions.length - 1; // Start with the latest chat
// Populate settings fields if they exist
if (apiKeyInput) apiKeyInput.value = openaiApiKey;
if (systemPromptInput) systemPromptInput.value = systemPrompt; // Load potentially custom prompt
updateLastKnownStorageState(); // Store initial state for multi-tab sync check
}
/**
* Saves the current `fastingData` object to localStorage.
*/
function saveData() {
try {
const dataString = JSON.stringify(fastingData);
localStorage.setItem("fastingData", dataString);
lastKnownStorageState.fastingData = dataString; // Update known state
} catch(err) {
console.error("Error saving fasting data:", err);
alert("Error saving fasting data. Check console for details.");
}
}
/**
* Saves the current AI settings (API key, system prompt) to localStorage.
*/
function saveSettings() {
localStorage.setItem("openaiApiKey", openaiApiKey);
localStorage.setItem("systemPrompt", systemPrompt);
lastKnownStorageState.settings = JSON.stringify({ openaiApiKey, systemPrompt }); // Update known state
}
/**
* Saves the current `chatSessions` array to localStorage.
*/
function saveChats() {
try {
const chatsString = JSON.stringify(chatSessions);
localStorage.setItem("chatSessions", chatsString);
lastKnownStorageState.chatSessions = chatsString; // Update known state
} catch(err) {
console.error("Error saving chat data:", err);
alert("Error saving chat data. Check console for details.");
}
}
/**
* Initializes or validates the data structure for a given month in `fastingData`.
* Ensures the month exists and has the correct number of days, filling missing days/properties.
* @param {number} y - Year
* @param {number} m - Month (0-indexed)
* @returns {string} The key for the month data (e.g., "2023-09")
*/
function initMonthData(y, m) {
const key = `${y}-${String(m + 1).padStart(2, '0')}`;
const daysInMonth = new Date(y, m + 1, 0).getDate();
let monthDataNeedsSave = false;
// Default structure for a single day's data
const defaultDay = () => ({
fasting: false, // boolean: Was the user fasting?
fastingHours: "", // string: Number of hours fasted (allows flexible input, e.g., "16.5")
notes: [], // array of {icon: string, text: string}: User notes for the day
weight: "", // string: Weight measurement
keton: "", // string: Keton measurement
steps: "" // string: Step count
});
// Ensure the month key exists and is an array
if (!fastingData[key] || !Array.isArray(fastingData[key])) {
fastingData[key] = Array.from({ length: daysInMonth }, defaultDay);
monthDataNeedsSave = true;
} else {
const existingData = fastingData[key];
// Adjust array length if month length is incorrect (e.g., due to leap year changes)
if (existingData.length !== daysInMonth) {
fastingData[key] = Array.from({ length: daysInMonth }, (_, i) => existingData[i] || defaultDay());
monthDataNeedsSave = true;
}
// Iterate through each day to validate its structure and types
fastingData[key].forEach((day, idx) => {
let dayChanged = false;
const current = day || {}; // Handle potentially null/undefined days
const base = defaultDay();
// Ensure all default properties exist
for (const prop in base) {
if (typeof current[prop] === 'undefined') {
current[prop] = base[prop];
dayChanged = true;
}
}
// Validate 'notes' array and its contents
if (!Array.isArray(current.notes)) {
current.notes = [];
dayChanged = true;
} else {
// Ensure each note is an object with icon/text strings, filter out invalid/empty notes
current.notes = current.notes.map(note => {
const correctedNote = { icon: '', text: '' };
let noteChanged = false;
if (typeof note === 'string') { // Handle legacy string notes
correctedNote.text = note; noteChanged = true;
} else if (note && typeof note === 'object') {
correctedNote.icon = typeof note.icon === 'string' ? note.icon : '';
correctedNote.text = typeof note.text === 'string' ? note.text : '';
if (note.icon !== correctedNote.icon || note.text !== correctedNote.text) noteChanged = true;
} else { // Invalid note format
noteChanged = true;
}
if (noteChanged) dayChanged = true;
return correctedNote;
}).filter(n => n.text); // Remove notes without text
}
// Validate numeric/string fields (ensure they are strings)
['weight', 'keton', 'steps', 'fastingHours'].forEach(prop => {
// Ensure the property exists and handle potential null values from older data formats
if (typeof current[prop] === 'undefined' || current[prop] === null) {
current[prop] = ""; dayChanged = true;
} else if (typeof current[prop] !== 'string' && typeof current[prop] !== 'number') {
current[prop] = ""; dayChanged = true;
} else if (typeof current[prop] === 'number') { // Convert numbers to strings
current[prop] = String(current[prop]); dayChanged = true;
}
});
// Validate 'fasting' boolean
if (typeof current.fasting !== 'boolean') {
current.fasting = false; dayChanged = true;
}
// If any corrections were made, update the data and mark for saving
if (dayChanged) {
fastingData[key][idx] = current;
monthDataNeedsSave = true;
}
});
}
// Save if any changes were made during initialization/validation
if (monthDataNeedsSave) saveData();
return key; // Return the month key
}
// --- Rendering ---
/**
* Updates the overall progress bar based on fasting days in the visible months.
*/
function updateStats() {
const months = getVisibleMonths();
let totalDays = 0, totalFasting = 0;
months.forEach(mo => {
const year = mo.y + mo.yOff, month = mo.m;
const key = initMonthData(year, month); // Ensure data is initialized
const arr = fastingData[key];
totalDays += arr.length;
totalFasting += arr.filter(d => d && d.fasting).length; // Count days marked as fasting
});
const pct = totalDays > 0 ? (totalFasting / totalDays) * 100 : 0;
percentageBar.style.width = pct.toFixed(0) + "%"; // Update bar width
}
/**
* Renders the 3-month calendar view.
*/
function renderCalendar() {
updateStats(); // Update progress bar first
calendarContainer.innerHTML = ""; // Clear previous calendar
getVisibleMonths().forEach((mo, viewIdx) => {
const year = mo.y + mo.yOff, month = mo.m;
// Set month title
monthTitles[viewIdx].textContent = new Date(year, month, 1)
.toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
const key = initMonthData(year, month); // Ensure data exists and is valid
const arr = fastingData[key];
// Create container for this month's grid
const calendarDiv = document.createElement("div");
calendarDiv.className = "flex flex-col w-full"; // Container for grid
const grid = document.createElement("div");
grid.className = "grid gap-1"; // Grid layout for days
// Determine grid columns (aim for ~2 rows, min 14 cols)
const cols = Math.max(14, Math.ceil(arr.length / 2));
grid.style.gridTemplateColumns = `repeat(${cols}, minmax(0, 1fr))`;
// Create a box for each day
arr.forEach((dayData, dayIdx) => {
if (!dayData) return; // Skip if data is somehow missing
const date = new Date(year, month, dayIdx + 1);
const dow = date.getDay(); // Day of the week (0=Sun, 6=Sat)
const box = document.createElement("div");
box.className = "day-box cursor-pointer p-1.5 shadow-sm"; // Base styling
// Calculate fasting percentage for background gradient
// Ensure fastingHours is treated as a number for calculation
const hours = Number(dayData.fastingHours || 0) || (dayData.fasting ? 24 : 0);
const pct = Math.min(Math.max((hours / 24) * 100, 0), 100); // Clamp between 0-100
// Apply background gradient and border based on fasting hours
if (pct > 0) {
box.style.background = `linear-gradient(to right, #2563EB ${pct}%, #374151 ${pct}%)`; // Blue gradient fill
box.style.borderColor = "#3B82F6"; // blue-500 border
} else {
box.style.backgroundColor = "#374151"; // gray-700 background
box.style.borderColor = "#4B5563"; // gray-600 border
}
// Add weekend indicator style
if ([0, 6].includes(dow)) box.classList.add("weekend");
// Highlight selected day
if (selected.monthIndex === viewIdx && selected.dayIndex === dayIdx) {
box.style.borderColor = "#FACC15"; // yellow-400
box.style.borderWidth = "2px"; // Make border thicker
box.style.boxShadow = "0 0 0 2px #FACC15"; // Add outer glow
}
// Day number
const num = document.createElement("span");
num.className = "day-number";
num.textContent = dayIdx + 1;
box.appendChild(num);
// Notes preview (icons only)
const notesPrev = document.createElement("div");
notesPrev.className = "notes-preview-container";
(dayData.notes || []).forEach(n => {
const ic = document.createElement("span");
ic.textContent = n.icon || "๐"; // Default icon if none provided
if (!n.icon) ic.classList.add("opacity-60"); // Dim default icon
notesPrev.appendChild(ic);
});
box.appendChild(notesPrev);
// Stats preview (Weight, Keton, Fasting Hours, Steps)
const statsDiv = document.createElement("div");
statsDiv.className = "stats-container";
let addedStat = false;
if (dayData.weight) {
const w = document.createElement("span"); w.textContent = `โ๏ธ ${dayData.weight}`; statsDiv.appendChild(w); addedStat = true;
}
if (dayData.keton) {
const k = document.createElement("span"); k.textContent = `๐งช ${dayData.keton}`; statsDiv.appendChild(k); addedStat = true;
}
if (hours > 0) { // Only show hours if > 0
const fh = document.createElement("span"); fh.textContent = `โฑ๏ธ ${hours}h`; statsDiv.appendChild(fh); addedStat = true;
}
if (dayData.steps) {
const s = document.createElement("span"); s.textContent = `๐ ${dayData.steps}`; s.className = "stat-steps"; statsDiv.appendChild(s); addedStat = true;
}
if (addedStat) box.appendChild(statsDiv);
// Click handler to select the day
box.addEventListener("click", () => {
selected = { monthIndex: viewIdx, dayIndex: dayIdx };
localStorage.setItem("selectedDay", JSON.stringify(selected)); // Persist selection
renderCalendar(); // Re-render to show selection highlight
renderSidebar(); // Update sidebar with selected day's details
});
// Store data attributes for potential future use
box.dataset.monthKey = key;
box.dataset.dayIndex = dayIdx;
grid.appendChild(box); // Add day box to the grid
});
calendarDiv.appendChild(grid); // Add grid to month container
calendarContainer.appendChild(calendarDiv); // Add month container to main calendar area
});
}
/**
* Renders the content of the left sidebar based on the currently selected day.
*/
function renderSidebar() {
sidebarContent.innerHTML = ""; // Clear previous content
globalSaveContainer.innerHTML = ""; // Clear previous save button
const { monthIndex, dayIndex } = selected;
// If no day is selected, show placeholder message
if (monthIndex === null || dayIndex === null) {
sidebarContent.innerHTML = "<p class='text-gray-400 italic'>Select a day from the calendar to see details.</p>";
return;
}
const months = getVisibleMonths();
// Validate selected month index
if (monthIndex < 0 || monthIndex >= months.length) {
selected = { monthIndex: null, dayIndex: null }; // Reset selection
localStorage.removeItem("selectedDay");
sidebarContent.innerHTML = "<p class='text-red-400'>Error: Invalid selected month index.</p>";
renderCalendar(); // Re-render calendar to remove highlight
return;
}
const mo = months[monthIndex];
const year = mo.y + mo.yOff, month = mo.m;
const key = initMonthData(year, month); // Ensure data exists
// Validate selected day index
if (!fastingData[key] || dayIndex < 0 || dayIndex >= fastingData[key].length) {
selected = { monthIndex: null, dayIndex: null }; // Reset selection
localStorage.removeItem("selectedDay");
sidebarContent.innerHTML = "<p class='text-red-400'>Error: Could not load data for the selected day.</p>";
renderCalendar(); // Re-render calendar to remove highlight
return;
}
const dayData = fastingData[key][dayIndex];
const date = new Date(year, month, dayIndex + 1);
// Ensure hoursVal is treated as a number for the slider/display
const hoursVal = Number(dayData.fastingHours || 0) || (dayData.fasting ? 24 : 0);
// Build sidebar HTML content
let html = `
<h3 class="text-lg font-semibold mb-1 text-gray-100">Details for Day ${dayIndex + 1}</h3>
<p class="text-xs text-gray-400 mb-3">${date.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' })}</p>
<div class="mb-4 flex items-center justify-between bg-gray-700 p-2 rounded-md shadow-sm">
<span class="text-sm font-medium text-gray-200">Fasting: <span class="font-bold ${dayData.fasting ? 'text-blue-400' : 'text-gray-400'}">${dayData.fasting ? 'Yes' : 'No'}</span></span>
<button id="toggleFastingBtn" class="px-3 py-1 bg-blue-600 rounded hover:bg-blue-700 text-xs text-white font-semibold shadow">Toggle</button>
</div>
<div class="mb-4">
<label class="block mb-0.5 text-xs font-medium text-gray-400">Fasting Hours:</label>
<input id="fastingHoursInput" type="range" min="0" max="24" step="0.5" value="${hoursVal}" class="w-full h-2 bg-gray-600 rounded-lg appearance-none cursor-pointer accent-blue-500"/>
<span id="fastingHoursValue" class="text-sm text-gray-200">${hoursVal}h</span>
</div>
<div class="mb-4 space-y-2">
<h4 class="text-md font-semibold mb-1 text-gray-200 border-b border-gray-600 pb-1">Daily Stats:</h4>
<div>
<label class="block mb-0.5 text-xs font-medium text-gray-400">Weight:</label>
<input id="weightInput" type="text" inputmode="decimal" value="${dayData.weight || ''}" placeholder="e.g., 70.5 kg" class="w-full bg-gray-600 border border-gray-500 rounded p-1 text-xs text-white shadow-inner focus:ring-1 focus:ring-blue-500 focus:border-blue-500"/>
</div>
<div>
<label class="block mb-0.5 text-xs font-medium text-gray-400">Keton Value:</label>
<input id="ketonInput" type="text" inputmode="decimal" value="${dayData.keton || ''}" placeholder="e.g., 1.2 mmol/L" class="w-full bg-gray-600 border border-gray-500 rounded p-1 text-xs text-white shadow-inner focus:ring-1 focus:ring-blue-500 focus:border-blue-500"/>
</div>
<div>
<label class="block mb-0.5 text-xs font-medium text-gray-400">Step Count:</label>
<input id="stepsInput" type="number" inputmode="numeric" value="${dayData.steps || ''}" placeholder="e.g., 10000" class="w-full bg-gray-600 border border-gray-500 rounded p-1 text-xs text-white shadow-inner focus:ring-1 focus:ring-blue-500 focus:border-blue-500"/>
</div>
</div>
<div class="mb-4">
<h4 class="text-md font-semibold mb-2 text-gray-200 border-b border-gray-600 pb-1">Notes:</h4>
<div id="notesListContainer" class="space-y-1.5 mb-2 max-h-48 overflow-y-auto pr-1"></div>
<button id="toggleNoteFormBtn" class="w-full px-3 py-1.5 bg-green-600 rounded hover:bg-green-700 text-xs text-white font-semibold shadow mb-2">Add Note +</button>
<div id="noteForm" class="space-y-1 bg-gray-700 p-2 rounded-md hidden shadow-sm">
<input id="newNoteIcon" type="text" placeholder="Icon (emoji, optional)" maxlength="2" class="w-full bg-gray-600 border border-gray-500 rounded p-1 text-xs text-white shadow-inner"/>
<textarea id="newNoteText" placeholder="Enter your note..." class="w-full bg-gray-600 border border-gray-500 rounded p-1 text-xs text-white shadow-inner resize-none" rows="2"></textarea>
<div class="flex justify-end space-x-2 mt-1">
<button id="saveNewNoteBtn" class="px-3 py-1 bg-green-600 rounded hover:bg-green-700 text-xs text-white font-semibold">Save Note</button>
<button id="cancelNewNoteBtn" class="px-3 py-1 bg-gray-600 rounded hover:bg-gray-500 text-xs text-gray-300">Cancel</button>
</div>
</div>
</div>`;
sidebarContent.innerHTML = html;
// --- Add Event Listeners for Sidebar Elements ---
// Live update for fasting hours slider display
document.getElementById("fastingHoursInput")?.addEventListener("input", e => {
document.getElementById("fastingHoursValue").textContent = `${e.target.value}h`;
});
// Render existing notes
const notesListContainer = document.getElementById("notesListContainer");
if (!dayData.notes || !dayData.notes.length) {
notesListContainer.innerHTML = `<p class="text-xs text-gray-500 italic px-1">No notes for this day.</p>`;
} else {
dayData.notes.forEach((note, idx) => {
const noteDiv = document.createElement("div");
noteDiv.className = "flex items-start justify-between bg-gray-700 p-1.5 rounded shadow-sm";
// Display note content and edit/delete buttons
noteDiv.innerHTML = `
<div class="flex items-start space-x-2 flex-grow min-w-0 note-display-container">
${note.icon ? `<span class="mt-0.5 flex-shrink-0 w-5 text-center">${note.icon}</span>` : `<span class="opacity-0 w-5 flex-shrink-0"></span>`}
<span class="note-text text-xs text-gray-200 flex-1 break-words pt-0.5" data-idx="${idx}">${note.text}</span>
</div>
<div class="flex items-center space-x-1.5 text-xs flex-shrink-0 ml-2 note-actions">
<button class="text-blue-400 hover:text-blue-300 edit-note p-0.5" data-idx="${idx}" title="Edit Note">✎</button> <button class="text-red-400 hover:text-red-300 delete-note p-0.5" data-idx="${idx}" title="Delete Note">×</button> </div>
<div class="flex-1 space-y-1 note-edit-container hidden">
<div class="flex items-center space-x-1">
<input type="text" value="${note.icon}" maxlength="2" class="note-edit-icon w-10 bg-gray-500 border border-gray-400 rounded p-0.5 text-xs text-white" data-idx="${idx}">
<input type="text" value="${note.text}" class="note-edit-input flex-1 bg-gray-500 border border-gray-400 rounded p-0.5 text-xs text-white" data-idx="${idx}">
</div>
<div class="flex justify-end space-x-2 mt-1">
<button class="save-edit-btn text-green-400 hover:text-green-300 text-xs font-semibold" data-idx="${idx}">Save</button>
<button class="cancel-edit-btn text-gray-400 hover:text-gray-300 text-xs" data-idx="${idx}">Cancel</button>
</div>
</div>
`;
notesListContainer.appendChild(noteDiv);
});
}
// Toggle fasting button listener
document.getElementById("toggleFastingBtn")?.addEventListener("click", () => {
const currentDayData = fastingData[key][dayIndex];
// Toggle the fasting state
currentDayData.fasting = !currentDayData.fasting;
// If now fasting, set hours to 24 unless already set; if not fasting, clear hours (optional)
if (currentDayData.fasting) {
// Only set to 24 if hours are currently 0 or empty
if (!currentDayData.fastingHours || Number(currentDayData.fastingHours) === 0) {
currentDayData.fastingHours = "24";
}
} else {
currentDayData.fastingHours = "0"; // Reset hours when toggling off
}
// Save data and update UI
saveData(); updateStats(); renderCalendar(); renderSidebar();
});
// Toggle Note Form button listener
document.getElementById("toggleNoteFormBtn")?.addEventListener("click", () => {
const noteForm = document.getElementById("noteForm");
const isHidden = noteForm.classList.toggle("hidden");
const toggleBtn = document.getElementById("toggleNoteFormBtn");
toggleBtn.textContent = isHidden ? "Add Note +" : "Cancel";
toggleBtn.classList.toggle("bg-green-600", isHidden);
toggleBtn.classList.toggle("hover:bg-green-700", isHidden);
toggleBtn.classList.toggle("bg-gray-600", !isHidden);
toggleBtn.classList.toggle("hover:bg-gray-500", !isHidden);
if (!isHidden) { // If form is shown, clear inputs and focus
document.getElementById("newNoteIcon").value = "";
document.getElementById("newNoteText").value = "";
document.getElementById("newNoteText").focus();
}
});
// Save New Note button listener (inside the form)
document.getElementById("saveNewNoteBtn")?.addEventListener("click", () => {
const textInput = document.getElementById("newNoteText");
const iconInput = document.getElementById("newNoteIcon");
const text = textInput.value.trim();
const icon = iconInput.value.trim();
if (text) {
fastingData[key][dayIndex].notes.push({ icon: icon, text: text });
saveData();
renderCalendar(); // Update calendar preview
renderSidebar(); // Re-render sidebar to show new note and hide form
} else {
alert("Note text cannot be empty.");
textInput.focus();
}
});
// Cancel New Note button listener
document.getElementById("cancelNewNoteBtn")?.addEventListener("click", () => {
const noteForm = document.getElementById("noteForm");
noteForm.classList.add("hidden");
const toggleBtn = document.getElementById("toggleNoteFormBtn");
toggleBtn.textContent = "Add Note +";
toggleBtn.className = "w-full px-3 py-1.5 bg-green-600 rounded hover:bg-green-700 text-xs text-white font-semibold shadow mb-2";
});
// Event delegation for notes list (Edit/Delete/Save Edit/Cancel Edit)
notesListContainer?.addEventListener("click", e => {
const target = e.target;
const currentDayData = fastingData[key][dayIndex];
const noteDiv = target.closest(".flex.items-start.justify-between"); // Get the main container for the note item
if (!noteDiv) return; // Click wasn't on a relevant element
const displayContainer = noteDiv.querySelector(".note-display-container");
const editContainer = noteDiv.querySelector(".note-edit-container");
const actionsContainer = noteDiv.querySelector(".note-actions");
// Ensure dataset.idx exists before parsing
const indexStr = target.dataset.idx;
if (indexStr === undefined) return; // Clicked element doesn't have index
const index = parseInt(indexStr);
// Handle Delete
if (target.classList.contains("delete-note")) {
if (confirm("Delete this note?")) {
currentDayData.notes.splice(index, 1);
saveData(); renderCalendar(); renderSidebar();
}
}
// Handle Edit Button Click
else if (target.classList.contains("edit-note")) {
displayContainer.classList.add("hidden");
actionsContainer.classList.add("hidden");
editContainer.classList.remove("hidden");
editContainer.querySelector(".note-edit-input").focus(); // Focus the text input
}
// Handle Save Edit Button Click
else if (target.classList.contains("save-edit-btn")) {
const textInput = editContainer.querySelector(".note-edit-input");
const iconInput = editContainer.querySelector(".note-edit-icon");
const newText = textInput.value.trim();
const newIcon = iconInput.value.trim();
if (newText) {
currentDayData.notes[index] = { icon: newIcon, text: newText };
saveData(); renderCalendar(); renderSidebar(); // Re-render to show updated note
} else {
alert("Note text cannot be empty.");
textInput.focus();
}
}
// Handle Cancel Edit Button Click
else if (target.classList.contains("cancel-edit-btn")) {
editContainer.classList.add("hidden");
displayContainer.classList.remove("hidden");
actionsContainer.classList.remove("hidden");
// No data save needed, just revert UI
}
});
// Global Save Button (saves all fields for the selected day)
const saveBtn = document.createElement("button");
saveBtn.className = "w-full px-3 py-2 bg-gradient-to-r from-green-500 to-emerald-600 rounded-md hover:from-green-600 hover:to-emerald-700 text-sm text-white font-bold shadow-md transition duration-150 ease-in-out";
saveBtn.textContent = "Save All Changes for This Day";
saveBtn.addEventListener("click", () => {
const weightVal = document.getElementById("weightInput")?.value || "";
const ketonVal = document.getElementById("ketonInput")?.value || "";
const stepsVal = document.getElementById("stepsInput")?.value || "";
const hoursVal = document.getElementById("fastingHoursInput")?.value || "0";
const currentDayData = fastingData[key][dayIndex];
// Update data object
currentDayData.weight = weightVal;
currentDayData.keton = ketonVal;
currentDayData.steps = stepsVal;
// Ensure fastingHours is saved as a string
currentDayData.fastingHours = String(hoursVal);
// Also update fasting boolean based on hours (if hours > 0, set fasting to true)
currentDayData.fasting = Number(hoursVal) > 0;
// Check if there's a new note pending in the form (handle case where user types then hits global save)
const newNoteTextElem = document.getElementById("newNoteText");
const newNoteIconElem = document.getElementById("newNoteIcon");
const noteForm = document.getElementById("noteForm");
if (newNoteTextElem && newNoteIconElem && noteForm && !noteForm.classList.contains("hidden") && newNoteTextElem.value.trim()) {
currentDayData.notes.push({ icon: newNoteIconElem.value.trim(), text: newNoteTextElem.value.trim() });
// Clear and hide the form after saving
newNoteTextElem.value = "";
newNoteIconElem.value = "";
noteForm.classList.add("hidden");
const toggleBtn = document.getElementById("toggleNoteFormBtn");
if (toggleBtn) {
toggleBtn.textContent = "Add Note +";
toggleBtn.className = "w-full px-3 py-1.5 bg-green-600 rounded hover:bg-green-700 text-xs text-white font-semibold shadow mb-2";
}
}
saveData(); // Save all changes
updateStats(); // Update progress bar
renderCalendar(); // Update calendar view
localStorage.setItem("selectedDay", JSON.stringify(selected)); // Re-save selection just in case
renderSidebar(); // Re-render sidebar to reflect saved state
// Show save confirmation feedback
const conf = document.createElement("span");
conf.textContent = " Saved!";
conf.className = "text-green-300 text-xs ml-2 fade-out";
saveBtn.appendChild(conf);
setTimeout(() => conf.remove(), 1500); // Remove confirmation after 1.5s
});
globalSaveContainer.appendChild(saveBtn);
}
/**
* Renders the chat interface for the current chat session.
*/
function renderChat() {
if (!chatContainer) return; // Ensure chat elements exist
// Validate currentChatIndex and ensure chatSessions has at least one session
if (currentChatIndex < 0 || currentChatIndex >= chatSessions.length) {
currentChatIndex = Math.max(0, chatSessions.length - 1);
if (!chatSessions.length) {
chatSessions.push({ id: Date.now(), messages: [] });
currentChatIndex = 0;
saveChats(); // Save the newly created session
}
}
chatContainer.innerHTML = ""; // Clear previous messages
const session = chatSessions[currentChatIndex];
// Display messages or placeholder if empty
if (!session || !session.messages.length) {
chatContainer.innerHTML = `<p class="text-center text-gray-500 text-sm italic mt-4">Chat history is empty. Ask the AI something!</p>`;
} else {
session.messages.forEach(m => {
const div = document.createElement("div");
div.classList.add("mb-2", "p-2.5", "rounded-lg", "max-w-[85%]", "shadow", "text-sm");
div.style.overflowWrap = 'break-word'; // Ensure long words wrap
if (m.sender === "ai") {
div.classList.add("bg-gray-600", "self-start", "text-gray-100");
try {
// Use marked library to render Markdown from AI
marked.setOptions({ breaks: true, gfm: true, sanitize: false }); // Configure marked
div.innerHTML = marked.parse(m.text || ''); // Render markdown
} catch (e) {
console.error("Markdown parsing error:", e);
div.textContent = m.text || ''; // Fallback to plain text
}
} else { // User message
div.classList.add("bg-blue-600", "self-end", "text-white");
div.textContent = m.text || ''; // Display plain text
}
chatContainer.appendChild(div);
});
}
// Scroll to the bottom of the chat container
chatContainer.scrollTop = chatContainer.scrollHeight;
// Update chat pagination indicator and button states
chatSessionIndicator.textContent = `Chat ${currentChatIndex + 1} / ${chatSessions.length}`;
prevChatBtn.disabled = currentChatIndex === 0;
nextChatBtn.disabled = currentChatIndex === chatSessions.length - 1;
deleteChatButton.disabled = chatSessions.length <= 1; // Can't delete the last chat
}
// --- Helpers ---
/**
* Calculates the year and month for the three currently visible calendar views.
* @returns {Array<{y: number, m: number, yOff: number}>} Array of month objects
*/
function getVisibleMonths() {
const m1 = currentMonth; // Current month
const y1Off = 0; // Year offset for current month
const m2 = (currentMonth + 1) % 12; // Next month
const y2Off = currentMonth + 1 > 11 ? 1 : 0; // Year offset for next month
const m3 = (currentMonth + 2) % 12; // Month after next
const y3Off = currentMonth + 2 > 11 ? 1 : 0; // Year offset for month after next
return [
{ y: currentYear, m: m1, yOff: y1Off },
{ y: currentYear, m: m2, yOff: y2Off },
{ y: currentYear, m: m3, yOff: y3Off }
];
}
/**
* Updates the `lastKnownStorageState` object with current localStorage values.
* Used for multi-tab synchronization check.
*/
function updateLastKnownStorageState() {
lastKnownStorageState.fastingData = localStorage.getItem("fastingData") || "{}";
lastKnownStorageState.chatSessions = localStorage.getItem("chatSessions") || "[]";
lastKnownStorageState.settings = JSON.stringify({
openaiApiKey: localStorage.getItem("openaiApiKey") || "",
systemPrompt: localStorage.getItem("systemPrompt") || ""
});
}
/**
* Checks if localStorage data has changed (e.g., in another tab) and updates the UI accordingly.
*/
function checkStorageChanges() {
const currentFastingData = localStorage.getItem("fastingData") || "{}";
const currentChatSessions = localStorage.getItem("chatSessions") || "[]";
const currentSettings = JSON.stringify({
openaiApiKey: localStorage.getItem("openaiApiKey") || "",
systemPrompt: localStorage.getItem("systemPrompt") || ""
});
let needsRerender = false;
let needsChatRerender = false;
let needsSettingsUpdate = false;
// Check Fasting Data
if (currentFastingData !== lastKnownStorageState.fastingData) {
try {
const newData = JSON.parse(currentFastingData);
if (typeof newData === 'object' && !Array.isArray(newData) && newData !== null) {
fastingData = newData;
lastKnownStorageState.fastingData = currentFastingData;
// Re-initialize visible months to ensure data integrity after external change
getVisibleMonths().forEach(mo => initMonthData(mo.y + mo.yOff, mo.m));
needsRerender = true;
console.log("Fasting data updated from another tab.");
}
} catch (e) { console.error("Error parsing external fasting data update:", e); }
}
// Check Chat Sessions
if (currentChatSessions !== lastKnownStorageState.chatSessions) {
try {
const newSessions = JSON.parse(currentChatSessions);
if (Array.isArray(newSessions)) {
chatSessions = newSessions;
lastKnownStorageState.chatSessions = currentChatSessions;
// Adjust currentChatIndex if it's now out of bounds
currentChatIndex = Math.max(0, Math.min(currentChatIndex, chatSessions.length - 1));
if (!chatSessions.length) { // Ensure at least one session exists
chatSessions.push({ id: Date.now(), messages: [] });
currentChatIndex = 0;
saveChats(); // Save the new empty session immediately
}
needsChatRerender = true;
console.log("Chat sessions updated from another tab.");
}
} catch (e) { console.error("Error parsing external chat session update:", e); }
}
// Check Settings
if (currentSettings !== lastKnownStorageState.settings) {
try {
const newSettingsObj = JSON.parse(currentSettings);
openaiApiKey = newSettingsObj.openaiApiKey || "";
systemPrompt = newSettingsObj.systemPrompt || ""; // Update local systemPrompt variable
lastKnownStorageState.settings = currentSettings;
needsSettingsUpdate = true;
console.log("Settings updated from another tab.");
} catch (e) { console.error("Error parsing external settings update:", e); }
}
// Apply updates to the UI
if (needsRerender) { renderCalendar(); renderSidebar(); }
if (needsChatRerender) renderChat();
if (needsSettingsUpdate) {
// Update settings fields only if the settings panel is currently visible
if (apiKeyInput && !settingsArea.classList.contains('hidden')) apiKeyInput.value = openaiApiKey;
// Update system prompt input field regardless of visibility, as it affects AI calls
if (systemPromptInput) systemPromptInput.value = systemPrompt;
}
}
// --- Initialization & Event Listeners ---
/**
* Initial setup when the DOM is fully loaded.
*/
document.addEventListener('DOMContentLoaded', () => {
loadData(); // Load all data from localStorage
// Ensure data for initially visible months is initialized/validated
getVisibleMonths().forEach(mo => initMonthData(mo.y + mo.yOff, mo.m));
// Restore previously selected day, if valid
try {
const savedSelection = JSON.parse(localStorage.getItem("selectedDay"));
if (savedSelection && typeof savedSelection.monthIndex === 'number' && typeof savedSelection.dayIndex === 'number') {
// Validate the saved selection against current view and data
if (savedSelection.monthIndex >= 0 && savedSelection.monthIndex < 3) {
const mmo = getVisibleMonths()[savedSelection.monthIndex];
const key = `${mmo.y + mmo.yOff}-${String(mmo.m + 1).padStart(2, '0')}`;
if (fastingData[key] && savedSelection.dayIndex >= 0 && savedSelection.dayIndex < fastingData[key].length) {
selected = savedSelection; // Restore valid selection
} else {
localStorage.removeItem("selectedDay"); // Remove invalid selection
}
} else {
localStorage.removeItem("selectedDay"); // Remove invalid selection
}
}
} catch (e) {
console.error("Error parsing selectedDay from localStorage:", e);
localStorage.removeItem("selectedDay");
}
renderCalendar(); // Initial render of the calendar
renderSidebar(); // Initial render of the sidebar (shows selection or placeholder)
renderChat(); // Initial render of the chat interface
// Start interval timer to check for changes in other tabs
if (dataCheckInterval) clearInterval(dataCheckInterval); // Clear existing interval if any
dataCheckInterval = setInterval(checkStorageChanges, 1500); // Check every 1.5 seconds
});
// --- Event Listeners for Controls ---
// Previous Month Button
prevMonthBtn?.addEventListener("click", () => {
currentMonth--;
if (currentMonth < 0) { currentMonth = 11; currentYear--; }
selected = { monthIndex: null, dayIndex: null }; // Clear selection when changing view
localStorage.removeItem("selectedDay");
renderCalendar(); renderSidebar();
});
// Next Month Button
nextMonthBtn?.addEventListener("click", () => {
currentMonth++;
if (currentMonth > 11) { currentMonth = 0; currentYear++; }
selected = { monthIndex: null, dayIndex: null }; // Clear selection
localStorage.removeItem("selectedDay");
renderCalendar(); renderSidebar();
});
// Reset All Data Link
resetLink?.addEventListener("click", e => {
e.preventDefault();
if (confirm("Are you sure? This will erase ALL fasting data, chat history, and settings.")) {
// Clear data variables
fastingData = {};
chatSessions = [{ id: Date.now(), messages: [] }]; // Reset to one empty chat
currentChatIndex = 0;
selected = { monthIndex: null, dayIndex: null };
openaiApiKey = "";
// Reset system prompt to default
const dynamicDate = new Date().toLocaleDateString('en-US',{weekday:'long',month:'long',day:'numeric',year:'numeric',hour:'numeric',minute:'numeric',hour12:false});
systemPrompt = `You are a helpful assistant analyzing fasting data. Be concise and encouraging. Today is ${dynamicDate}. The 'fastingHours' field represents the duration of the fast in hours for that specific day (use this for calculations). Summaries for the relevant period are provided first, followed by daily details.`;
// Clear localStorage
localStorage.removeItem("fastingData");
localStorage.removeItem("chatSessions");
localStorage.removeItem("selectedDay");
localStorage.removeItem("openaiApiKey");
localStorage.removeItem("systemPrompt");
// Re-initialize current view and update UI
getVisibleMonths().forEach(mo => initMonthData(mo.y + mo.yOff, mo.m)); // Initialize empty months
saveChats(); // Save the single empty chat session
renderCalendar(); renderSidebar(); renderChat();
updateLastKnownStorageState(); // Update state for multi-tab sync
if(settingsArea && !settingsArea.classList.contains('hidden')) { // Update settings view if open
apiKeyInput.value = "";
systemPromptInput.value = systemPrompt; // Show default prompt
} else if (systemPromptInput) {
systemPromptInput.value = systemPrompt; // Ensure input reflects reset even if hidden
}
alert("Tracker data, chats, and settings have been reset.");
}
});
// Export Data Link
exportLink?.addEventListener("click", e => {
e.preventDefault();
try {
// Export only fastingData
exportTextarea.value = JSON.stringify(fastingData, null, 2); // Pretty print JSON
exportArea.classList.remove("hidden");
// Hide other panels
importArea.classList.add("hidden");
settingsArea.classList.add("hidden");
exportTextarea.select(); // Select text for easy copying
} catch (err) {
console.error("Error generating export data:", err);
alert("Error generating export data: " + err.message);
}
});
// Import Data Link
importLink?.addEventListener("click", e => {
e.preventDefault();
importArea.classList.remove("hidden");
// Hide other panels
exportArea.classList.add("hidden");
settingsArea.classList.add("hidden");
importTextarea.focus();
});
// Settings Link
settingsLink?.addEventListener("click", e => {
e.preventDefault();
// Populate fields with current settings
apiKeyInput.value = openaiApiKey;
systemPromptInput.value = systemPrompt; // Load current prompt into textarea
settingsArea.classList.remove("hidden");
// Hide other panels
exportArea.classList.add("hidden");
importArea.classList.add("hidden");
});
// Import Data Button
importButton?.addEventListener("click", () => {
const jsonData = importTextarea.value;
if (!jsonData) { alert("No data pasted to import."); return; }
try {
const importedObj = JSON.parse(jsonData);
// Basic validation: must be a non-null, non-array object
if (importedObj && typeof importedObj === 'object' && !Array.isArray(importedObj)) {
if (confirm("Importing will OVERWRITE your current fasting data. Chat history and settings will NOT be affected. Continue?")) {
fastingData = importedObj; // Overwrite existing data
// Re-validate / initialize all months present in the imported data
Object.keys(fastingData).forEach(key => {
const parts = key.split('-');
if (parts.length === 2) {
const year = parseInt(parts[0]);
const month = parseInt(parts[1]) - 1; // Month is 0-indexed
if (!isNaN(year) && !isNaN(month) && month >= 0 && month <= 11) {
initMonthData(year, month); // Validate/initialize structure
} else {
console.warn(`Invalid key format found during import: ${key}. Skipping.`);
delete fastingData[key]; // Remove invalid keys
}
} else {
console.warn(`Invalid key format found during import: ${key}. Skipping.`);
delete fastingData[key]; // Remove invalid keys
}
});
// Also initialize currently visible months in case they weren't in the import
getVisibleMonths().forEach(mo => initMonthData(mo.y + mo.yOff, mo.m));
saveData(); // Save the imported data
selected = { monthIndex: null, dayIndex: null }; // Reset selection
localStorage.removeItem("selectedDay");
renderCalendar(); renderSidebar(); // Update UI
updateLastKnownStorageState(); // Update sync state
alert("Import successful! Fasting data has been replaced.");
importArea.classList.add("hidden"); // Hide import panel
importTextarea.value = ""; // Clear textarea
}
} else {
alert("Invalid import format. Data must be a JSON object (e.g., {\"YYYY-MM\": [...]}).");
}
} catch (err) {
console.error("Import error:", err);
alert("JSON parse error during import:\n" + err.message);
}
});
// Save API Key Button
saveApiKeyBtn?.addEventListener("click", () => {
openaiApiKey = apiKeyInput.value.trim();
saveSettings();
alert("API Key saved!");
settingsArea.classList.add("hidden"); // Hide settings panel
});
// Save System Prompt Button
saveSystemPromptBtn?.addEventListener("click", () => {
// Use default if input is empty, otherwise use trimmed input
const dynamicDate = new Date().toLocaleDateString('en-US',{weekday:'long',month:'long',day:'numeric',year:'numeric',hour:'numeric',minute:'numeric',hour12:false});
const defaultPrompt = `You are a helpful assistant analyzing fasting data. Be concise and encouraging. Today is ${dynamicDate}. The 'fastingHours' field represents the duration of the fast in hours for that specific day (use this for calculations). Summaries for the relevant period are provided first, followed by daily details.`;
systemPrompt = systemPromptInput.value.trim() || defaultPrompt;
systemPromptInput.value = systemPrompt; // Update input field in case it was defaulted
saveSettings();
alert("System Prompt saved!");
settingsArea.classList.add("hidden"); // Hide settings panel
});
// Chat Input Listener (Send on Enter, newline on Shift+Enter)
chatInput?.addEventListener("keydown", e => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault(); // Prevent default newline insertion
const message = chatInput.value.trim();
if (message) {
chatInput.value = ""; // Clear input field
sendMessage(message); // Send message to AI
}
}
});
// New Chat Button
newChatButton?.addEventListener("click", () => {
chatSessions.push({ id: Date.now(), messages: [] }); // Add new empty session
currentChatIndex = chatSessions.length - 1; // Switch to the new session
saveChats();
renderChat(); // Update chat UI
});
// Delete Chat Button
deleteChatButton?.addEventListener("click", () => {
if (chatSessions.length <= 1) {
alert("Cannot delete the last chat session.");
return;
}
if (confirm(`Are you sure you want to delete Chat ${currentChatIndex + 1}? This cannot be undone.`)) {
chatSessions.splice(currentChatIndex, 1); // Remove current session
// Adjust index if the last session was deleted
if (currentChatIndex >= chatSessions.length) {
currentChatIndex = chatSessions.length - 1;
}
saveChats();
renderChat(); // Update chat UI
updateLastKnownStorageState(); // Update sync state
}
});
// Previous Chat Button
prevChatBtn?.addEventListener("click", () => {
if (currentChatIndex > 0) {
currentChatIndex--;
renderChat();
}
});
// Next Chat Button
nextChatBtn?.addEventListener("click", () => {
if (currentChatIndex < chatSessions.length - 1) {
currentChatIndex++;
renderChat();
}
});
/**
* Sends a user message to the OpenAI API and displays the response.
* @param {string} message - The user's message text.
*/
async function sendMessage(message) {
if (!openaiApiKey) {
alert("Please set your OpenAI API Key in the Settings panel first.");
return;
}
// Add user message to the current chat session
chatSessions[currentChatIndex].messages.push({ sender: "user", text: message });
renderChat(); // Update UI immediately
saveChats(); // Save the updated chat history
// Display thinking indicator
const thinkingDiv = document.createElement("div");
thinkingDiv.className = "thinking-indicator text-sm text-gray-300 italic";
thinkingDiv.textContent = "AI is thinking...";
chatContainer.appendChild(thinkingDiv);
chatContainer.scrollTop = chatContainer.scrollHeight; // Scroll to show indicator
// --- Prepare Context for AI ---
// 1. Get current date/time for the system prompt
const dynamicDate = new Date().toLocaleDateString('en-US',{weekday:'long',month:'long',day:'numeric',year:'numeric',hour:'numeric',minute:'numeric',hour12:false});
// Retrieve the potentially user-customized prompt, ensure date is current, and add instructions
const basePromptInstruction = `The 'fastingHours' field represents the duration of the fast in hours for that specific day (use this for calculations). Summaries for the relevant period are provided first, followed by daily details.`;
const currentSystemPrompt = localStorage.getItem("systemPrompt") || `You are a helpful assistant analyzing fasting data. Be concise and encouraging. Today is ${dynamicDate}.`;
const finalSystemPrompt = currentSystemPrompt
.replace(/\bToday is .*?\./, `Today is ${dynamicDate}.`) // Update date
+ `\n\n${basePromptInstruction}`; // Add instructions
// 2. Prepare relevant fasting data (last ~6 months, explicit dates) AND calculate summaries
let dataContext = "";
let summarySection = "--- DATA SUMMARY (Last ~6 Months) ---\n";
let totalFastingHours = 0;
let totalSteps = 0;
let daysWithDataCount = 0;
let fastingDaysCount = 0;
try {
const sixMonthsAgo = new Date(); sixMonthsAgo.setMonth(sixMonthsAgo.getMonth()-6);
const relevantDataForAI = {}; // Use an object keyed by YYYY-MM-DD
// Iterate through saved data, sorted by month key
Object.keys(fastingData).sort().forEach(key => {
const [yy, mm] = key.split('-').map(Number);
const keyDate = new Date(yy, mm - 1, 1); // Date object for the start of the month
// Check if the month is within the last 6 months
if (keyDate >= sixMonthsAgo) {
const monthData = fastingData[key];
if (Array.isArray(monthData)) {
monthData.forEach((dayData, dayIndex) => {
// Only include days that actually have *some* data entered
const hasData = dayData && (
dayData.fasting ||
(dayData.fastingHours && Number(dayData.fastingHours || 0) > 0) ||
(dayData.notes && dayData.notes.length > 0) ||
dayData.weight ||
dayData.keton ||
dayData.steps
);
if (hasData) {
daysWithDataCount++;
const day = dayIndex + 1;
const fullDateStr = `${yy}-${String(mm).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
relevantDataForAI[fullDateStr] = dayData; // Map date string to day's data
// Accumulate summaries
const hours = Number(dayData.fastingHours || 0);
totalFastingHours += hours;
if (dayData.fasting || hours > 0) {
fastingDaysCount++;
}
totalSteps += Number(dayData.steps || 0);
}
});
}
}
});
// Add calculated summaries to the summary section
summarySection += `Total Days with Data: ${daysWithDataCount}\n`;
summarySection += `Total Fasting Days: ${fastingDaysCount}\n`;
summarySection += `Total Fasting Hours: ${totalFastingHours.toFixed(1)}\n`;
summarySection += `Total Steps: ${totalSteps}\n`;
summarySection += `--- END SUMMARY ---`;
// Combine summary and detailed data
dataContext = `${summarySection}\n\n--- RELEVANT FASTING DATA DETAILS (Last ~6 Months, entries with data only) ---\n${JSON.stringify(relevantDataForAI, null, 2)}\n--- END FASTING DATA DETAILS ---`;
} catch(err) {
console.error("Error preparing data context for AI:", err);
dataContext = "--- Error preparing fasting data for context ---";
}
// 3. Construct message history for the API call
const systemMsg = { role: "system", content: finalSystemPrompt + "\n\n" + dataContext };
// Include recent messages (max ~10 previous + current user message)
const recentMessages = chatSessions[currentChatIndex].messages.slice(-11, -1); // Get up to 10 previous messages
const conversationHistory = [
systemMsg,
...recentMessages.map(m => ({ role: m.sender === "user" ? "user" : "assistant", content: m.text })),
{ role: "user", content: message } // Add the latest user message
];
// --- Call OpenAI API ---
try {
const response = await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer " + openaiApiKey // Use the stored API key
},
body: JSON.stringify({
model: "gpt-4o-mini", // Specify the model
messages: conversationHistory,
temperature: 0.7 // Adjust creativity/randomness
})
});
thinkingDiv.remove(); // Remove thinking indicator
if (!response.ok) {
// Handle API errors gracefully
const errorData = await response.json().catch(() => ({ error: { message: "Failed to parse error response from API." } }));
throw new Error(`API Error (${response.status}): ${errorData.error?.message || response.statusText}`);
}
const data = await response.json();
const aiText = data.choices?.[0]?.message?.content?.trim();
// Add AI response to chat history
chatSessions[currentChatIndex].messages.push({ sender: "ai", text: aiText || "Sorry, I received an empty response from the AI." });
renderChat(); // Update UI with AI response
saveChats(); // Save the updated chat history
} catch (err) {
console.error("Error calling OpenAI API:", err);
thinkingDiv.remove(); // Remove thinking indicator on error
// Add error message to chat history
chatSessions[currentChatIndex].messages.push({ sender: "ai", text: `Sorry, an error occurred while contacting the AI: ${err.message}` });
renderChat(); // Update UI with error message
saveChats(); // Save chat history including the error
}
}
</script>
</body>
</html>
Also see: Tab Triggers