Files
2026-04-28 11:14:32 -04:00

1943 lines
67 KiB
HTML
Raw Permalink 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;
margin-left: auto;
align-self: flex-start;
}
/* Vertical divider between calendar and checklist */
.header-divider {
width: 1px;
background: #0f3460;
align-self: stretch;
margin: 0 24px;
}
.checklist-section {
display: flex;
flex-direction: column;
width: 276px;
}
.checklist-header {
font-size: 1.2rem;
font-weight: 500;
margin-bottom: 15px;
text-align: center;
min-height: 32px;
display: flex;
align-items: center;
justify-content: center;
}
.checklist-add {
display: flex;
gap: 6px;
margin-bottom: 8px;
}
.checklist-add input {
flex: 1;
background: #0f3460;
border: 1px solid #0f3460;
color: #eaeaea;
padding: 4px 8px;
border-radius: 6px;
font-size: 0.85rem;
outline: none;
}
.checklist-add input:focus {
border-color: #e94560;
}
.checklist-add button {
background: #0f3460;
border: none;
color: #eaeaea;
width: 32px;
height: 32px;
border-radius: 6px;
cursor: pointer;
font-size: 1.1rem;
transition: background 0.2s;
flex-shrink: 0;
}
.checklist-add button:hover {
background: #e94560;
}
.checklist-list {
list-style: none;
overflow-y: auto;
max-height: 156px;
display: flex;
flex-direction: column;
gap: 4px;
}
.checklist-list::-webkit-scrollbar {
width: 4px;
}
.checklist-list::-webkit-scrollbar-track {
background: transparent;
}
.checklist-list::-webkit-scrollbar-thumb {
background: #0f3460;
border-radius: 2px;
}
.checklist-item {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 6px;
border-radius: 6px;
background: #0f3460;
font-size: 0.85rem;
min-height: 36px;
}
.checklist-item input[type="checkbox"] {
accent-color: #e94560;
width: 15px;
height: 15px;
cursor: pointer;
flex-shrink: 0;
}
.checklist-item .task-text {
flex: 1;
word-break: break-word;
}
.checklist-item.done .task-text {
text-decoration: line-through;
color: #666;
}
.checklist-item .delete-task {
background: none;
border: none;
color: #555;
cursor: pointer;
font-size: 0.8rem;
padding: 0 2px;
line-height: 1;
flex-shrink: 0;
transition: color 0.2s;
}
.checklist-item .delete-task:hover {
color: #e94560;
}
.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 {
height: 500px;
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: 0;
border-radius: 8px;
border-left: 3px solid #e94560;
display: flex;
align-items: stretch;
overflow: hidden;
}
.entry-meta {
width: 160px;
flex-shrink: 0;
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 6px;
}
.entry-project-id {
font-size: 0.75rem;
color: #aac4e8;
font-weight: 500;
}
.entry-links {
margin-top: 4px;
font-size: 0.75rem;
color: #888;
}
.link-date {
cursor: pointer;
color: #e94560;
}
.link-date:hover {
text-decoration: underline;
}
.entry-divider {
width: 1px;
background: #0f3460;
flex-shrink: 0;
}
.entry-body {
flex: 1;
padding: 12px 16px;
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
}
.entry-text {
white-space: pre-wrap;
word-wrap: break-word;
flex: 1;
}
.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;
}
.project-id-input {
width: 100%;
background: #1a1a2e;
border: 1px solid #0f3460;
border-radius: 8px;
color: #eaeaea;
font-family: inherit;
font-size: 0.85rem;
padding: 8px 10px;
outline: none;
box-sizing: border-box;
margin: 4px 0;
}
.project-id-input:focus {
border-color: #e94560;
}
.project-id-input::placeholder {
color: #555;
}
.link-date-input {
width: 100%;
background: #1a1a2e;
border: 1px solid #0f3460;
border-radius: 8px;
color: #eaeaea;
font-family: inherit;
font-size: 0.85rem;
padding: 8px 10px;
outline: none;
box-sizing: border-box;
margin: 4px 0;
color-scheme: dark;
}
.link-date-input:focus {
border-color: #e94560;
}
/* ── 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;
}
.project-group {
margin-bottom: 18px;
}
.project-group-header {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 12px;
background: #0f3460;
border-radius: 6px;
cursor: pointer;
user-select: none;
margin-bottom: 10px;
}
.project-group-header:hover {
background: #16457a;
}
.project-group-name {
font-size: 0.78rem;
font-weight: 700;
color: #e94560;
text-transform: uppercase;
letter-spacing: 0.07em;
flex: 1;
}
.project-group-count {
font-size: 0.72rem;
color: #888;
}
.project-group-chevron {
font-size: 0.7rem;
color: #888;
transition: transform 0.15s ease;
display: inline-block;
}
.project-group-header.collapsed .project-group-chevron {
transform: rotate(-90deg);
}
.project-group-header.collapsed + .project-group-entries {
display: none;
}
</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-divider"></div>
<div class="checklist-section">
<div class="checklist-header">Checklist</div>
<div class="checklist-add">
<input type="text" id="checklistInput" placeholder="Add a task..." />
<button id="checklistAddBtn">+</button>
</div>
<ul class="checklist-list" id="checklistList"></ul>
</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>
<input id="projectIdInput" class="project-id-input" type="text" placeholder="Project ID (example: client-001)" autocomplete="off">
<input id="linkDateInput" class="link-date-input" type="date" title="Link to another day" />
<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');
// --- Checklist ---
const CHECKLIST_KEY = 'journalChecklist';
function loadChecklist() {
try { return JSON.parse(localStorage.getItem(CHECKLIST_KEY)) || []; }
catch { return []; }
}
function saveChecklist(items) {
localStorage.setItem(CHECKLIST_KEY, JSON.stringify(items));
}
function renderChecklist() {
const items = loadChecklist();
const list = document.getElementById('checklistList');
list.innerHTML = '';
items.forEach((item, i) => {
const li = document.createElement('li');
li.className = 'checklist-item' + (item.done ? ' done' : '');
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.checked = item.done;
cb.addEventListener('change', () => {
const data = loadChecklist();
data[i].done = cb.checked;
saveChecklist(data);
renderChecklist();
});
const span = document.createElement('span');
span.className = 'task-text';
span.textContent = item.text;
const del = document.createElement('button');
del.className = 'delete-task';
del.textContent = '✕';
del.title = 'Remove';
del.addEventListener('click', () => {
const data = loadChecklist();
data.splice(i, 1);
saveChecklist(data);
renderChecklist();
});
li.appendChild(cb);
li.appendChild(span);
li.appendChild(del);
list.appendChild(li);
});
}
function addChecklistItem() {
const input = document.getElementById('checklistInput');
const text = input.value.trim();
if (!text) return;
const items = loadChecklist();
items.push({ text, done: false });
saveChecklist(items);
input.value = '';
renderChecklist();
}
document.getElementById('checklistAddBtn').addEventListener('click', addChecklistItem);
document.getElementById('checklistInput').addEventListener('keydown', e => {
if (e.key === 'Enter') addChecklistItem();
});
renderChecklist();
// --- End Checklist ---
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 = [], projectId = null, linkedDates = []) {
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,
projectId: projectId || null,
linkedDates: [...new Set(linkedDates)]
};
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;
}
function buildEntryHtml(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-meta">
<div class="entry-tags" data-id="${entry.id}"></div>
<div class="entry-date">${entry.mood ? `<span class="entry-mood">${entry.mood}</span>` : ''}${formatDate(date)}</div>
${entry.linkedDates?.length ? `<div class="entry-links">Linked: ${entry.linkedDates.map(d => {
const dt = new Date(d);
return `<span class="link-date" data-date="${d}">${dt.toLocaleDateString()}</span>`;
}).join(', ')}</div>` : ''}
</div>
<div class="entry-divider"></div>
<div class="entry-body">
<div class="entry-text">${escapeHtml(entry.text)}</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>
`;
}
// Group entries by project; entries with no project go into ''
const groups = {};
entries.forEach(entry => {
const key = entry.projectId?.trim() || '';
if (!groups[key]) groups[key] = [];
groups[key].push(entry);
});
const hasNamedProjects = Object.keys(groups).some(k => k !== '');
if (!hasNamedProjects) {
// No projects used — render flat as before
timeline.innerHTML = entries.map(buildEntryHtml).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 });
}
});
} else {
// Render grouped sections
timeline.innerHTML = '';
// Sort named-project groups by their most-recent entry (desc),
// mirroring the flat chronological order. "General" always trails.
const latestTs = key => Math.max(...groups[key].map(e => e.timestamp));
const sortedKeys = Object.keys(groups)
.filter(k => k !== '')
.sort((a, b) => latestTs(b) - latestTs(a));
if (groups['']) sortedKeys.push('');
sortedKeys.forEach(key => {
const groupEntries = groups[key];
const label = key || 'General';
const groupEl = document.createElement('div');
groupEl.className = 'project-group';
const header = document.createElement('div');
header.className = 'project-group-header';
header.innerHTML = `
<span class="project-group-name">${escapeHtml(label)}</span>
<span class="project-group-count">${groupEntries.length} ${groupEntries.length === 1 ? 'entry' : 'entries'}</span>
<span class="project-group-chevron">▾</span>
`;
header.addEventListener('click', () => header.classList.toggle('collapsed'));
const entriesEl = document.createElement('div');
entriesEl.className = 'project-group-entries';
entriesEl.innerHTML = groupEntries.map(buildEntryHtml).join('');
groupEntries.forEach(entry => {
const container = entriesEl.querySelector(`.entry-tags[data-id="${entry.id}"]`);
if (container && (entry.tags || []).length > 0) {
renderTagChips(container, entry.tags, { clickable: true, onTagClick: activateTagFilter });
}
});
groupEl.appendChild(header);
groupEl.appendChild(entriesEl);
timeline.appendChild(groupEl);
});
}
timelineContainer.scrollTop = 0;
// ── Linked dates ─────────────────────────────────────────
timeline.querySelectorAll('.link-date').forEach(el => {
el.addEventListener('click', () => {
const date = new Date(el.dataset.date);
selectedDate = new Date(date.getFullYear(), date.getMonth(), date.getDate());
currentYear = selectedDate.getFullYear();
currentMonth = selectedDate.getMonth();
renderCalendar();
renderTimeline();
updateSelectedDateDisplay();
});
});
// ── 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();
});
});
}
async function getKnownProjects() {
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readonly');
const store = tx.objectStore(STORE_NAME);
const req = store.getAll();
req.onsuccess = () => {
const projects = [...new Set(
req.result
.map(e => e.projectId?.trim())
.filter(p => p)
)].sort((a, b) => a.localeCompare(b));
resolve(projects);
};
req.onerror = () => reject(req.error);
});
}
async function refreshProjectAutocomplete() {
const projects = await getKnownProjects();
let list = document.getElementById('projectIdList');
if (!list) {
list = document.createElement('datalist');
list.id = 'projectIdList';
document.body.appendChild(list);
document.getElementById('projectIdInput').setAttribute('list', 'projectIdList');
}
list.innerHTML = projects.map(p => `<option value="${escapeHtml(p)}"></option>`).join('');
}
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());
const projectId = document.getElementById('projectIdInput').value.trim();
const linkDateRaw = document.getElementById('linkDateInput').value;
if (linkDateRaw && isNaN(new Date(linkDateRaw))) return;
let linkedDates = [];
if (linkDateRaw) {
const [y, m, d] = linkDateRaw.split('-').map(Number);
linkedDates = [new Date(y, m - 1, d).toISOString()];
}
await saveEntry(text, entryDate, selectedMood, [...pendingTags], projectId || null, linkedDates);
entryInput.value = '';
updateWordCount();
selectedMood = null;
document.querySelectorAll('.mood-btn').forEach(b => b.classList.remove('selected'));
pendingTags = [];
document.getElementById('projectIdInput').value = '';
document.getElementById('linkDateInput').value = '';
const newEntryTagArea = document.getElementById('newEntryTagArea');
newEntryTagArea.innerHTML = '';
newEntryTagArea.appendChild(buildTagInput([], (tags) => { pendingTags = tags; }));
await renderCalendar();
await renderTimeline();
await renderStatsBar();
await refreshProjectAutocomplete();
} 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; }));
await refreshProjectAutocomplete();
}
init();
</script>
</body>
</html>