<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Mental Health Tracker (Download)</title>
<style>
html,body {
height: 100%;
margin: 0;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial;
background: linear-gradient(180deg, #1a0b1f 0%, #2a0d2e 100%);
color: #fdf2f8;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
overflow-x: hidden;
}
h1, h2 { color: #fdf2f8; }
h1 { font-size: 22px; margin-bottom: 10px; letter-spacing: -0.2px; }
p { color: #c084fc; font-size: 15px; line-height: 1.5; }
code { color: #ec4899; font-weight: 600; }
.controls {
display: flex;
gap: 12px;
margin-top: 18px;
margin-bottom: 16px;
flex-wrap: wrap;
}
button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px 16px;
border-radius: 10px;
border: none;
cursor: pointer;
font-weight: 600;
background: linear-gradient(90deg, #ec4899, #8b5cf6);
color: white;
box-shadow: 0 6px 14px rgba(236, 72, 153, 0.25);
transition: transform .08s ease, box-shadow .12s ease;
white-space: nowrap;
}
button:hover {
box-shadow: 0 8px 24px rgba(236, 72, 153, 0.35);
transform: translateY(-1px);
}
button:active { transform: translateY(1px); }
.note {
color: #c084fc;
font-size: 14px;
background: rgba(139, 92, 246, 0.08);
padding: 12px;
border-radius: 12px;
line-height: 1.5;
}
details {
margin-top: 18px;
background: rgba(255, 0, 128, 0.1);
border-radius: 12px;
padding: 10px 14px;
box-shadow: 0 8px 24px rgba(236, 72, 153, 0.2);
}
summary {
cursor: pointer;
color: #ec4899;
font-weight: 600;
}
textarea {
width: 100%;
height: 360px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, "Roboto Mono", "Courier New", monospace;
margin-top: 10px;
padding: 10px;
border-radius: 10px;
border: 1px solid rgba(139, 92, 246, 0.3);
background: rgba(139, 92, 246, 0.1);
color: #fdf2f8;
resize: vertical;
box-sizing: border-box;
}
#logConsole {
background: #2a0d2e;
color: #fdf2f8;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, "Roboto Mono", "Courier New", monospace;
padding: 12px;
border-radius: 12px;
height: 180px;
overflow-y: auto;
margin-top: 16px;
white-space: pre-wrap;
box-shadow: 0 6px 20px rgba(236, 72, 153, 0.2);
}
#logConsole .error { color: #ef4444; }
#logConsole .warn { color: #f7d154; }
#logConsole .info { color: #8b5cf6; }
details[open] {
background: rgba(139, 92, 246, 0.15);
}
@media (max-width: 600px) {
button { flex: 1 1 100%; }
textarea { height: 240px; }
}
</style>
</head>
<body>
<h1>Merge the website files into single HTML</h1>
<div class="controls">
<button id="mergeDownloadBtn">Merge & Download mental-health-tracking.html</button>
<button id="mergePreviewBtn">Merge & Open Preview</button>
<button id="showMergedBtn">Show merged HTML (open panel)</button>
</div>
<div class="note">
Important: This must be served from the <b>same origin</b> as <code>/website/</code>. Browser security (CORS / same-origin) prevents fetching files from another domain unless CORS is permitted.
</div>
<details style="margin-top:14px">
<summary>Merged HTML output (editable)</summary>
<textarea id="mergedOutput"></textarea>
</details>
<h2 style="margin-top:28px;">Console Output</h2>
<div id="logConsole"></div>
<script>
// ---------- Logging Utility ----------
const logConsole = document.getElementById('logConsole');
function logMessage(level, msg) {
const time = new Date().toLocaleTimeString();
const div = document.createElement('div');
div.className = level;
div.textContent = `[${time}] ${level.toUpperCase()}: ${msg}`;
logConsole.appendChild(div);
logConsole.scrollTop = logConsole.scrollHeight;
console[level === 'error' ? 'error' : (level === 'warn' ? 'warn' : 'log')](msg);
}
const logInfo = msg => logMessage('info', msg);
const logWarn = msg => logMessage('warn', msg);
const logError = msg => logMessage('error', msg);
// ---------- Helpers ----------
function isAbsoluteOrProtocol(val) {
if (!val) return false;
return /^(https?:|\/\/|data:|mailto:|tel:|#|\/)/i.test(val);
}
function rewriteHtmlRelativeUrls(doc) {
const attrs = ['src','href','poster','data-src'];
const all = doc.querySelectorAll('*');
for (const el of all) {
for (const a of attrs) {
if (!el.hasAttribute(a)) continue;
const v = el.getAttribute(a);
if (!v) continue;
if (isAbsoluteOrProtocol(v)) continue;
let newVal = v.startsWith('./') ? v.slice(2) : v;
newVal = 'website/' + newVal;
el.setAttribute(a, newVal);
}
}
}
function rewriteCssUrls(cssText) {
return cssText.replace(/url\(\s*(['"]?)(?!data:|https?:|\/\/|\/)([^'")]+)\1\s*\)/gi,
(m, q, path) => {
if (path.startsWith('./')) path = path.slice(2);
return `url(${q}website/${path}${q})`;
});
}
async function fetchText(url) {
try {
logInfo(`Fetching: ${url}`);
const res = await fetch(url, {cache: 'no-store'});
if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`);
const text = await res.text();
logInfo(`Fetched successfully: ${url}`);
return text;
} catch (err) {
logError(`Failed to fetch ${url}: ${err.message}`);
throw err;
}
}
// ---------- Core merge ----------
async function buildMergedHtml() {
logInfo('Starting merge...');
const base = './website/';
try {
const [indexHtmlText, cssTextMain, jsTextMain, guideHtmlText] = await Promise.all([
fetchText(base + 'index.html'),
fetchText(base + 'styles.css'),
fetchText(base + 'scripts.js'),
fetchText(base + 'guide.html')
]);
const parser = new DOMParser();
const doc = parser.parseFromString(indexHtmlText, 'text/html');
// remove link tags replaced by inline CSS
doc.querySelectorAll('link[rel="stylesheet"]').forEach(l => l.remove());
const gdoc = parser.parseFromString(guideHtmlText, 'text/html');
let cssText = cssTextMain;
// inline CSS from guide page
for (const l of gdoc.querySelectorAll('link[rel="stylesheet"]')) {
const href = l.getAttribute('href') || '';
if (!isAbsoluteOrProtocol(href)) {
try {
const fetchedCss = await fetchText(base + href.replace(/^\.\//, ''));
cssText += `/* inlined from guide: ${href} */` + rewriteCssUrls(fetchedCss);
} catch (e) { logWarn(`Failed to inline ${href}`); }
}
l.remove();
}
// inline JS from guide page
let extraJsFromGuide = '';
for (const s of gdoc.querySelectorAll('script[src]')) {
const src = s.getAttribute('src') || '';
if (!isAbsoluteOrProtocol(src)) {
try {
const fetchedJs = await fetchText(base + src.replace(/^\.\//, ''));
extraJsFromGuide += fetchedJs;
} catch (e) { logWarn(`Failed to inline ${src}`); }
}
s.remove();
}
const gInlineScripts = Array.from(gdoc.querySelectorAll('script:not([src])')).map(s => s.textContent);
gdoc.querySelectorAll('script:not([src])').forEach(s => s.remove());
rewriteHtmlRelativeUrls(gdoc);
const styleEl = doc.createElement('style');
styleEl.textContent = rewriteCssUrls(cssText);
doc.head.appendChild(styleEl);
doc.querySelectorAll('script[src]').forEach(s => s.remove());
rewriteHtmlRelativeUrls(doc);
const indexWrap = doc.createElement('div');
indexWrap.dataset.page = 'index';
while (doc.body.firstChild) indexWrap.appendChild(doc.body.firstChild);
doc.body.appendChild(indexWrap);
const gWrap = doc.createElement('div');
gWrap.dataset.page = 'guide';
gWrap.style.display = 'none';
gWrap.innerHTML = gdoc.body.innerHTML.trim() || gdoc.documentElement.outerHTML;
doc.body.appendChild(gWrap);
const inlineScript = doc.createElement('script');
inlineScript.textContent = jsTextMain;
doc.body.appendChild(inlineScript);
if (extraJsFromGuide || gInlineScripts.length) {
const gScript = doc.createElement('script');
gScript.textContent = extraJsFromGuide + '\n' + gInlineScripts.join('\n');
doc.body.appendChild(gScript);
}
// ๐น Minimal page switcher (no nav bar)
const pageSwitcher = doc.createElement('script');
pageSwitcher.textContent = `
(function(){
function showPage(name){
const pages = document.querySelectorAll('[data-page]');
let found = false;
pages.forEach(p=>{
if(p.getAttribute('data-page')===name){p.style.display='';found=true;}
else p.style.display='none';
});
return found;
}
function setFromHash(){
const name = location.hash ? location.hash.slice(1) : 'index';
if(!showPage(name)) showPage('index');
}
window.addEventListener('hashchange', setFromHash);
document.addEventListener('DOMContentLoaded', setFromHash);
})();
`;
doc.body.appendChild(pageSwitcher);
const merged = '<!doctype html>\n' + doc.documentElement.outerHTML;
logInfo('Merge completed successfully.');
return merged;
} catch (err) {
logError('Error during merge: ' + err.message);
throw err;
}
}
// ---------- UI Logic ----------
const mergeDownloadBtn = document.getElementById('mergeDownloadBtn');
const mergePreviewBtn = document.getElementById('mergePreviewBtn');
const showMergedBtn = document.getElementById('showMergedBtn');
const mergedOutput = document.getElementById('mergedOutput');
async function tryBuildAndReturn() {
try {
mergeDownloadBtn.disabled = mergePreviewBtn.disabled = showMergedBtn.disabled = true;
mergeDownloadBtn.textContent = 'Merging...';
const merged = await buildMergedHtml();
mergeDownloadBtn.textContent = 'Merge & Download mental-health-tracking.html';
mergeDownloadBtn.disabled = mergePreviewBtn.disabled = showMergedBtn.disabled = false;
return merged;
} catch (err) {
mergeDownloadBtn.textContent = 'Merge & Download mental-health-tracking.html';
mergeDownloadBtn.disabled = mergePreviewBtn.disabled = showMergedBtn.disabled = false;
logError('Build failed: ' + err.message);
return null;
}
}
mergeDownloadBtn.addEventListener('click', async () => {
const merged = await tryBuildAndReturn();
if (!merged) return;
const blob = new Blob([merged], { type: 'text/html;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'mental-health-tracking.html';
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
logInfo('Merged file downloaded.');
});
mergePreviewBtn.addEventListener('click', async () => {
const merged = await tryBuildAndReturn();
if (!merged) return;
const blob = new Blob([merged], { type: 'text/html' });
const url = URL.createObjectURL(blob);
window.open(url, '_blank');
setTimeout(() => URL.revokeObjectURL(url), 5000);
logInfo('Preview opened in new tab.');
});
showMergedBtn.addEventListener('click', async () => {
const merged = await tryBuildAndReturn();
if (!merged) return;
mergedOutput.value = merged;
mergedOutput.scrollTop = 0;
logInfo('Merged HTML displayed in textarea.');
});
</script>
</body>
</html>
<!doctype html>
<html lang="en" data-theme="">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Personal Journal</title>
</head>
<body>
<div style="max-width:1100px;margin:24px auto;padding:0 18px;">
<div class="app card" id="root">
<header>
<div>
<h1>Local Journal</h1>
<div class="sub">Private, local-only tracking โ entries saved to your browser.</div>
</div>
<div class="row">
<button class="btn btn-ghost" id="btnGuide">User Guide โ</button>
<button class="btn btn-secondary" id="openSettings">Settings</button>
</div>
</header>
<!-- Left column -->
<div class="controls">
<div class="card">
<div style="display:flex;justify-content:space-between;align-items:center;">
<div>
<strong>Quick Actions</strong>
<div class="small muted">Add an entry, manage types, or quick-log.</div>
</div>
</div>
<div class="row">
<button class="btn btn-primary btn-large" id="btnAddEntry">+ Add Entry</button>
<button class="btn btn-secondary" id="btnQuickLog">Quick Log</button>
</div>
<div class="row">
<button class="btn btn-ghost" id="btnManageTypes">Manage Event Types</button>
<button class="btn btn-ghost" id="btnReminders">Reminders</button>
<button class="btn btn-ghost" id="btnExport">Export</button>
</div>
<div style="border-top:1px dashed var(--glass-2);padding-top:10px" class="small muted">
<div><strong id="totalCount">0</strong> total entries</div>
<div id="overviewText" style="margin-top:6px;">No entries yet โ add one to get started.</div>
</div>
</div>
<div class="card">
<div style="display:flex;align-items:center;justify-content:space-between">
<div>
<strong>Event Types</strong>
<div class="small muted">Manage categories used when logging entries.</div>
</div>
<button class="btn btn-secondary" id="btnManageTypes2">Edit</button>
</div>
<div id="typesPreview" style="margin-top:12px;display:flex;flex-wrap:wrap;gap:8px;"></div>
</div>
<div class="card">
<div style="display:flex;align-items:center;justify-content:space-between">
<div>
<strong>Data</strong>
<div class="small muted">Export, import, or clear all local data.</div>
</div>
</div>
<div class="row">
<button class="btn btn-secondary" id="btnExport2">Export CSV/JSON</button>
<button class="btn btn-ghost" id="btnImport">Import JSON</button>
<button class="btn btn-ghost danger" id="btnClearAll">Clear All</button>
</div>
</div>
</div>
<!-- Right column -->
<div class="right">
<div class="card chart-wrap">
<canvas id="weekChart" aria-label="Weekly entries chart"></canvas>
<div class="overview card" style="padding:12px;">
<div style="display:flex;flex-direction:column;gap:8px;">
<div><strong id="weekTotal">0</strong> entries in last 7 days</div>
<div class="muted" id="topType">โ</div>
<div class="muted" id="topMood">โ</div>
</div>
</div>
</div>
<div class="card">
<div style="display:flex;align-items:center;justify-content:space-between">
<strong>Entries</strong>
<div class="row">
<button class="btn btn-ghost" id="toggleCollapse">Collapse</button>
<button class="btn btn-secondary" id="btnClearEntries">Clear</button>
</div>
</div>
<div class="table-wrap">
<table id="entriesTable">
<thead>
<tr>
<th style="width:160px">Date</th>
<th>Type</th>
<th class="col-mood">Mood</th>
<th class="col-sev">Severity</th>
<th>Duration</th>
<th>Notes</th>
<th class="actions-col">Actions</th>
</tr>
</thead>
<tbody id="entriesBody">
<!-- rows -->
</tbody>
</table>
</div>
</div>
<footer class="muted">All data stays in your browser. Use Export to make backups.</footer>
</div>
</div>
</div>
<!-- Backdrop & Modals -->
<div class="backdrop" id="backdrop" role="dialog" aria-hidden="true">
<!-- Add Entry Modal -->
<div class="modal card hidden" id="modalAddEntry">
<div class="card">
<h3 id="addEntryTitle">Add Entry</h3>
<div class="row">
<div style="flex:1">
<label for="entryDate">Date & time</label>
<input type="datetime-local" id="entryDate" />
</div>
<div style="width:200px">
<label for="entryType">Type</label>
<select id="entryType"></select>
</div>
</div>
<div id="fieldMood" class="col">
<label>Mood</label>
<div style="display:flex;gap:8px;align-items:center;">
<input type="text" id="entryMood" placeholder="Choose emoji or type" />
<button class="btn btn-ghost" id="openEmojiPicker">๐</button>
</div>
<div id="emojiPicker" class="emoji-grid hidden" style="margin-top:8px;"></div>
</div>
<div id="fieldSeverity" class="col">
<label>Severity (1-10)</label>
<input type="number" id="entrySeverity" min="1" max="10" />
</div>
<div class="col">
<label>Duration (minutes)</label>
<input type="number" id="entryDuration" min="0" />
</div>
<div class="col">
<label>Notes</label>
<textarea id="entryNotes" placeholder="Optional notes..."></textarea>
</div>
<div class="actions">
<button class="btn btn-secondary" id="btnClearEntry">Clear</button>
<button class="btn btn-ghost" id="btnCancelEntry">Cancel</button>
<button class="btn btn-primary" id="btnSaveEntry">Save</button>
</div>
</div>
</div>
<!-- Quick Log Modal -->
<div class="modal card hidden" id="modalQuickLog">
<div class="card">
<h3>Quick Log</h3>
<div class="row">
<div style="flex:1">
<label>Time</label>
<input type="datetime-local" id="quickDate" />
</div>
<div style="width:200px">
<label>Type</label>
<select id="quickType"></select>
</div>
</div>
<div id="quickMoodWrap" class="col">
<label>Mood</label>
<input type="text" id="quickMood" />
</div>
<div class="actions">
<button class="btn btn-ghost" id="btnCancelQuick">Cancel</button>
<button class="btn btn-primary" id="btnSaveQuick">Save</button>
</div>
</div>
</div>
<!-- Manage Types Modal -->
<div class="modal card hidden" id="modalManageTypes">
<div class="card">
<h3>Manage Event Types</h3>
<div class="type-list" id="typeList"></div>
<div style="display:flex;gap:8px;margin-top:12px;align-items:center;">
<input type="text" id="newTypeName" placeholder="New type name" />
<label style="display:flex;align-items:center;gap:8px;"><input type="checkbox" id="newTypeAction" /> action</label>
<button class="btn btn-primary" id="btnAddType">Add</button>
</div>
<div class="actions">
<button class="btn btn-ghost" id="btnCloseTypes">Close</button>
</div>
</div>
</div>
<!-- Reminders Modal -->
<div class="modal card hidden" id="modalReminders">
<div class="card">
<h3>Daily Reminder</h3>
<div class="col">
<label>Reminder time</label>
<input type="time" id="reminderTime" />
</div>
<div class="row" style="align-items:center">
<label style="margin-right:8px;">Enable reminders</label>
<input type="checkbox" id="enableReminders" />
</div>
<div class="small muted" id="reminderStatus"></div>
<div class="actions">
<button class="btn btn-ghost" id="btnCancelReminders">Close</button>
<button class="btn btn-primary" id="btnSaveReminders">Save</button>
</div>
</div>
</div>
<!-- Settings Modal -->
<div class="modal card hidden" id="modalSettings">
<div class="card">
<h3>Settings</h3>
<div class="col">
<label><input type="checkbox" id="settingMood" /> Enable Mood field</label>
<label><input type="checkbox" id="settingSeverity" /> Enable Severity field</label>
<label><input type="checkbox" id="settingReminders" /> Enable Reminders feature</label>
<label><input type="checkbox" id="settingExport" /> Enable Export/Import</label>
<label><input type="checkbox" id="settingCompact" /> Compact UI</label>
</div>
<div style="margin-top:10px" class="small muted">Changes apply immediately and persist in localStorage.</div>
<div class="actions">
<button class="btn btn-ghost" id="btnCloseSettings">Close</button>
</div>
</div>
</div>
<!-- Export Modal -->
<div class="modal card hidden" id="modalExport">
<div class="card">
<h3>Export</h3>
<div class="col">
<label>Export format</label>
<div class="row">
<button class="btn btn-secondary" id="exportCSV">Download CSV</button>
<button class="btn btn-secondary" id="exportJSON">Download JSON</button>
<button class="btn btn-ghost" id="openImportFile">Import JSON</button>
<input type="file" id="importFile" accept="application/json" class="hidden" />
</div>
</div>
<div class="actions">
<button class="btn btn-ghost" id="btnCloseExport">Close</button>
</div>
</div>
</div>
<!-- View Entry Modal -->
<div class="modal card hidden" id="modalViewEntry">
<div class="card">
<h3>View Entry</h3>
<div id="viewEntryBody" class="col"></div>
<div class="actions">
<button class="btn btn-ghost" id="btnCloseView">Close</button>
<button class="btn btn-secondary" id="btnDeleteEntry">Delete</button>
</div>
</div>
</div>
</div>
<!-- ๐น Simple script to handle the page switch -->
<script>
document.getElementById('btnGuide').addEventListener('click', () => {
location.hash = 'guide';
});
</script>
</body>
</html>
/* =========================
VERSIONED STORAGE KEYS
========================= */
const APP_VERSION = 'v1';
const KEYS = {
// ENTRIES will be stored in IndexedDB now (KEYS.ENTRIES retained for backwards compat where needed)
ENTRIES: `LOCALJOURNAL_${APP_VERSION}_ENTRIES`,
TYPES: `LOCALJOURNAL_${APP_VERSION}_TYPES`,
REMINDER: `LOCALJOURNAL_${APP_VERSION}_REMINDER`,
SETTINGS: `LOCALJOURNAL_${APP_VERSION}_SETTINGS`
};
/* =========================
DEFAULTS
========================= */
const defaultTypes = [
{ id: 'nssi_action', name: 'NSSI โ action', action: true },
{ id: 'nssi_ideation', name: 'NSSI โ ideation', action: false },
{ id: 'depressive', name: 'Depressive episode', action: false },
{ id: 'suicidal_ideation', name: 'Suicidal ideation', action: false },
{ id: 'suicidal_action', name: 'Suicidal โ action', action: true }
];
const DEFAULT_SETTINGS = {
enableMood: true,
enableSeverity: true,
enableReminders: false,
enableExport: true,
compactUI: false
};
const DEFAULT_REMINDER = { time: null, enabled: false };
/* =========================
Persistence helpers (localStorage for small things)
========================= */
function loadJSON(key, fallback) {
try {
const raw = localStorage.getItem(key);
if (!raw) return fallback;
return JSON.parse(raw);
} catch (e) {
console.warn('Failed to parse localStorage key', key, e);
return fallback;
}
}
function saveJSON(key, value) {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch (e) {
console.error('Failed to save', key, e);
}
}
/* =========================
IndexedDB wrapper for entries
========================= */
const DB_NAME = 'LocalJournalDB';
const DB_VERSION = 1;
const STORE_ENTRIES = 'entries';
let dbPromise = null;
function openDB() {
if (dbPromise) return dbPromise;
dbPromise = new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = (e) => {
const db = e.target.result;
if (!db.objectStoreNames.contains(STORE_ENTRIES)) {
const store = db.createObjectStore(STORE_ENTRIES, { keyPath: 'id' });
store.createIndex('timestamp', 'timestamp', { unique: false });
store.createIndex('type', 'type', { unique: false });
// mood or other indexes could be added here if needed
}
};
req.onsuccess = (e) => resolve(e.target.result);
req.onerror = (e) => reject(e.target.error);
});
return dbPromise;
}
function promisifyRequest(req) {
return new Promise((resolve, reject) => {
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
async function dbPutEntry(entry) {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_ENTRIES, 'readwrite');
const store = tx.objectStore(STORE_ENTRIES);
const req = store.put(entry);
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
async function dbGetAllEntries() {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_ENTRIES, 'readonly');
const store = tx.objectStore(STORE_ENTRIES);
const req = store.getAll();
req.onsuccess = () => resolve(req.result || []);
req.onerror = () => reject(req.error);
});
}
async function dbDeleteEntry(id) {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_ENTRIES, 'readwrite');
const store = tx.objectStore(STORE_ENTRIES);
const req = store.delete(id);
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
});
}
async function dbClearEntries() {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_ENTRIES, 'readwrite');
const store = tx.objectStore(STORE_ENTRIES);
const req = store.clear();
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
});
}
async function dbBulkPut(entriesArray) {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_ENTRIES, 'readwrite');
const store = tx.objectStore(STORE_ENTRIES);
let count = 0;
for (const entry of entriesArray) {
const req = store.put(entry);
req.onsuccess = () => {
count++;
// if last, resolve โ but can't easily know last synchronously; instead wait for tx.oncomplete
};
req.onerror = () => {
// ignore single failure? reject
console.error('dbBulkPut item failed', req.error);
};
}
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(tx.error);
});
}
/* =========================
Utilities
========================= */
function esc(s){ // safe HTML insertion
if (s === null || s === undefined) return '';
return String(s)
.replaceAll('&','&')
.replaceAll('<','<')
.replaceAll('>','>')
.replaceAll('"','"')
.replaceAll("'", ''');
}
function isoDate(ts){ return (new Date(ts)).toISOString(); }
function formatShort(ts){
const d = new Date(ts);
return d.toLocaleString([], {month:'short', day:'numeric', hour:'2-digit', minute:'2-digit'});
}
function friendlyDateTime(ts){
const d = new Date(ts);
return d.toLocaleString();
}
function pad(n){ return String(n).padStart(2,'0'); }
function downloadBlob(filename, blob){
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(()=> URL.revokeObjectURL(url), 5000);
}
function isActionType(typeId){
const t = types.find(x => x.id === typeId);
return !!(t && t.action);
}
/* =========================
App state
- entries moved to IndexedDB; keep in-memory cache 'entries' for UI responsiveness
========================= */
let entries = []; // will be populated from IndexedDB during init
let types = loadJSON(KEYS.TYPES, defaultTypes);
let settings = loadJSON(KEYS.SETTINGS, DEFAULT_SETTINGS);
let reminder = loadJSON(KEYS.REMINDER, DEFAULT_REMINDER);
let reminderInterval = null;
/* =========================
DOM refs
========================= */
const rootEl = document.getElementById('root');
const weekCanvas = document.getElementById('weekChart');
const entriesBody = document.getElementById('entriesBody');
const totalCountEl = document.getElementById('totalCount');
const overviewText = document.getElementById('overviewText');
const typesPreview = document.getElementById('typesPreview');
const weekTotalEl = document.getElementById('weekTotal');
const topTypeEl = document.getElementById('topType');
const topMoodEl = document.getElementById('topMood');
const backdrop = document.getElementById('backdrop');
/* Modal nodes */
const modalAddEntry = document.getElementById('modalAddEntry');
const modalQuickLog = document.getElementById('modalQuickLog');
const modalManageTypes = document.getElementById('modalManageTypes');
const modalReminders = document.getElementById('modalReminders');
const modalSettings = document.getElementById('modalSettings');
const modalExport = document.getElementById('modalExport');
const modalViewEntry = document.getElementById('modalViewEntry');
/* inputs */
const entryDate = document.getElementById('entryDate');
const entryType = document.getElementById('entryType');
const entryMood = document.getElementById('entryMood');
const entrySeverity = document.getElementById('entrySeverity');
const entryDuration = document.getElementById('entryDuration');
const entryNotes = document.getElementById('entryNotes');
const emojiPicker = document.getElementById('emojiPicker');
const quickDate = document.getElementById('quickDate');
const quickType = document.getElementById('quickType');
const quickMood = document.getElementById('quickMood');
const typeList = document.getElementById('typeList');
const newTypeName = document.getElementById('newTypeName');
const newTypeAction = document.getElementById('newTypeAction');
const reminderTime = document.getElementById('reminderTime');
const enableRemindersInput = document.getElementById('enableReminders');
const reminderStatus = document.getElementById('reminderStatus');
const settingMood = document.getElementById('settingMood');
const settingSeverity = document.getElementById('settingSeverity');
const settingReminders = document.getElementById('settingReminders');
const settingExport = document.getElementById('settingExport');
const settingCompact = document.getElementById('settingCompact');
const entriesTable = document.getElementById('entriesTable');
let isListCollapsed = false;
let currentViewEntryId = null;
/* =========================
Initial setup (async to load entries from DB)
========================= */
async function init(){
// load entries from IndexedDB
try {
entries = await dbGetAllEntries();
// sort desc by timestamp
entries.sort((a,b)=> new Date(b.timestamp) - new Date(a.timestamp));
} catch (err) {
console.error('Failed to load entries from IndexedDB', err);
entries = [];
}
// apply settings UI
applySettingsToForm();
applySettings();
// wire up UI buttons
wireUI();
// draw initial UI
renderTypeSelectors();
renderTypesPreview();
renderEntries();
drawWeekChart();
setupEmojiPicker();
scheduleReminderLoop();
}
/* =========================
Settings apply
========================= */
function applySettingsToForm(){
settingMood.checked = !!settings.enableMood;
settingSeverity.checked = !!settings.enableSeverity;
settingReminders.checked = !!settings.enableReminders;
settingExport.checked = !!settings.enableExport;
settingCompact.checked = !!settings.compactUI;
// apply reminder form
reminderTime.value = reminder.time || '';
enableRemindersInput.checked = !!(reminder.enabled);
}
function applySettings(){
// Show/hide mood & severity fields in Add Entry modal and Quick Log
const fieldMood = document.getElementById('fieldMood');
const fieldSeverity = document.getElementById('fieldSeverity');
const quickMoodWrap = document.getElementById('quickMoodWrap');
if (settings.enableMood) { fieldMood.classList.remove('hidden'); quickMoodWrap.classList.remove('hidden'); }
else { fieldMood.classList.add('hidden'); quickMoodWrap.classList.add('hidden'); }
if (settings.enableSeverity) fieldSeverity.classList.remove('hidden'); else fieldSeverity.classList.add('hidden');
// Export/Import availability
if (settings.enableExport) {
document.getElementById('btnExport').classList.remove('hidden');
document.getElementById('btnExport2').classList.remove('hidden');
} else {
document.getElementById('btnExport').classList.add('hidden');
document.getElementById('btnExport2').classList.add('hidden');
}
// Reminders
if (settings.enableReminders) {
document.getElementById('btnReminders').classList.remove('hidden');
} else {
document.getElementById('btnReminders').classList.add('hidden');
}
// compact UI
if (settings.compactUI) document.body.classList.add('compact'); else document.body.classList.remove('compact');
// columns in table
document.querySelectorAll('.col-mood').forEach(n => n.style.display = settings.enableMood ? '' : 'none');
document.querySelectorAll('.col-sev').forEach(n => n.style.display = settings.enableSeverity ? '' : 'none');
}
/* =========================
Event wiring
========================= */
function wireUI(){
document.getElementById('btnAddEntry').addEventListener('click', openAddEntry);
document.getElementById('btnQuickLog').addEventListener('click', openQuickLog);
document.getElementById('btnManageTypes').addEventListener('click', openManageTypes);
document.getElementById('btnManageTypes2').addEventListener('click', openManageTypes);
document.getElementById('btnReminders').addEventListener('click', openReminders);
document.getElementById('btnExport').addEventListener('click', openExport);
document.getElementById('btnExport2').addEventListener('click', openExport);
document.getElementById('btnImport').addEventListener('click', ()=> document.getElementById('importFile').click());
document.getElementById('importFile').addEventListener('change', handleImportFile);
document.getElementById('btnClearAll').addEventListener('click', clearAllData);
document.getElementById('btnClearEntries').addEventListener('click', clearEntries);
document.getElementById('toggleCollapse').addEventListener('click', toggleCollapse);
document.getElementById('btnSaveEntry').addEventListener('click', saveEntryFromModal);
document.getElementById('btnCancelEntry').addEventListener('click', hideBackdrop);
document.getElementById('btnSaveQuick').addEventListener('click', saveQuickLog);
document.getElementById('btnCancelQuick').addEventListener('click', hideBackdrop);
document.getElementById('btnAddType').addEventListener('click', addTypeFromInput);
document.getElementById('btnCloseTypes').addEventListener('click', hideBackdrop);
document.getElementById('btnSaveReminders').addEventListener('click', saveReminderSettings);
document.getElementById('btnCancelReminders').addEventListener('click', hideBackdrop);
document.getElementById('openSettings').addEventListener('click', openSettings);
document.getElementById('btnCloseSettings').addEventListener('click', hideBackdrop);
// settings toggles
settingMood.addEventListener('change', ()=> { settings.enableMood = settingMood.checked; saveJSON(KEYS.SETTINGS, settings); applySettings(); });
settingSeverity.addEventListener('change', ()=> { settings.enableSeverity = settingSeverity.checked; saveJSON(KEYS.SETTINGS, settings); applySettings(); });
settingReminders.addEventListener('change', ()=> { settings.enableReminders = settingReminders.checked; saveJSON(KEYS.SETTINGS, settings); applySettings(); scheduleReminderLoop(); });
settingExport.addEventListener('change', ()=> { settings.enableExport = settingExport.checked; saveJSON(KEYS.SETTINGS, settings); applySettings(); });
settingCompact.addEventListener('change', ()=> { settings.compactUI = settingCompact.checked; saveJSON(KEYS.SETTINGS, settings); applySettings(); });
// emoji
document.getElementById('openEmojiPicker').addEventListener('click', ()=> emojiPicker.classList.toggle('hidden'));
// export modal
document.getElementById('exportCSV').addEventListener('click', exportCSV);
document.getElementById('exportJSON').addEventListener('click', exportJSON);
document.getElementById('openImportFile').addEventListener('click', ()=> document.getElementById('importFile').click());
document.getElementById('btnCloseExport').addEventListener('click', hideBackdrop);
// view modal
document.getElementById('btnCloseView').addEventListener('click', hideBackdrop);
document.getElementById('btnDeleteEntry').addEventListener('click', deleteViewedEntry);
// backdrop click closes
backdrop.addEventListener('click', (e) => {
if (e.target === backdrop) hideBackdrop();
});
}
/* =========================
UI: Modals open/close
========================= */
function showBackdrop(){
backdrop.classList.add('show');
// set first modal's aria
backdrop.setAttribute('aria-hidden','false');
}
function hideBackdrop(){
backdrop.classList.remove('show');
// hide all modals
document.querySelectorAll('.modal').forEach(m=>m.classList.add('hidden'));
backdrop.setAttribute('aria-hidden','true');
}
/* =========================
Add / Quick entries (async DB-backed)
========================= */
function openAddEntry(){
// prefill
entryDate.value = (new Date()).toISOString().slice(0,16);
entryType.innerHTML = '';
renderTypeSelectors(); // fill select
entrySeverity.value = '';
entryDuration.value = '';
entryMood.value = '';
entryNotes.value = '';
document.getElementById('addEntryTitle').textContent = 'Add Entry';
document.querySelectorAll('.modal').forEach(m=>m.classList.add('hidden'));
modalAddEntry.classList.remove('hidden');
showBackdrop();
}
async function saveEntryFromModal(){
const ts = entryDate.value ? new Date(entryDate.value).toISOString() : new Date().toISOString();
const t = entryType.value;
const moodVal = settings.enableMood ? (entryMood.value || '') : '';
const sevVal = settings.enableSeverity ? (entrySeverity.value ? Number(entrySeverity.value) : null) : null;
const dur = entryDuration.value ? Number(entryDuration.value) : null;
const notesVal = entryNotes.value || '';
const entry = {
id: 'e_' + Date.now(),
timestamp: ts,
type: t,
mood: moodVal,
severity: sevVal,
duration: dur,
notes: notesVal
};
try {
await dbPutEntry(entry);
// keep in-memory list in sync (most recent first)
entries.unshift(entry);
// keep sorted
entries.sort((a,b)=> new Date(b.timestamp) - new Date(a.timestamp));
renderEntries();
drawWeekChart();
hideBackdrop();
} catch (err) {
console.error('Failed to save entry to IndexedDB', err);
alert('Failed to save entry. See console for details.');
}
}
function openQuickLog(){
quickDate.value = (new Date()).toISOString().slice(0,16);
quickType.innerHTML = '';
renderTypeSelectors(true);
quickMood.value = '';
document.querySelectorAll('.modal').forEach(m=>m.classList.add('hidden'));
modalQuickLog.classList.remove('hidden');
showBackdrop();
}
async function saveQuickLog(){
const ts = quickDate.value ? new Date(quickDate.value).toISOString() : new Date().toISOString();
const t = quickType.value;
const moodVal = settings.enableMood ? (quickMood.value || '') : '';
const entry = {
id: 'e_' + Date.now(),
timestamp: ts,
type: t,
mood: moodVal,
severity: null, duration: null, notes: ''
};
try {
await dbPutEntry(entry);
entries.unshift(entry);
entries.sort((a,b)=> new Date(b.timestamp) - new Date(a.timestamp));
renderEntries();
drawWeekChart();
hideBackdrop();
} catch (err) {
console.error('Failed to save quick entry', err);
alert('Failed to save entry. See console for details.');
}
}
/* =========================
Render types selectors & preview
========================= */
function renderTypeSelectors(quick=false){
// fill both selects
const selects = quick ? [quickType] : [entryType, quickType];
selects.forEach(sel => {
if (!sel) return;
sel.innerHTML = '';
types.forEach(t => {
const opt = document.createElement('option');
opt.value = t.id;
opt.textContent = t.name + (t.action ? ' (action)' : '');
sel.appendChild(opt);
});
});
}
function renderTypesPreview(){
typesPreview.innerHTML = '';
types.slice(0,8).forEach(t => {
const d = document.createElement('div');
d.className = 'pill';
d.textContent = t.name;
typesPreview.appendChild(d);
});
}
/* =========================
Manage types
========================= */
function openManageTypes(){
typeList.innerHTML = '';
types.forEach(t => {
const row = document.createElement('div');
row.className = 'type-item';
row.innerHTML = `
<div style="flex:1">
<input type="text" data-id="${esc(t.id)}" class="typeNameInput" value="${esc(t.name)}" />
</div>
<label style="display:flex;align-items:center;gap:6px;">
<input type="checkbox" class="typeActionInput" ${t.action ? 'checked' : ''} />
action
</label>
<button class="btn btn-ghost btn-sm typeRename" data-id="${esc(t.id)}">Save</button>
<button class="btn btn-ghost danger typeDelete" data-id="${esc(t.id)}">Delete</button>
`;
typeList.appendChild(row);
// wire inside
row.querySelector('.typeRename').addEventListener('click', ()=>{
const input = row.querySelector('.typeNameInput');
const chk = row.querySelector('.typeActionInput');
const id = input.getAttribute('data-id');
const idx = types.findIndex(x => x.id === id);
if (idx >= 0){
types[idx].name = input.value.trim() || types[idx].name;
types[idx].action = chk.checked;
saveJSON(KEYS.TYPES, types);
renderTypeSelectors();
renderTypesPreview();
renderEntries();
}
});
row.querySelector('.typeDelete').addEventListener('click', ()=>{
const id = row.querySelector('.typeNameInput').getAttribute('data-id');
if (!confirm('Delete type "' + types.find(x=>x.id===id).name + '"? Existing entries will keep their type id.')) return;
types = types.filter(x=>x.id !== id);
saveJSON(KEYS.TYPES, types);
renderTypeSelectors();
renderTypesPreview();
renderEntries();
openManageTypes();
});
});
// prepare new-type inputs
newTypeName.value = '';
newTypeAction.checked = false;
document.querySelectorAll('.modal').forEach(m=>m.classList.add('hidden'));
modalManageTypes.classList.remove('hidden');
showBackdrop();
}
function addTypeFromInput(){
const nm = newTypeName.value.trim();
if (!nm) return alert('Please provide a type name.');
const id = nm.toLowerCase().replace(/\s+/g,'_').replace(/[^\w\-]/g,'') + '_' + Date.now().toString(36);
const obj = { id, name: nm, action: !!newTypeAction.checked };
types.push(obj);
saveJSON(KEYS.TYPES, types);
renderTypeSelectors();
renderTypesPreview();
addTypeFromInputClear();
openManageTypes(); // refresh
}
function addTypeFromInputClear(){ newTypeName.value=''; newTypeAction.checked=false; }
/* =========================
Entries rendering & actions
========================= */
function renderEntries(){
// entries is the in-memory cache of DB entries (already sorted where appropriate)
entries.sort((a,b)=> new Date(b.timestamp) - new Date(a.timestamp));
entriesBody.innerHTML = '';
entries.forEach(e => {
const tr = document.createElement('tr');
const typeObj = types.find(t=>t.id===e.type) || { name: e.type || 'Unknown', action: false };
const dateTd = document.createElement('td');
dateTd.innerHTML = `<div style="font-weight:700">${esc(formatShort(e.timestamp))}</div><div class="small muted">${esc(new Date(e.timestamp).toLocaleString())}</div>`;
const typeTd = document.createElement('td');
typeTd.innerHTML = `<div>${esc(typeObj.name)}</div>`;
const moodTd = document.createElement('td');
moodTd.className = 'col-mood';
moodTd.textContent = settings.enableMood ? (e.mood || '') : '';
const sevTd = document.createElement('td');
sevTd.className = 'col-sev';
sevTd.textContent = (settings.enableSeverity && isActionType(e.type) && e.severity) ? String(e.severity) : (isActionType(e.type) ? 'โ' : '');
const durTd = document.createElement('td');
durTd.textContent = e.duration != null ? (String(e.duration) + ' min') : 'โ';
const notesTd = document.createElement('td');
notesTd.className = 'notes-cell';
notesTd.textContent = e.notes && e.notes.trim() ? e.notes : 'โ';
// Actions cell: use classes that keep buttons in a single row and prevent stretching
const actTd = document.createElement('td');
actTd.className = 'actions-cell actions-col';
actTd.innerHTML = `
<button class="btn btn-ghost" data-id="${esc(e.id)}" data-action="view" title="View">View</button>
<button class="btn btn-ghost danger" data-id="${esc(e.id)}" data-action="delete" title="Delete">Delete</button>
`;
tr.appendChild(dateTd);
tr.appendChild(typeTd);
tr.appendChild(moodTd);
tr.appendChild(sevTd);
tr.appendChild(durTd);
tr.appendChild(notesTd);
tr.appendChild(actTd);
entriesBody.appendChild(tr);
// wire actions (use async handler for delete)
actTd.querySelectorAll('button').forEach(btn => {
btn.addEventListener('click', async (ev)=>{
const id = btn.getAttribute('data-id');
const action = btn.getAttribute('data-action');
if (action === 'view') viewEntry(id);
if (action === 'delete') {
if (!confirm('Delete this entry?')) return;
try {
await dbDeleteEntry(id);
entries = entries.filter(x=>x.id !== id);
renderEntries();
drawWeekChart();
} catch (err) {
console.error('Failed to delete entry', err);
alert('Failed to delete entry. See console for details.');
}
}
});
});
});
totalCountEl.textContent = entries.length;
overviewText.textContent = entries.length ? `Most recent: ${entries.length? formatShort(entries[0].timestamp) : 'โ'}` : 'No entries yet โ add one to get started.';
renderTypesPreview();
}
/* =========================
View Entry modal
========================= */
function viewEntry(id){
const ent = entries.find(x => x.id === id);
if (!ent) return alert('Entry not found.');
currentViewEntryId = id;
const typeObj = types.find(t => t.id === ent.type) || { name: ent.type || 'Unknown', action:false };
const wrap = document.getElementById('viewEntryBody');
wrap.innerHTML = `
<div><strong>Date</strong><div class="small muted">${esc(friendlyDateTime(ent.timestamp))}</div></div>
<div style="margin-top:8px"><strong>Type</strong><div class="small muted">${esc(typeObj.name)}</div></div>
${settings.enableMood ? `<div style="margin-top:8px"><strong>Mood</strong><div class="small muted">${esc(ent.mood||'โ')}</div></div>` : ''}
${settings.enableSeverity && typeObj.action ? `<div style="margin-top:8px"><strong>Severity</strong><div class="small muted">${esc(ent.severity||'โ')}</div></div>` : ''}
<div style="margin-top:8px"><strong>Duration</strong><div class="small muted">${ent.duration != null ? esc(String(ent.duration) + ' min') : 'โ'}</div></div>
<div style="margin-top:8px"><strong>Notes</strong><div class="small muted">${ent.notes ? esc(ent.notes) : 'โ'}</div></div>
`;
document.querySelectorAll('.modal').forEach(m=>m.classList.add('hidden'));
modalViewEntry.classList.remove('hidden');
showBackdrop();
}
async function deleteViewedEntry(){
if (!currentViewEntryId) return;
if (!confirm('Delete this entry?')) return;
try {
await dbDeleteEntry(currentViewEntryId);
entries = entries.filter(x => x.id !== currentViewEntryId);
currentViewEntryId = null;
renderEntries();
drawWeekChart();
hideBackdrop();
} catch (err) {
console.error('Failed to delete viewed entry', err);
alert('Failed to delete entry. See console for details.');
}
}
/* =========================
Chart: weekly bar chart
========================= */
function drawWeekChart(){
const ctx = weekCanvas.getContext('2d');
const DPR = window.devicePixelRatio || 1;
const W = weekCanvas.clientWidth;
const H = weekCanvas.clientHeight;
weekCanvas.width = Math.floor(W * DPR);
weekCanvas.height = Math.floor(H * DPR);
ctx.scale(DPR, DPR);
// compute last 7 days (including today)
const counts = [];
const labels = [];
const today = new Date();
for (let i = 6; i >= 0; i--){
const d = new Date(today);
d.setDate(today.getDate() - i);
const start = new Date(d.getFullYear(), d.getMonth(), d.getDate(), 0,0,0,0);
const end = new Date(d.getFullYear(), d.getMonth(), d.getDate(), 23,59,59,999);
const c = entries.filter(e => {
const t = new Date(e.timestamp);
return t >= start && t <= end;
}).length;
counts.push(c);
labels.push(d.toLocaleDateString([], {weekday:'short'}).slice(0,3));
}
// clear
ctx.clearRect(0,0,W,H);
// visual params
const padding = 18;
const barGap = 10;
const maxVal = Math.max(1, ...counts);
const barAreaW = W - padding*2;
const barW = (barAreaW - (counts.length - 1)*barGap) / counts.length;
const chartH = H - padding*2;
// colors from CSS variables
const barColor = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim() || '#2563eb';
const barColor2 = getComputedStyle(document.documentElement).getPropertyValue('--accent-2').trim() || '#10b981';
const textColor = getComputedStyle(document.documentElement).getPropertyValue('--muted').trim() || '#6b7280';
// draw grid lines
ctx.strokeStyle = 'rgba(0,0,0,0.06)';
ctx.lineWidth = 1;
ctx.beginPath();
for (let row=0; row<=3; row++){
const y = padding + (chartH * row / 3);
ctx.moveTo(padding, y);
ctx.lineTo(W - padding, y);
}
ctx.stroke();
// draw bars
counts.forEach((val, i) => {
const x = padding + i * (barW + barGap);
const h = (val / maxVal) * (chartH - 20);
const y = padding + (chartH - h) - 6;
// gradient
const grad = ctx.createLinearGradient(x, y, x, y + h);
grad.addColorStop(0, barColor);
grad.addColorStop(1, barColor2);
ctx.fillStyle = grad;
// rounded rect
const r = 6;
roundRect(ctx, x, y, barW, h, r);
ctx.fill();
// label (count)
ctx.fillStyle = textColor;
ctx.font = '600 12px system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.fillText(String(val), x + barW / 2, y - 8);
// day label
ctx.font = '12px system-ui, sans-serif';
ctx.fillText(labels[i], x + barW / 2, padding + chartH + 14);
});
// summary stats
const last7 = counts.reduce((a,b)=>a+b,0);
weekTotalEl.textContent = last7;
// top type
const since = new Date();
since.setDate(since.getDate() - 6);
const recent = entries.filter(e => new Date(e.timestamp) >= new Date(since.getFullYear(), since.getMonth(), since.getDate(),0,0,0));
const typeCounts = {};
recent.forEach(r => typeCounts[r.type] = (typeCounts[r.type] || 0) + 1);
let topType = 'โ';
if (Object.keys(typeCounts).length){
const topId = Object.keys(typeCounts).sort((a,b)=> typeCounts[b]-typeCounts[a])[0];
const tObj = types.find(t=>t.id===topId);
topType = tObj ? `${tObj.name} (${typeCounts[topId]})` : `${topId} (${typeCounts[topId]})`;
}
topTypeEl.textContent = topType;
// top mood
const moodCounts = {};
recent.forEach(r => { if (r.mood) moodCounts[r.mood] = (moodCounts[r.mood]||0)+1; });
let topMood = 'โ';
if (Object.keys(moodCounts).length){
const m = Object.keys(moodCounts).sort((a,b)=> moodCounts[b]-moodCounts[a])[0];
topMood = `${m} (${moodCounts[m]})`;
}
topMoodEl.textContent = topMood;
}
function roundRect(ctx, x, y, w, h, r){
const radius = Math.min(r, w/2, h/2);
ctx.beginPath();
ctx.moveTo(x + radius, y);
ctx.arcTo(x + w, y, x + w, y + h, radius);
ctx.arcTo(x + w, y + h, x, y + h, radius);
ctx.arcTo(x, y + h, x, y, radius);
ctx.arcTo(x, y, x + w, y, radius);
ctx.closePath();
}
/* =========================
Reminders (settings still in localStorage)
========================= */
function openReminders(){
reminderTime.value = reminder.time || '';
enableRemindersInput.checked = !!reminder.enabled;
reminderStatus.textContent = `Current saved: ${reminder.time || 'none'} (enabled: ${reminder.enabled ? 'yes' : 'no'})`;
document.querySelectorAll('.modal').forEach(m=>m.classList.add('hidden'));
modalReminders.classList.remove('hidden');
showBackdrop();
}
async function saveReminderSettings(){
const t = reminderTime.value || null;
const en = !!enableRemindersInput.checked;
reminder.time = t;
reminder.enabled = en;
saveJSON(KEYS.REMINDER, reminder);
settings.enableReminders = en;
saveJSON(KEYS.SETTINGS, settings);
applySettingsToForm();
applySettings();
scheduleReminderLoop();
hideBackdrop();
if (en){
// request permission if necessary
if (Notification && Notification.permission !== 'granted') {
try {
await Notification.requestPermission();
} catch(e){}
}
}
}
function scheduleReminderLoop(){
if (reminderInterval) {
clearInterval(reminderInterval);
reminderInterval = null;
}
// only run if feature is enabled in settings and reminder.enabled
if (!settings.enableReminders || !reminder.enabled || !reminder.time) return;
// Do a check every 60 seconds (aligned to minute)
checkReminderNow(); // immediate check
reminderInterval = setInterval(checkReminderNow, 60 * 1000);
}
let lastReminderTrigger = null;
function checkReminderNow(){
if (!reminder.time || !reminder.enabled) return;
const now = new Date();
const hh = pad(now.getHours());
const mm = pad(now.getMinutes());
const cur = `${hh}:${mm}`;
if (cur !== reminder.time) return;
// avoid repeat triggers within the same minute
if (lastReminderTrigger === cur) return;
lastReminderTrigger = cur;
// show notification if permission
if (window.Notification && Notification.permission === 'granted') {
new Notification('Reminder', { body: 'Time to log an entry', silent: false });
} else {
// fallback: small on-screen prompt
alert('Reminder: time to log an entry.');
}
}
/* =========================
Export / Import (read from DB to ensure completeness)
========================= */
async function exportCSV(){
if (!settings.enableExport) return alert('Export is disabled in Settings.');
try {
const all = await dbGetAllEntries();
const header = ['timestamp','type','mood','severity','duration','notes'];
const rows = all.map(e => [e.timestamp, e.type, e.mood || '', e.severity ?? '', e.duration ?? '', e.notes || '']);
const csv = [header.join(',')].concat(rows.map(r => r.map(csvEscape).join(','))).join('\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
downloadBlob('local-journal-export.csv', blob);
} catch (err) {
console.error('Failed to export CSV', err);
alert('Export failed. See console for details.');
}
}
async function exportJSON(){
if (!settings.enableExport) return alert('Export is disabled in Settings.');
try {
const all = await dbGetAllEntries();
const blob = new Blob([JSON.stringify(all, null, 2)], { type: 'application/json;charset=utf-8' });
downloadBlob('local-journal-export.json', blob);
} catch (err) {
console.error('Failed to export JSON', err);
alert('Export failed. See console for details.');
}
}
function csvEscape(val){
if (val === null || val === undefined) return '';
const s = String(val).replace(/\r?\n/g,' ');
if (s.includes(',') || s.includes('"')) {
return '"' + s.replaceAll('"','""') + '"';
}
return s;
}
function handleImportFile(e){
const file = e.target.files && e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = async (ev) => {
try{
const parsed = JSON.parse(ev.target.result);
if (!Array.isArray(parsed)) return alert('Invalid JSON: expected an array of entries.');
// basic validation
const good = parsed.filter(p => p.timestamp && p.type);
if (!good.length) return alert('No valid entries found.');
// ask user whether to append or replace
if (!confirm(`Import ${good.length} entries. Click OK to append to existing entries.`)) return;
// ensure ids
const normalized = good.map(x=>{
return {
id: x.id || ('e_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2,8)),
timestamp: x.timestamp,
type: x.type,
mood: x.mood || '',
severity: x.severity ?? null,
duration: x.duration ?? null,
notes: x.notes || ''
};
});
// bulk write to DB
await dbBulkPut(normalized);
// refresh in-memory cache
entries = await dbGetAllEntries();
entries.sort((a,b)=> new Date(b.timestamp) - new Date(a.timestamp));
renderEntries();
drawWeekChart();
alert('Import complete.');
}catch(err){
console.error(err);
alert('Failed to import JSON: ' + err.message);
} finally {
e.target.value = '';
}
};
reader.readAsText(file);
}
/* =========================
Clearing
========================= */
async function clearAllData(){
if (!confirm('Clear all app data (entries, types, settings, reminders)? This cannot be undone.')) return;
try {
await dbClearEntries();
localStorage.removeItem(KEYS.TYPES);
localStorage.removeItem(KEYS.SETTINGS);
localStorage.removeItem(KEYS.REMINDER);
entries = [];
types = defaultTypes.slice();
settings = DEFAULT_SETTINGS;
reminder = DEFAULT_REMINDER;
applySettingsToForm();
applySettings();
renderTypeSelectors();
renderEntries();
drawWeekChart();
alert('All data cleared.');
} catch (err) {
console.error('Failed to clear data', err);
alert('Failed to clear data. See console for details.');
}
}
async function clearEntries(){
if (!confirm('Clear all saved entries?')) return;
try {
await dbClearEntries();
entries = [];
renderEntries();
drawWeekChart();
} catch (err) {
console.error('Failed to clear entries', err);
alert('Failed to clear entries. See console for details.');
}
}
/* =========================
Export modal & import
========================= */
function openExport(){
if (!settings.enableExport) return alert('Export is disabled in settings.');
document.querySelectorAll('.modal').forEach(m=>m.classList.add('hidden'));
modalExport.classList.remove('hidden');
showBackdrop();
}
/* =========================
Settings modal
========================= */
function openSettings(){
applySettingsToForm();
document.querySelectorAll('.modal').forEach(m=>m.classList.add('hidden'));
modalSettings.classList.remove('hidden');
showBackdrop();
}
/* =========================
Emoji picker
========================= */
const EMOJIS = [
// Saddest / Negative
'๐ญ','๐ข','๐','๐','๐','โน๏ธ',
'๐ฃ','๐','๐ซ','๐ฉ',
'๐ ','๐ก','๐คฌ',
// Worried / Fearful
'๐จ','๐ฐ','๐ฑ','๐ง',
// Neutral / Ambiguous
'๐ถ','๐','๐','๐','๐ฌ','๐ค','๐คจ',
// Surprised / Shocked
'๐ฏ','๐ฎ','๐ฒ','๐ณ','๐ต','๐คฏ',
// Slightly Positive
'๐','๐','๐','๐',
// Happy / Laughing
'๐','๐','๐','๐','๐','๐คฃ',
// Playful / Excited
'๐','๐','๐คช','๐','๐คฉ','๐ฅณ'
];
function setupEmojiPicker(){
emojiPicker.innerHTML = '';
EMOJIS.forEach(e=>{
const b = document.createElement('button');
b.className = 'emoji-btn';
b.textContent = e;
b.title = e;
b.addEventListener('click', ()=> {
entryMood.value = e;
quickMood.value = e;
emojiPicker.classList.add('hidden');
});
emojiPicker.appendChild(b);
});
}
/* =========================
Toggle collapse list
========================= */
function toggleCollapse(){
isListCollapsed = !isListCollapsed;
entriesBody.parentElement.style.display = isListCollapsed ? 'none' : '';
document.getElementById('toggleCollapse').textContent = isListCollapsed ? 'Expand' : 'Collapse';
}
/* =========================
Import/Export helpers in settings (text import)
========================= */
async function handleImportJsonText(jsonText){
try{
const parsed = JSON.parse(jsonText);
if (!Array.isArray(parsed)) throw new Error('Expected array');
const normalized = parsed.map(x => ({
id: x.id || ('e_' + Date.now().toString(36) + Math.random().toString(36).slice(2,5)),
timestamp: x.timestamp,
type: x.type,
mood: x.mood || '',
severity: x.severity ?? null,
duration: x.duration ?? null,
notes: x.notes || ''
}));
await dbBulkPut(normalized);
entries = await dbGetAllEntries();
entries.sort((a,b)=> new Date(b.timestamp) - new Date(a.timestamp));
renderEntries();
drawWeekChart();
alert('Imported ' + parsed.length + ' entries.');
}catch(err){
alert('Import failed: ' + err.message);
}
}
/* =========================
Init: ensure types set (persist small items in localStorage)
========================= */
if (!Array.isArray(types) || types.length === 0){
types = defaultTypes.slice();
saveJSON(KEYS.TYPES, types);
}
if (!settings || typeof settings !== 'object'){ settings = DEFAULT_SETTINGS; saveJSON(KEYS.SETTINGS, settings); }
if (!reminder || typeof reminder !== 'object'){ reminder = DEFAULT_REMINDER; saveJSON(KEYS.REMINDER, reminder); }
init().catch(err => {
console.error('Initialization failed', err);
});
/* =========================
Extra: Save settings watchers (only small things remain in localStorage)
========================= */
window.addEventListener('beforeunload', ()=> {
// entries are persisted to IndexedDB on each operation โ no need to write them here
saveJSON(KEYS.TYPES, types);
saveJSON(KEYS.SETTINGS, settings);
saveJSON(KEYS.REMINDER, reminder);
});
/* =========================
Other UI wiring for convenience
========================= */
document.getElementById('btnExport2').addEventListener('click', openExport);
document.getElementById('btnExport').addEventListener('click', openExport);
document.getElementById('btnQuickLog').addEventListener('click', openQuickLog);
/* === Handle imported file from sidebar input === */
document.getElementById('importFile').addEventListener('change', handleImportFile);
:root {
--bg: #1a0b1f; /* deep purple-black */
--card: #2a0d2e; /* darker pinkish-purple */
--muted: #c084fc; /* muted lilac for secondary text */
--text: #fdf2f8; /* light pink-white for primary text */
--accent: #ec4899; /* hot pink */
--accent-2: #8b5cf6; /* vibrant purple */
--danger: #ef4444; /* strong red for errors/danger */
--glass: rgba(255, 0, 128, 0.15); /* pink glow */
--glass-2: rgba(139, 92, 246, 0.15); /* purple glow */
--shadow: 0 8px 24px rgba(236, 72, 153, 0.3); /* pink shadow */
--radius: 12px;
--grid-gap: 18px;
--card-padding: 14px;
--compact-gap: 8px;
--ui-gap: 10px;
--small-radius: 8px;
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, "Roboto Mono", "Courier New", monospace;
--ui-font: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial;
}
html,body {
height: 100%;
margin: 0;
font-family: var(--ui-font);
background: linear-gradient(180deg, var(--bg) 0%, #2a0d2e 100%);
color: var(--text);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* prevent accidental page-level horizontal scrolling caused by tiny rounding differences */
html, body {
overflow-x: hidden;
}
.app {
max-width: 1100px;
margin: 28px auto;
padding: 18px;
display: grid;
grid-template-columns: 360px 1fr;
gap: var(--grid-gap);
}
.compact .app { gap: var(--compact-gap); }
.compact .card { padding: 8px; border-radius: 8px; }
header {
grid-column: 1/-1;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 6px;
}
h1 {
font-size: 18px;
margin: 0;
letter-spacing: -0.2px;
}
.sub {
color: var(--muted);
font-size: 13px;
}
.card {
background: var(--card);
box-shadow: var(--shadow);
border-radius: var(--radius);
padding: var(--card-padding);
}
.controls {
display: flex;
flex-direction: column;
gap: 12px;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 8px 12px;
border-radius: 10px;
border: none;
cursor: pointer;
font-weight: 600;
background: transparent;
transition: transform .08s ease, box-shadow .12s ease;
white-space: nowrap; /* prevent text inside buttons from wrapping */
}
.btn:active { transform: translateY(1px); }
.btn-primary {
background: linear-gradient(90deg, var(--accent), var(--accent-2));
color: white;
box-shadow: 0 6px 14px rgba(236, 72, 153, 0.25);
}
.btn-secondary {
background: transparent;
border: 1px solid var(--glass-2);
color: var(--text);
}
.btn-ghost {
background: transparent;
color: var(--text);
opacity: 0.95;
}
.btn-large { padding: 12px 16px; font-size: 15px; border-radius: 12px; }
.row { display: flex; gap: 10px; align-items: center; }
.col { display: flex; flex-direction: column; gap: 8px; }
label { font-size: 13px; color: var(--muted); display: block; }
input[type="text"], input[type="time"], input[type="number"], select, textarea {
width: 100%;
padding: 8px 10px;
border-radius: 8px;
border: 1px solid var(--glass-2);
background: transparent;
color: var(--text);
box-sizing: border-box;
font-size: 14px;
}
textarea { min-height: 80px; resize: vertical; padding-top: 10px; }
.right {
display: flex;
flex-direction: column;
gap: var(--grid-gap);
}
.chart-wrap {
display: grid;
grid-template-columns: 1fr 160px;
gap: 12px;
align-items: center;
}
canvas {
width: 100%; height: 140px; display: block;
border-radius: 8px;
background: linear-gradient(180deg, rgba(139,92,246,0.08), transparent);
}
.overview {
padding: 12px;
border-radius: 10px;
background: linear-gradient(180deg, rgba(236,72,153,0.08), transparent);
min-height: 80px;
}
.muted { color: var(--muted); font-size: 13px; }
.table-wrap {
margin-top:10px;
overflow:auto; /* only scroll when strictly necessary */
-webkit-overflow-scrolling: touch;
}
/* Keep table flexible, allow browser to size columns sensibly */
table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
table-layout: auto; /* changed from fixed so actions column isn't squeezed */
word-break: break-word;
}
th,td {
text-align: left;
padding: 10px 8px;
border-bottom: 1px solid var(--glass-2);
vertical-align: top;
overflow-wrap: anywhere;
}
th {
font-weight: 700;
color: var(--muted);
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.6px;
}
tr:hover td { background: linear-gradient(90deg, rgba(236,72,153,0.05), transparent); }
td.notes-cell {
white-space: pre-wrap;
word-break: break-word;
}
.pill {
display: inline-block;
padding: 6px 8px;
border-radius: 999px;
background: var(--glass);
font-weight: 600;
font-size: 13px;
}
.backdrop {
position: fixed;
inset: 0;
background: rgba(10, 0, 20, 0.75);
display: none;
align-items: center;
justify-content: center;
z-index: 9998;
padding: 20px;
}
.backdrop.show { display: flex; }
.modal {
width: min(720px, 98%);
max-height: 90vh;
overflow: auto;
border-radius: 12px;
}
.modal .card { padding: 18px; }
.emoji-grid { display: grid; grid-template-columns: repeat(8,1fr); gap: 8px; }
.emoji-btn { font-size: 18px; padding: 8px; border-radius: 8px; background: var(--glass); border: none; cursor: pointer; }
.small { font-size: 13px; color: var(--muted); }
/* modal actions (kept intentionally separate from table action cell) */
.actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 12px; }
/* type list */
.type-item { display: flex; gap: 8px; align-items: center; padding: 8px; border-radius: 8px; background: var(--glass); }
.type-list { display: flex; flex-direction: column; gap: 8px; }
.hidden { display: none !important; }
.controls .card { display: flex; flex-direction: column; gap: 12px; }
.flex { display: flex; gap: 8px; align-items: center; }
.spacer { flex: 1; }
.danger { color: var(--danger); font-weight: 700; }
/* ===========================================
ACTIONS CELL: keep buttons horizontally aligned
=========================================== */
/* A dedicated class applied to the <td> that contains action buttons.
Keeps buttons on a single row and prevents them from expanding to full width. */
.actions-cell {
display: inline-flex;
align-items: center;
justify-content: flex-end; /* keep to the right of the cell */
gap: 8px;
white-space: nowrap; /* prevent internal text wrapping */
flex-wrap: nowrap; /* prevent button wrapping to a new line */
padding: 8px; /* consistent padding with other cells */
box-sizing: border-box;
}
/* Prevent buttons inside the actions cell from stretching */
.actions-cell .btn {
flex: 0 0 auto; /* don't grow or shrink; keep intrinsic size */
padding: 6px 8px;
min-width: 40px; /* keeps buttons comfortably tappable on touch screens */
font-size: 13px;
}
/* Apply a modest fixed width to the Actions column so two buttons usually fit.
Adjust the width to taste (increase if your labels are longer). */
.actions-col {
width: 160px;
min-width: 120px;
max-width: 240px;
vertical-align: middle;
overflow: visible;
}
/* Make sure header cell follows same sizing */
th.actions-col { width: 160px; min-width: 120px; }
/* Responsive behavior:
- The table container already has overflow:auto โ if viewport is very narrow,
the table will scroll horizontally instead of wrapping the action buttons.
- On very narrow screens we tighten spacing so buttons remain side-by-side when possible.
*/
@media (max-width: 420px) {
/* Keep the action buttons side-by-side, but reduce spacing */
.actions-cell { gap: 6px; }
.actions-cell .btn { padding: 5px 6px; min-width: 36px; font-size: 13px; }
/* If you want icons only on tiny screens you can swap text to icons in JS/rendering */
}
/* responsive layout rules */
@media (max-width:960px) {
.app { grid-template-columns: 1fr; padding: 12px; }
.chart-wrap{ grid-template-columns: 1fr; }
}
footer {
grid-column: 1/-1;
text-align: center;
color: var(--muted);
margin-top: 10px;
font-size: 12px;
}
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Inspirational Quotes</title>
<!-- Main site CSS -->
<link rel="stylesheet" href="./styles.css" />
<!-- Minimal page-specific styles -->
<style>
/* Quotes container styling */
.quotes {
max-width: 800px;
margin: 40px auto;
padding: 20px;
border-radius: 10px;
background: var(--glass, rgba(255,255,255,0.1));
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
/* Individual quotes */
.quote {
font-style: italic;
margin: 12px 0;
border-left: 3px solid var(--accent, #ec4899);
padding-left: 12px;
}
#btnBack {
margin-top: 20px;
}
</style>
</head>
<body>
<div class="quotes card">
<h1>Daily Quote</h1>
<h2>w.i.p.</h2>
<p class="quote" id="quoteText"></p>
<button class="btn btn-secondary" id="btnBack">โ Back to Journal</button>
</div>
<script>
// Array of quotes
const quotes = [
"โThe best way to predict the future is to create it.โ โ Peter Drucker"
];
// Select a random quote
const randomQuote = quotes[Math.floor(Math.random() * quotes.length)];
// Display it on the page
document.getElementById('quoteText').textContent = randomQuote;
// Navigate back to the main journal page
document.getElementById('btnBack').addEventListener('click', () => {
location.hash = 'index';
});
</script>
</body>
</html>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Personal Journal โ User Guide</title>
<style>
body {margin:0;font-family:system-ui,sans-serif;background:#07121d;color:#e6eef8;line-height:1.5;}
.wrap{max-width:800px;margin:28px auto;padding:20px;}
h1,h2,h3{margin-top:1em;margin-bottom:.5em;}
.card{background:rgba(255,255,255,0.02);border-radius:10px;padding:16px;margin-top:16px;}
a.btn{padding:8px 10px;border-radius:8px;background:#7c3aed;color:white;text-decoration:none;font-weight:600;margin-right:6px;}
.muted{color:#94a3b8;font-size:0.9em;}
ul{margin:0 0 1em 20px;}
ol{margin:0 0 1em 20px;}
.warn{color:#fecaca;}
.ok{color:#bbf7d0;}
</style>
</head>
<body>
<div class="wrap">
<header>
<h1>Local Journal โ User Guide</h1>
<div class="muted">A practical walkthrough of your appโs main features.</div>
</header>
<div class="card">
<h2>Quick Start โ Common Tasks</h2>
<ol>
<li><strong>Add a new entry</strong>
<ul>
<li>Click <strong>Add Entry</strong>.</li>
<li>Fill in the date, type, mood, severity, duration, and notes.</li>
<li>Click <strong>Save</strong>.</li>
</ul>
</li>
<li><strong>Quick Log</strong>
<ul>
<li>Click <strong>Quick Log</strong> to add a minimal entry (time, type, optional mood).</li>
<li>Click <strong>Save</strong>.</li>
</ul>
</li>
<li><strong>View or delete entries</strong>
<ul>
<li>Click <strong>View</strong> or <strong>Delete</strong> on any entry.</li>
</ul>
</li>
<li><strong>Export / Import</strong>
<ul>
<li>Use Export to save your entries as CSV or JSON files.</li>
<li>Use Import to load entries from a JSON file.</li>
</ul>
</li>
</ol>
</div>
<div class="card">
<h2>UI Overview</h2>
<ul>
<li><strong>Add Entry</strong> โ Opens form for new journal entries.</li>
<li><strong>Quick Log</strong> โ Adds minimal entries quickly.</li>
<li><strong>Reminders</strong> โ Enable notifications at set times.</li>
<li><strong>Export / Import</strong> โ Save or load your journal entries.</li>
<li><strong>Charts</strong> โ Weekly summaries of your logged data.</li>
<li><strong>Entries Table</strong> โ Lists all saved entries with view/delete options.</li>
</ul>
</div>
<div class="card">
<h2>Tips & Troubleshooting</h2>
<ul>
<li>Ensure your browser allows local storage; otherwise, entries may not save.</li>
<li>Notifications only work when the tab is open and permission is granted.</li>
<li>If imported JSON fails, check it has the correct format with timestamps and type for each entry.</li>
<li>Severity will only display for entries marked as action types.</li>
<li><span class="warn">Sensitive content alert:</span> Some default categories include self-harm types. Make sure to use responsibly and seek professional help if needed.</li>
</ul>
</div>
<div class="card">
<h2>Data Safety</h2>
<p class="muted">All your data is stored locally in your browser. Export files to back up or transfer entries. Clearing the app or browser data will erase entries unless previously exported.</p>
</div>
<footer class="muted">
<p>Simple user guide for Local Journal. Focused on using the app safely and effectively. No developer knowledge required.</p>
</footer>
</div>
</body>
</html>