Files
JournalApp/Backup/journal.html
T
2026-04-28 11:14:32 -04:00

1478 lines
52 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Journal</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: #1a1a2e;
color: #eaeaea;
min-height: 100vh;
display: flex;
flex-direction: column;
}
header {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 20px 30px;
background: #16213e;
border-bottom: 1px solid #0f3460;
}
.calendar-section {
display: flex;
flex-direction: column;
align-items: center;
}
.month-nav {
display: flex;
align-items: center;
gap: 15px;
margin-bottom: 15px;
}
.month-nav h2 {
font-size: 1.2rem;
font-weight: 500;
min-width: 180px;
text-align: center;
}
.nav-btn {
background: #0f3460;
border: none;
color: #eaeaea;
width: 32px;
height: 32px;
border-radius: 6px;
cursor: pointer;
font-size: 1rem;
transition: background 0.2s;
}
.nav-btn:hover {
background: #e94560;
}
.today-btn {
background: none;
border: 1px solid #0f3460;
color: #888;
padding: 0 10px;
height: 32px;
border-radius: 6px;
cursor: pointer;
font-size: 0.75rem;
transition: border-color 0.2s, color 0.2s, background 0.2s;
white-space: nowrap;
}
.today-btn:hover {
border-color: #e94560;
color: #eaeaea;
background: rgba(233, 69, 96, 0.1);
}
.mini-calendar {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
font-size: 0.85rem;
}
.cal-day-header {
text-align: center;
font-weight: 600;
color: #888;
padding: 4px;
font-size: 0.75rem;
}
.cal-day {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
cursor: pointer;
transition: all 0.2s;
}
.cal-day:hover {
background: #0f3460;
}
.cal-day.empty {
cursor: default;
}
.cal-day.empty:hover {
background: transparent;
}
.cal-day.has-entry {
background: #e94560;
color: #fff;
flex-direction: column;
gap: 1px;
}
.cal-day-count {
font-size: 0.6rem;
font-weight: 700;
line-height: 1;
opacity: 0.88;
}
.cal-day.selected {
background: #0f3460;
border: 2px solid #e94560;
}
.cal-day.today {
border: 1px solid #e94560;
}
.header-actions {
display: flex;
gap: 10px;
}
.action-btn {
background: #e94560;
border: none;
color: #fff;
padding: 10px 20px;
border-radius: 6px;
cursor: pointer;
font-size: 0.9rem;
transition: background 0.2s;
}
.action-btn:hover {
background: #ff6b6b;
}
main {
flex: 1;
display: flex;
flex-direction: column;
padding: 30px;
overflow: hidden;
}
.timeline-container {
flex: 1;
overflow-y: auto;
padding: 20px 0;
scroll-behavior: smooth;
}
.timeline {
position: relative;
padding-left: 120px;
}
.timeline::before {
content: '';
position: absolute;
left: 35px;
top: 0;
bottom: 0;
width: 2px;
background: #0f3460;
}
.entry {
position: relative;
margin-bottom: 30px;
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.entry-dot {
position: absolute;
left: -90px;
top: 8px;
width: 12px;
height: 12px;
background: #e94560;
border-radius: 50%;
border: 3px solid #1a1a2e;
}
.entry-time {
position: absolute;
left: -105px;
top: 5px;
width: 90px;
text-align: right;
font-size: 0.85rem;
color: #888;
}
.entry-content {
background: #16213e;
padding: 15px 20px;
border-radius: 8px;
border-left: 3px solid #e94560;
}
.entry-text {
white-space: pre-wrap;
word-wrap: break-word;
}
.entry-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 8px;
}
.entry-date {
font-size: 0.75rem;
color: #666;
}
.entry-actions {
display: flex;
gap: 6px;
opacity: 0;
transition: opacity 0.15s;
}
.entry:hover .entry-actions {
opacity: 1;
}
.entry-btn {
background: none;
border: none;
color: #555;
font-size: 0.72rem;
cursor: pointer;
padding: 2px 8px;
border-radius: 4px;
transition: color 0.15s, background 0.15s;
}
.entry-btn:hover {
color: #eaeaea;
background: #0f3460;
}
.entry-btn.delete-btn:hover {
color: #e94560;
background: rgba(233, 69, 96, 0.12);
}
.entry-edit-textarea {
width: 100%;
background: #1a1a2e;
border: 1px solid #e94560;
border-radius: 6px;
padding: 8px 10px;
color: #eaeaea;
font-family: inherit;
font-size: 1rem;
resize: vertical;
min-height: 60px;
}
.entry-edit-textarea:focus {
outline: none;
}
.entry-time.editable {
cursor: pointer;
}
.entry-time.editable:hover {
color: #eaeaea;
}
.entry-time-input {
width: 90px;
background: #1a1a2e;
border: 1px solid #e94560;
border-radius: 4px;
padding: 2px 4px;
color: #eaeaea;
font-size: 0.78rem;
}
.entry-time-input:focus {
outline: none;
}
.empty-state {
text-align: center;
color: #666;
padding: 60px 20px;
}
.input-section {
background: #16213e;
padding: 20px;
border-radius: 12px;
margin-top: 20px;
}
.input-section textarea {
width: 100%;
min-height: 100px;
background: #1a1a2e;
border: 1px solid #0f3460;
border-radius: 8px;
padding: 15px;
color: #eaeaea;
font-family: inherit;
font-size: 1rem;
resize: vertical;
}
.input-section textarea:focus {
outline: none;
border-color: #e94560;
}
.input-section textarea::placeholder {
color: #666;
}
.word-count {
color: #888888;
font-size: 0.75rem;
}
.input-actions {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 15px;
}
.selected-date {
color: #888;
font-size: 0.9rem;
}
.submit-btn {
background: #e94560;
border: none;
color: #fff;
padding: 12px 30px;
border-radius: 6px;
cursor: pointer;
font-size: 1rem;
transition: background 0.2s;
}
.submit-btn:hover {
background: #ff6b6b;
}
.submit-btn:disabled {
background: #444;
cursor: not-allowed;
}
/* ── Stats Bar ─────────────────────────────────────────── */
.stats-bar {
display: flex;
align-items: center;
gap: 24px;
padding: 10px 30px;
background: #16213e;
border-bottom: 1px solid #0f3460;
font-size: 0.85rem;
flex-wrap: wrap;
}
.stat-item {
display: flex;
align-items: baseline;
gap: 6px;
}
.stat-value {
font-size: 1.1rem;
font-weight: 600;
color: #e94560;
}
.stat-label {
color: #888;
font-size: 0.78rem;
}
.stat-divider {
width: 1px;
height: 18px;
background: #0f3460;
flex-shrink: 0;
}
#statMoodSection {
display: flex;
align-items: center;
}
.stat-heatmap {
display: flex;
gap: 3px;
align-items: center;
margin-left: auto;
}
.heatmap-cell {
width: 11px;
height: 11px;
border-radius: 2px;
background: #0f3460;
flex-shrink: 0;
}
.heatmap-cell.level-1 { background: #5a1525; }
.heatmap-cell.level-2 { background: #8b2035; }
.heatmap-cell.level-3 { background: #c42d4a; }
.heatmap-cell.level-4 { background: #e94560; }
/* ── Mood Selector ──────────────────────────────────────── */
.mood-selector {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 12px;
}
.mood-label {
font-size: 0.78rem;
color: #888;
margin-right: 2px;
}
.mood-btn {
background: none;
border: 2px solid transparent;
border-radius: 8px;
font-size: 1.25rem;
cursor: pointer;
padding: 3px 7px;
line-height: 1;
opacity: 0.35;
transition: opacity 0.15s, border-color 0.15s, background 0.15s;
}
.mood-btn:hover {
opacity: 0.75;
background: #0f3460;
}
.mood-btn.selected {
opacity: 1;
border-color: #e94560;
background: rgba(233, 69, 96, 0.12);
}
.entry-mood {
margin-right: 4px;
font-size: 0.9rem;
}
/* ── Tag Chips (read-only, on cards) ────────────────────── */
.entry-tags {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin-top: 8px;
min-height: 0;
}
.tag-chip {
display: inline-flex;
align-items: center;
gap: 3px;
background: rgba(15, 52, 96, 0.8);
color: #aac4e8;
font-size: 0.72rem;
padding: 2px 8px;
border-radius: 20px;
border: 1px solid #0f3460;
line-height: 1.4;
white-space: nowrap;
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.tag-chip--clickable {
cursor: pointer;
}
.tag-chip--clickable:hover {
background: rgba(233, 69, 96, 0.18);
border-color: #e94560;
color: #e94560;
}
.tag-chip-remove {
background: none;
border: none;
color: inherit;
font-size: 0.85rem;
cursor: pointer;
padding: 0 0 0 2px;
line-height: 1;
opacity: 0.6;
}
.tag-chip-remove:hover {
opacity: 1;
color: #e94560;
}
/* ── Tag Input Widget (new entry + inline edit) ──────────── */
.tag-input-wrapper {
margin: 6px 0 4px;
}
.tag-chips-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 5px;
background: #1a1a2e;
border: 1px solid #0f3460;
border-radius: 8px;
padding: 6px 10px;
min-height: 38px;
transition: border-color 0.2s;
}
.tag-chips-row:focus-within {
border-color: #e94560;
}
.tag-text-input {
background: none;
border: none;
outline: none;
color: #eaeaea;
font-family: inherit;
font-size: 0.85rem;
min-width: 120px;
flex: 1;
}
.tag-text-input::placeholder {
color: #555;
}
/* ── Tag Filter Bar ──────────────────────────────────────── */
.tag-filter-bar {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 16px;
background: rgba(233, 69, 96, 0.08);
border: 1px solid rgba(233, 69, 96, 0.3);
border-radius: 8px;
margin-bottom: 16px;
font-size: 0.85rem;
}
.tag-filter-label {
color: #888;
}
.tag-filter-chip {
background: rgba(233, 69, 96, 0.18);
color: #e94560;
border: 1px solid #e94560;
border-radius: 20px;
padding: 2px 10px;
font-size: 0.8rem;
font-weight: 600;
}
.tag-filter-clear {
background: none;
border: 1px solid #444;
color: #888;
border-radius: 6px;
padding: 3px 10px;
font-size: 0.78rem;
cursor: pointer;
margin-left: auto;
transition: border-color 0.15s, color 0.15s;
}
.tag-filter-clear:hover {
border-color: #e94560;
color: #e94560;
}
/* ── Tag View: Date Group Headings ───────────────────────── */
.tag-view-group {
margin-bottom: 30px;
}
.tag-view-date-heading {
font-size: 0.8rem;
font-weight: 600;
color: #888;
text-transform: uppercase;
letter-spacing: 0.05em;
padding-bottom: 10px;
border-bottom: 1px solid #0f3460;
margin-bottom: 14px;
}
</style>
</head>
<body>
<header>
<div class="calendar-section">
<div class="month-nav">
<button class="nav-btn" id="prevMonth">&lt;</button>
<h2 id="currentMonth"></h2>
<button class="nav-btn" id="nextMonth">&gt;</button>
<button class="today-btn" id="todayBtn">Today</button>
</div>
<div class="mini-calendar" id="miniCalendar"></div>
</div>
<div class="header-actions">
<button class="action-btn" id="exportBtn">Export Markdown</button>
</div>
</header>
<div class="stats-bar" id="statsBar">
<div class="stat-item">
<span class="stat-value" id="statEntriesToday"></span>
<span class="stat-label">today</span>
</div>
<div class="stat-divider"></div>
<div class="stat-item">
<span class="stat-value" id="statStreak"></span>
<span class="stat-label">day streak</span>
</div>
<div class="stat-divider"></div>
<div class="stat-item">
<span class="stat-value" id="statPeakHour"></span>
<span class="stat-label">peak hour</span>
</div>
<div id="statMoodSection"></div>
<div class="stat-heatmap" id="statHeatmap"></div>
</div>
<main>
<div class="timeline-container" id="timelineContainer">
<div class="timeline" id="timeline"></div>
</div>
<div class="input-section">
<div class="mood-selector">
<span class="mood-label">Mood:</span>
<button class="mood-btn" data-mood="😊" title="Good">😊</button>
<button class="mood-btn" data-mood="😐" title="Neutral">😐</button>
<button class="mood-btn" data-mood="😤" title="Stressed">😤</button>
<button class="mood-btn" data-mood="😴" title="Tired">😴</button>
</div>
<div id="newEntryTagArea"></div>
<textarea id="entryInput" placeholder="Write your journal entry..."></textarea>
<div class="input-actions">
<span class="selected-date" id="selectedDateDisplay"></span>
<span class="word-count" id="wordCount">0 words</span>
<button class="submit-btn" id="submitBtn">Add Entry</button>
</div>
</div>
</main>
<script>
const DB_NAME = 'JournalDB';
const DB_VERSION = 1;
const STORE_NAME = 'entries';
let db = null;
let currentYear = new Date().getFullYear();
let currentMonth = new Date().getMonth();
let selectedDate = new Date();
let selectedMood = null;
let activeTagFilter = null;
let pendingTags = [];
const timelineContainer = document.getElementById('timelineContainer');
const timeline = document.getElementById('timeline');
const entryInput = document.getElementById('entryInput');
const submitBtn = document.getElementById('submitBtn');
const miniCalendar = document.getElementById('miniCalendar');
const currentMonthEl = document.getElementById('currentMonth');
const selectedDateDisplay = document.getElementById('selectedDateDisplay');
function initDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = () => reject(request.error);
request.onsuccess = () => {
db = request.result;
resolve(db);
};
request.onupgradeneeded = (event) => {
const database = event.target.result;
if (!database.objectStoreNames.contains(STORE_NAME)) {
const store = database.createObjectStore(STORE_NAME, { keyPath: 'id', autoIncrement: true });
store.createIndex('timestamp', 'timestamp', { unique: false });
}
};
});
}
function getAllEntries() {
return new Promise((resolve, reject) => {
const transaction = db.transaction([STORE_NAME], 'readonly');
const store = transaction.objectStore(STORE_NAME);
const request = store.getAll();
request.onsuccess = () => resolve(request.result || []);
request.onerror = () => reject(request.error);
});
}
function saveEntry(text, date, mood = null, tags = []) {
return new Promise((resolve, reject) => {
const transaction = db.transaction([STORE_NAME], 'readwrite');
const store = transaction.objectStore(STORE_NAME);
const entry = {
text: text,
date: date.toISOString(),
timestamp: date.getTime(),
mood: mood,
tags: tags
};
const request = store.add(entry);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
function deleteEntry(id) {
return new Promise((resolve, reject) => {
const tx = db.transaction([STORE_NAME], 'readwrite');
const store = tx.objectStore(STORE_NAME);
const req = store.delete(id);
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
});
}
function updateEntry(id, fields) {
return new Promise((resolve, reject) => {
const tx = db.transaction([STORE_NAME], 'readwrite');
const store = tx.objectStore(STORE_NAME);
const req = store.get(id);
req.onsuccess = () => {
const entry = req.result;
if (!entry) return reject(new Error('Entry not found'));
Object.assign(entry, fields);
const putReq = store.put(entry);
putReq.onsuccess = () => resolve();
putReq.onerror = () => reject(putReq.error);
};
req.onerror = () => reject(req.error);
});
}
async function getEntriesByDate(date) {
const allEntries = await getAllEntries();
const startOfDay = new Date(date);
startOfDay.setHours(0, 0, 0, 0);
const endOfDay = new Date(date);
endOfDay.setHours(23, 59, 59, 999);
return allEntries
.filter(entry => {
const entryDate = new Date(entry.date);
return entryDate >= startOfDay && entryDate <= endOfDay;
})
.sort((a, b) => b.timestamp - a.timestamp);
}
async function getDaysWithEntries(year, month) {
const allEntries = await getAllEntries();
const startOfMonth = new Date(year, month, 1);
const endOfMonth = new Date(year, month + 1, 0, 23, 59, 59, 999);
const dayCounts = new Map();
allEntries.forEach(entry => {
const entryDate = new Date(entry.date);
if (entryDate >= startOfMonth && entryDate <= endOfMonth) {
const day = entryDate.getDate();
dayCounts.set(day, (dayCounts.get(day) || 0) + 1);
}
});
return dayCounts;
}
async function getAllEntriesForMonth(year, month) {
const allEntries = await getAllEntries();
const startOfMonth = new Date(year, month, 1);
const endOfMonth = new Date(year, month + 1, 0, 23, 59, 59, 999);
return allEntries
.filter(entry => {
const entryDate = new Date(entry.date);
return entryDate >= startOfMonth && entryDate <= endOfMonth;
})
.sort((a, b) => a.timestamp - b.timestamp);
}
function formatTime(date) {
let hours = date.getHours();
const minutes = date.getMinutes().toString().padStart(2, '0');
const ampm = hours >= 12 ? 'PM' : 'AM';
hours = hours % 12;
hours = hours ? hours : 12;
return `${hours}:${minutes} ${ampm}`;
}
function formatDate(date) {
const options = { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' };
return date.toLocaleDateString('en-US', options);
}
function getMonthName(month) {
const months = ['January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'];
return months[month];
}
async function renderCalendar() {
currentMonthEl.textContent = `${getMonthName(currentMonth)} ${currentYear}`;
const daysWithEntries = await getDaysWithEntries(currentYear, currentMonth);
const today = new Date();
const firstDay = new Date(currentYear, currentMonth, 1);
const lastDay = new Date(currentYear, currentMonth + 1, 0);
const startPadding = firstDay.getDay();
let html = '<div class="cal-day-header">Sun</div>';
html += '<div class="cal-day-header">Mon</div>';
html += '<div class="cal-day-header">Tue</div>';
html += '<div class="cal-day-header">Wed</div>';
html += '<div class="cal-day-header">Thu</div>';
html += '<div class="cal-day-header">Fri</div>';
html += '<div class="cal-day-header">Sat</div>';
for (let i = 0; i < startPadding; i++) {
html += '<div class="cal-day empty"></div>';
}
for (let day = 1; day <= lastDay.getDate(); day++) {
const isToday = today.getDate() === day &&
today.getMonth() === currentMonth &&
today.getFullYear() === currentYear;
const isSelected = selectedDate.getDate() === day &&
selectedDate.getMonth() === currentMonth &&
selectedDate.getFullYear() === currentYear;
const count = daysWithEntries.get(day) || 0;
const hasEntry = count > 0;
let classes = 'cal-day';
if (hasEntry) classes += ' has-entry';
if (isSelected) classes += ' selected';
if (isToday) classes += ' today';
html += `<div class="${classes}" data-day="${day}">${hasEntry ? `<span>${day}</span><span class="cal-day-count">${count}</span>` : day}</div>`;
}
miniCalendar.innerHTML = html;
document.querySelectorAll('.cal-day:not(.empty)').forEach(el => {
el.addEventListener('click', () => {
const day = parseInt(el.dataset.day);
selectedDate = new Date(currentYear, currentMonth, day);
renderCalendar();
renderTimeline();
updateSelectedDateDisplay();
});
});
}
async function renderTimeline() {
activeTagFilter = null;
const bar = document.getElementById('tagFilterBar');
if (bar) bar.style.display = 'none';
const entries = await getEntriesByDate(selectedDate);
if (entries.length === 0) {
timeline.innerHTML = `
<div class="empty-state">
<p>No entries for this day</p>
<p style="font-size: 0.9rem; margin-top: 10px;">Start writing below!</p>
</div>
`;
return;
}
timeline.innerHTML = entries.map(entry => {
const date = new Date(entry.date);
return `
<div class="entry" data-id="${entry.id}" data-date="${entry.date}">
<div class="entry-dot"></div>
<div class="entry-time editable" data-id="${entry.id}" title="Click to edit time">${formatTime(date)}</div>
<div class="entry-content">
<div class="entry-text">${escapeHtml(entry.text)}</div>
<div class="entry-tags" data-id="${entry.id}"></div>
<div class="entry-footer">
<div class="entry-date">${entry.mood ? `<span class="entry-mood">${entry.mood}</span>` : ''}${formatDate(date)}</div>
<div class="entry-actions">
<button class="entry-btn edit-btn" data-id="${entry.id}">edit</button>
<button class="entry-btn tags-btn" data-id="${entry.id}">tags</button>
<button class="entry-btn delete-btn" data-id="${entry.id}">delete</button>
</div>
</div>
</div>
</div>
`;
}).join('');
entries.forEach(entry => {
const container = timeline.querySelector(`.entry-tags[data-id="${entry.id}"]`);
if (container && (entry.tags || []).length > 0) {
renderTagChips(container, entry.tags, { clickable: true, onTagClick: activateTagFilter });
}
});
timelineContainer.scrollTop = 0;
// ── Delete ───────────────────────────────────────────────
timeline.querySelectorAll('.delete-btn').forEach(btn => {
btn.addEventListener('click', async () => {
if (!confirm('Delete this entry? This cannot be undone.')) return;
await deleteEntry(parseInt(btn.dataset.id));
await Promise.all([renderCalendar(), renderTimeline(), renderStatsBar()]);
});
});
// ── Edit text ────────────────────────────────────────────
timeline.querySelectorAll('.edit-btn').forEach(btn => {
btn.addEventListener('click', () => {
const entryEl = btn.closest('.entry');
const textEl = entryEl.querySelector('.entry-text');
if (entryEl.querySelector('.entry-edit-textarea')) return;
const currentText = textEl.textContent;
const ta = document.createElement('textarea');
ta.className = 'entry-edit-textarea';
ta.value = currentText;
textEl.replaceWith(ta);
ta.focus();
ta.setSelectionRange(ta.value.length, ta.value.length);
let saved = false;
const saveEdit = async () => {
if (saved) return;
saved = true;
const newText = ta.value.trim();
if (newText && newText !== currentText) {
await updateEntry(parseInt(btn.dataset.id), { text: newText });
}
await renderTimeline();
await renderStatsBar();
};
ta.addEventListener('blur', saveEdit);
ta.addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); saveEdit(); }
if (e.key === 'Escape') { saved = true; renderTimeline(); }
});
});
});
// ── Edit time ────────────────────────────────────────────
timeline.querySelectorAll('.entry-time.editable').forEach(timeEl => {
timeEl.addEventListener('click', () => {
if (timeEl.querySelector('input')) return;
const entryEl = timeEl.closest('.entry');
const id = parseInt(timeEl.dataset.id);
const storedDate = new Date(entryEl.dataset.date);
const hh = storedDate.getHours().toString().padStart(2, '0');
const mm = storedDate.getMinutes().toString().padStart(2, '0');
const input = document.createElement('input');
input.type = 'time';
input.className = 'entry-time-input';
input.value = `${hh}:${mm}`;
timeEl.textContent = '';
timeEl.appendChild(input);
input.focus();
let saved = false;
const saveTime = async () => {
if (saved) return;
saved = true;
const [newH, newM] = input.value.split(':').map(Number);
if (!isNaN(newH) && !isNaN(newM)) {
const newDate = new Date(storedDate);
newDate.setHours(newH, newM, 0, 0);
await updateEntry(id, {
date: newDate.toISOString(),
timestamp: newDate.getTime()
});
}
await renderTimeline();
};
input.addEventListener('blur', saveTime);
input.addEventListener('keydown', e => {
if (e.key === 'Enter') { e.preventDefault(); saveTime(); }
if (e.key === 'Escape') { saved = true; renderTimeline(); }
});
});
});
// ── Edit tags ────────────────────────────────────────────
timeline.querySelectorAll('.tags-btn').forEach(btn => {
btn.addEventListener('click', () => {
const entryEl = btn.closest('.entry');
if (entryEl.querySelector('.tag-input-wrapper')) return;
const tagsContainer = entryEl.querySelector('.entry-tags');
const entry = entries.find(e => e.id === parseInt(btn.dataset.id));
if (!entry) return;
tagsContainer.innerHTML = '';
const widget = buildTagInput(entry.tags || [], async (newTags) => {
await updateEntry(entry.id, { tags: newTags });
entry.tags = newTags;
});
tagsContainer.appendChild(widget);
widget.addEventListener('focusout', async (e) => {
if (widget.contains(e.relatedTarget)) return;
await new Promise(r => setTimeout(r, 150));
await renderTimeline();
});
widget.querySelector('.tag-text-input').focus();
});
});
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function normalizeTag(raw) {
return raw.trim().replace(/^#+/, '').toLowerCase()
.replace(/\s+/g, '-').replace(/[^a-z0-9\-]/g, '');
}
function renderTagChips(container, tags, opts = {}) {
container.innerHTML = '';
(tags || []).forEach(tag => {
const chip = document.createElement('span');
chip.className = 'tag-chip' + (opts.clickable ? ' tag-chip--clickable' : '');
chip.textContent = '#' + tag;
if (opts.clickable) {
chip.addEventListener('click', (e) => {
e.stopPropagation();
if (opts.onTagClick) opts.onTagClick(tag);
});
}
if (opts.removable) {
const x = document.createElement('button');
x.className = 'tag-chip-remove';
x.textContent = '×';
x.setAttribute('aria-label', 'Remove tag ' + tag);
x.addEventListener('click', (e) => {
e.stopPropagation();
if (opts.onRemove) opts.onRemove(tag);
});
chip.appendChild(x);
}
container.appendChild(chip);
});
}
function buildTagInput(initialTags, onChange) {
const wrapper = document.createElement('div');
wrapper.className = 'tag-input-wrapper';
const chipsRow = document.createElement('div');
chipsRow.className = 'tag-chips-row';
const textInput = document.createElement('input');
textInput.type = 'text';
textInput.className = 'tag-text-input';
textInput.placeholder = 'Add tags…';
textInput.setAttribute('aria-label', 'Tag input');
let currentTags = [...(initialTags || [])];
function refreshChips() {
chipsRow.innerHTML = '';
renderTagChips(chipsRow, currentTags, {
removable: true,
onRemove: (tag) => {
currentTags = currentTags.filter(t => t !== tag);
refreshChips();
onChange(currentTags);
}
});
chipsRow.appendChild(textInput);
}
function commitInput() {
const parts = textInput.value.split(',').map(normalizeTag).filter(t => t.length > 0);
parts.forEach(tag => { if (!currentTags.includes(tag)) currentTags.push(tag); });
textInput.value = '';
refreshChips();
onChange(currentTags);
}
textInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ',') { e.preventDefault(); commitInput(); }
if (e.key === 'Backspace' && textInput.value === '' && currentTags.length > 0) {
currentTags = currentTags.slice(0, -1);
refreshChips();
onChange(currentTags);
}
});
textInput.addEventListener('blur', () => { if (textInput.value.trim()) commitInput(); });
refreshChips();
wrapper.appendChild(chipsRow);
return wrapper;
}
function updateSelectedDateDisplay() {
selectedDateDisplay.textContent = formatDate(selectedDate);
}
async function exportMarkdown() {
const entries = await getAllEntriesForMonth(currentYear, currentMonth);
if (entries.length === 0) {
alert('No entries to export for this month.');
return;
}
entries.sort((a, b) => a.timestamp - b.timestamp);
let md = `# Journal - ${getMonthName(currentMonth)} ${currentYear}\n\n`;
let currentDay = null;
entries.forEach(entry => {
const date = new Date(entry.date);
const dayKey = date.toDateString();
if (dayKey !== currentDay) {
currentDay = dayKey;
md += `## ${formatDate(date)}\n\n`;
}
const tagStr = (entry.tags || []).map(t => '#' + t).join(' ');
md += `- **${formatTime(date)}**${entry.mood ? ` ${entry.mood}` : ''} ${entry.text}${tagStr ? ' ' + tagStr : ''}\n`;
});
const blob = new Blob([md], { type: 'text/markdown' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `journal-${currentYear}-${(currentMonth + 1).toString().padStart(2, '0')}.md`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
document.getElementById('prevMonth').addEventListener('click', () => {
currentMonth--;
if (currentMonth < 0) {
currentMonth = 11;
currentYear--;
}
selectedDate = new Date(currentYear, currentMonth, Math.min(selectedDate.getDate(), new Date(currentYear, currentMonth + 1, 0).getDate()));
renderCalendar();
renderTimeline();
updateSelectedDateDisplay();
});
document.getElementById('nextMonth').addEventListener('click', () => {
currentMonth++;
if (currentMonth > 11) {
currentMonth = 0;
currentYear++;
}
selectedDate = new Date(currentYear, currentMonth, Math.min(selectedDate.getDate(), new Date(currentYear, currentMonth + 1, 0).getDate()));
renderCalendar();
renderTimeline();
updateSelectedDateDisplay();
});
submitBtn.addEventListener('click', async () => {
const text = entryInput.value.trim();
if (!text) return;
submitBtn.disabled = true;
submitBtn.textContent = 'Saving...';
try {
const entryDate = new Date(selectedDate);
entryDate.setHours(new Date().getHours(), new Date().getMinutes(), new Date().getSeconds());
await saveEntry(text, entryDate, selectedMood, [...pendingTags]);
entryInput.value = '';
updateWordCount();
selectedMood = null;
document.querySelectorAll('.mood-btn').forEach(b => b.classList.remove('selected'));
pendingTags = [];
const newEntryTagArea = document.getElementById('newEntryTagArea');
newEntryTagArea.innerHTML = '';
newEntryTagArea.appendChild(buildTagInput([], (tags) => { pendingTags = tags; }));
await renderCalendar();
await renderTimeline();
await renderStatsBar();
} catch (error) {
console.error('Error saving entry:', error);
alert('Failed to save entry. Please try again.');
} finally {
submitBtn.disabled = false;
submitBtn.textContent = 'Add Entry';
}
});
entryInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && e.ctrlKey) {
submitBtn.click();
}
});
document.getElementById('exportBtn').addEventListener('click', exportMarkdown);
document.getElementById('todayBtn').addEventListener('click', () => {
const now = new Date();
currentYear = now.getFullYear();
currentMonth = now.getMonth();
selectedDate = new Date(now.getFullYear(), now.getMonth(), now.getDate());
renderCalendar();
renderTimeline();
updateSelectedDateDisplay();
});
document.querySelectorAll('.mood-btn').forEach(btn => {
btn.addEventListener('click', () => {
const mood = btn.dataset.mood;
if (selectedMood === mood) {
selectedMood = null;
btn.classList.remove('selected');
} else {
selectedMood = mood;
document.querySelectorAll('.mood-btn').forEach(b => b.classList.remove('selected'));
btn.classList.add('selected');
}
});
});
const wordCountEl = document.getElementById('wordCount');
function updateWordCount() {
const text = entryInput.value.trim();
const count = text === '' ? 0 : text.split(/\s+/).length;
wordCountEl.textContent = count === 1 ? '1 word' : `${count} words`;
}
entryInput.addEventListener('input', updateWordCount);
async function renderStatsBar() {
const allEntries = await getAllEntries();
const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
// Build a map: midnight-timestamp → entry count
const dayCounts = {};
allEntries.forEach(entry => {
const d = new Date(entry.date);
const key = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
dayCounts[key] = (dayCounts[key] || 0) + 1;
});
// Entries today
document.getElementById('statEntriesToday').textContent = dayCounts[todayStart.getTime()] || 0;
// Current streak — walk backwards from today
let streak = 0;
const check = new Date(todayStart);
if (!dayCounts[check.getTime()]) check.setDate(check.getDate() - 1);
while (dayCounts[check.getTime()]) {
streak++;
check.setDate(check.getDate() - 1);
}
document.getElementById('statStreak').textContent = streak;
// Peak hour
const hourCounts = new Array(24).fill(0);
allEntries.forEach(e => hourCounts[new Date(e.date).getHours()]++);
const peakHour = hourCounts.indexOf(Math.max(...hourCounts));
const peakEl = document.getElementById('statPeakHour');
if (allEntries.length > 0) {
const ampm = peakHour >= 12 ? 'PM' : 'AM';
const h = peakHour % 12 || 12;
peakEl.textContent = `${h} ${ampm}`;
} else {
peakEl.textContent = '—';
}
// 30-day heatmap
const maxCount = Math.max(1, ...Object.values(dayCounts).concat([0]));
let heatHtml = '';
for (let i = 29; i >= 0; i--) {
const d = new Date(todayStart);
d.setDate(d.getDate() - i);
const count = dayCounts[d.getTime()] || 0;
const level = count === 0 ? 0 : Math.min(4, Math.ceil((count / maxCount) * 4));
const label = d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
heatHtml += `<div class="heatmap-cell level-${level}" title="${label}: ${count} entr${count === 1 ? 'y' : 'ies'}"></div>`;
}
document.getElementById('statHeatmap').innerHTML = heatHtml;
// Mood tally for today
const todayMoods = allEntries.filter(e => {
const d = new Date(e.date);
return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime() === todayStart.getTime() && e.mood;
}).map(e => e.mood);
const moodSection = document.getElementById('statMoodSection');
if (todayMoods.length > 0) {
const moodCounts = {};
todayMoods.forEach(m => { moodCounts[m] = (moodCounts[m] || 0) + 1; });
const moodHtml = Object.entries(moodCounts)
.map(([emoji, count]) => count > 1 ? `${emoji}×${count}` : emoji)
.join(' ');
moodSection.innerHTML = `<div class="stat-divider"></div><div class="stat-item"><span class="stat-value" style="font-size:1rem;letter-spacing:2px;">${moodHtml}</span><span class="stat-label">mood today</span></div>`;
} else {
moodSection.innerHTML = '';
}
}
function showTagFilterBar(tag) {
let bar = document.getElementById('tagFilterBar');
if (!bar) {
bar = document.createElement('div');
bar.id = 'tagFilterBar';
bar.className = 'tag-filter-bar';
const main = document.querySelector('main');
main.insertBefore(bar, main.firstChild);
}
bar.innerHTML = `
<span class="tag-filter-label">Filtered by:</span>
<span class="tag-filter-chip">#${tag}</span>
<button class="tag-filter-clear" id="tagFilterClear" aria-label="Clear filter">✕ Clear</button>
`;
document.getElementById('tagFilterClear').addEventListener('click', clearTagFilter);
bar.style.display = 'flex';
}
function clearTagFilter() {
activeTagFilter = null;
const bar = document.getElementById('tagFilterBar');
if (bar) bar.style.display = 'none';
renderTimeline();
}
async function activateTagFilter(tag) {
activeTagFilter = tag;
showTagFilterBar(tag);
const allEntries = await getAllEntries();
const matched = allEntries
.filter(e => (e.tags || []).includes(tag))
.sort((a, b) => b.timestamp - a.timestamp);
if (matched.length === 0) {
timeline.innerHTML = `<div class="empty-state"><p>No entries tagged <strong>#${tag}</strong></p></div>`;
return;
}
const groups = {};
matched.forEach(entry => {
const key = new Date(entry.date).toISOString().slice(0, 10);
if (!groups[key]) groups[key] = [];
groups[key].push(entry);
});
timeline.innerHTML = '';
Object.keys(groups).sort((a, b) => b.localeCompare(a)).forEach(dateKey => {
const groupEntries = groups[dateKey];
const dateLabel = formatDate(new Date(dateKey + 'T12:00:00'));
const groupEl = document.createElement('div');
groupEl.className = 'tag-view-group';
groupEl.innerHTML = `<div class="tag-view-date-heading">${dateLabel}</div>`;
groupEntries.forEach(entry => {
const date = new Date(entry.date);
const entryEl = document.createElement('div');
entryEl.className = 'entry';
entryEl.innerHTML = `
<div class="entry-dot"></div>
<div class="entry-time">${formatTime(date)}</div>
<div class="entry-content">
<div class="entry-text">${escapeHtml(entry.text)}</div>
<div class="entry-tags" data-id="${entry.id}"></div>
<div class="entry-footer">
<div class="entry-date">${entry.mood ? `<span class="entry-mood">${entry.mood}</span>` : ''}${formatDate(date)}</div>
</div>
</div>
`;
groupEl.appendChild(entryEl);
const tagsContainer = entryEl.querySelector(`.entry-tags[data-id="${entry.id}"]`);
renderTagChips(tagsContainer, entry.tags || [], {
clickable: true,
onTagClick: activateTagFilter
});
});
timeline.appendChild(groupEl);
});
timelineContainer.scrollTop = 0;
}
async function init() {
await initDB();
selectedDate = new Date(currentYear, currentMonth, selectedDate.getDate());
await renderCalendar();
await renderTimeline();
await renderStatsBar();
updateSelectedDateDisplay();
const newEntryTagArea = document.getElementById('newEntryTagArea');
newEntryTagArea.appendChild(buildTagInput([], (tags) => { pendingTags = tags; }));
}
init();
</script>
</body>
</html>