5d35dcfe03
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1478 lines
52 KiB
HTML
1478 lines
52 KiB
HTML
<!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"><</button>
|
||
<h2 id="currentMonth"></h2>
|
||
<button class="nav-btn" id="nextMonth">></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>
|