Files
JournalApp/JournalAppPrototype/journal.html
T
JasonBrock 72dc0a8d9a Add delete & edit for project panel entries
- deleteProjectEntry(id) and updateProjectEntry(id, text) DB helpers on project_entries store
- Edit toggle button beside Add Entry; switches to Done when active, resets on panel open/close
- In edit mode, panel-sourced entries show ✎ (inline edit) and × (delete) action buttons
- Delete re-renders timeline and updates breakout card count
- Inline edit saves on Enter/blur, cancels on Escape

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 13:32:08 -04:00

3078 lines
113 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>
:root {
--bg-primary: #1a1a2e;
--bg-secondary: #16213e;
--bg-accent: #0f3460;
--accent: #e94560;
--accent-hover: #ff6b6b;
--text-primary: #eaeaea;
--text-secondary: #888888;
--text-muted: #666666;
--disabled: #444444;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
display: flex;
flex-direction: column;
}
body::before {
content: '';
position: fixed;
inset: 0;
pointer-events: none;
z-index: 9999;
background: repeating-linear-gradient(
0deg,
transparent,
transparent 2px,
rgba(0,0,0,0.03) 2px,
rgba(0,0,0,0.03) 4px
);
}
/* ── Toast notifications ─────────────────────────────────── */
.toast {
position: fixed;
bottom: 24px;
right: 24px;
background: var(--bg-secondary);
border: 1px solid var(--accent);
box-shadow: 0 0 12px rgba(233,69,96,0.25);
color: var(--text-primary);
padding: 10px 18px;
border-radius: 8px;
font-size: 0.85rem;
z-index: 10000;
animation: toastIn 0.2s ease;
}
@keyframes toastIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
header {
display: flex;
justify-content: space-between;
align-items: stretch;
padding: 20px 30px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--bg-accent);
}
.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: var(--bg-accent);
border: none;
color: var(--text-primary);
width: 32px;
height: 32px;
border-radius: 6px;
cursor: pointer;
font-size: 1rem;
transition: background 0.2s;
}
.nav-btn:hover {
background: var(--accent);
}
.today-btn {
background: none;
border: 1px solid var(--bg-accent);
color: var(--text-secondary);
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: var(--accent);
color: var(--text-primary);
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: var(--text-secondary);
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: var(--bg-accent);
}
.cal-day.empty {
cursor: default;
}
.cal-day.empty:hover {
background: transparent;
}
.cal-day.has-entry {
background: var(--accent);
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: var(--bg-accent);
border: 2px solid var(--accent);
}
.cal-day.today {
border: 1px solid var(--accent);
}
.header-actions {
display: flex;
gap: 10px;
margin-left: auto;
align-self: flex-start;
}
/* Vertical divider between calendar and checklist */
.header-divider {
width: 1px;
background: var(--bg-accent);
align-self: stretch;
margin: 0 24px;
}
.checklist-section {
display: flex;
flex-direction: column;
width: 276px;
flex-shrink: 0;
}
.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: var(--bg-accent);
border: 1px solid var(--bg-accent);
color: var(--text-primary);
padding: 4px 8px;
border-radius: 6px;
font-size: 0.85rem;
outline: none;
}
.checklist-add input:focus {
border-color: var(--accent);
}
.checklist-add button {
background: var(--bg-accent);
border: none;
color: var(--text-primary);
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: var(--accent);
}
.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: var(--bg-accent);
border-radius: 2px;
}
.checklist-item {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 6px;
border-radius: 6px;
background: var(--bg-accent);
font-size: 0.85rem;
min-height: 36px;
}
.checklist-item input[type="checkbox"] {
accent-color: var(--accent);
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: var(--text-muted);
}
.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: var(--accent);
}
.action-btn {
background: var(--accent);
border: none;
color: #fff;
padding: 10px 20px;
border-radius: 6px;
cursor: pointer;
font-size: 0.9rem;
transition: background 0.2s;
}
.action-btn:hover {
background: var(--accent-hover);
}
/* ── Header Input Panel ─────────────────────────────────── */
.header-input-divider {
width: 1px;
background: var(--bg-accent);
align-self: stretch;
margin: 0 20px;
flex-shrink: 0;
}
.header-input-panel {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
padding: 4px 0;
}
.header-input-panel .mood-selector {
margin-bottom: 0;
}
.header-input-panel textarea {
width: 100%;
flex: 1;
min-height: 70px;
background: var(--bg-primary);
border: 1px solid var(--bg-accent);
border-radius: 8px;
padding: 10px 12px;
color: var(--text-primary);
font-family: inherit;
font-size: 0.95rem;
resize: none;
}
.header-input-panel textarea:focus {
outline: none;
border-color: var(--accent);
}
.header-input-panel textarea::placeholder {
color: var(--text-muted);
}
.header-input-row {
display: flex;
gap: 8px;
align-items: center;
}
.header-input-row .project-select-wrapper,
.header-input-row .link-date-input {
flex: 1;
margin: 0;
}
.header-submit-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.header-submit-row .word-count {
color: var(--text-secondary);
font-size: 0.75rem;
}
.header-submit-row .selected-date {
color: var(--text-secondary);
font-size: 0.82rem;
}
/* Export in stats bar */
.stats-export-btn {
background: var(--accent);
border: none;
color: #fff;
padding: 5px 14px;
border-radius: 6px;
cursor: pointer;
font-size: 0.78rem;
transition: background 0.2s;
margin-left: 8px;
white-space: nowrap;
}
.stats-export-btn:hover {
background: var(--accent-hover);
}
main {
flex: 1;
display: flex;
flex-direction: column;
padding: 30px;
overflow: hidden;
gap: 0;
}
main.main--panel-open {
flex-direction: row;
gap: 16px;
}
.main-col {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 0;
}
.timeline-container {
flex: 1;
overflow-y: auto;
padding: 20px 0;
scroll-behavior: smooth;
}
/* ── Side Panel ─────────────────────────────────────────── */
.side-panel {
width: 360px;
flex-shrink: 0;
background: var(--bg-secondary);
border-radius: 12px;
border: 1px solid var(--bg-accent);
display: flex;
flex-direction: column;
overflow: hidden;
animation: panelSlideIn 0.2s ease;
}
@keyframes panelSlideIn {
from { opacity: 0; transform: translateX(20px); }
to { opacity: 1; transform: translateX(0); }
}
.side-panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid var(--bg-accent);
font-weight: 600;
color: var(--accent);
font-size: 0.9rem;
}
.close-panel-btn {
background: transparent;
border: none;
color: var(--text-secondary);
cursor: pointer;
font-size: 1rem;
padding: 2px 6px;
border-radius: 4px;
}
.close-panel-btn:hover { color: var(--accent); }
.side-panel-timeline {
flex: 1;
overflow-y: auto;
padding: 12px 16px;
}
.side-panel-input {
padding: 12px 16px;
border-top: 1px solid var(--bg-accent);
}
.side-panel-input textarea {
width: 100%;
box-sizing: border-box;
background: var(--bg-primary);
border: 1px solid var(--bg-accent);
border-radius: 8px;
color: var(--text-primary);
padding: 8px 10px;
resize: none;
font-size: 0.85rem;
font-family: inherit;
min-height: 64px;
}
.side-panel-input textarea:focus {
outline: none;
border-color: var(--accent);
}
.side-panel-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 8px;
}
.panel-date-group { margin-bottom: 16px; }
.panel-date-label {
font-size: 0.72rem;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 6px;
}
.panel-entry {
display: flex;
gap: 8px;
padding: 6px 0;
border-bottom: 1px solid var(--bg-accent);
align-items: flex-start;
}
.panel-entry:last-child { border-bottom: none; }
.panel-entry-time {
font-size: 0.72rem;
color: var(--text-secondary);
white-space: nowrap;
padding-top: 2px;
min-width: 56px;
}
.panel-entry-body {
font-size: 0.85rem;
color: var(--text-primary);
line-height: 1.45;
word-break: break-word;
flex: 1;
}
.panel-entry-body textarea {
width: 100%;
background: var(--bg-accent);
border: 1px solid var(--accent);
border-radius: 4px;
color: var(--text-primary);
font-size: 0.85rem;
line-height: 1.45;
padding: 4px 6px;
resize: vertical;
min-height: 48px;
box-sizing: border-box;
}
.panel-entry-actions {
display: flex;
gap: 4px;
flex-shrink: 0;
padding-top: 1px;
}
.panel-entry-action-btn {
background: none;
border: none;
cursor: pointer;
padding: 2px 5px;
border-radius: 4px;
font-size: 0.9rem;
line-height: 1;
color: var(--text-secondary);
transition: background 0.15s, color 0.15s;
}
.panel-entry-action-btn:hover { background: var(--bg-accent); color: var(--text-primary); }
.panel-entry-action-btn.del:hover { color: #e05252; }
.panel-edit-mode-btn {
background: transparent;
border: 1px solid var(--text-secondary);
color: var(--text-secondary);
border-radius: 6px;
padding: 8px 14px;
font-size: 0.85rem;
cursor: pointer;
transition: border-color 0.15s, color 0.15s;
}
.panel-edit-mode-btn:hover,
.panel-edit-mode-btn.active {
border-color: var(--accent);
color: var(--accent);
}
/* ── Breakout card in main timeline ─────────────────────── */
.breakout-card {
position: relative;
background: var(--bg-secondary);
border-left: 3px solid var(--accent);
border-radius: 8px;
padding: 10px 14px;
margin: 8px 0 8px 120px;
display: flex;
justify-content: space-between;
align-items: center;
}
/* Horizontal connector from card left edge back to the timeline vertical line */
.breakout-card::before {
content: '';
position: absolute;
/* timeline line is at 35px from .timeline left;
card left edge is at 240px (120px padding + 120px margin);
so we need to reach back 205px */
left: -205px;
top: 50%;
width: 205px;
height: 2px;
background: var(--accent);
opacity: 0.5;
transform: translateY(-50%);
pointer-events: none;
}
/* Small dot marker where the connector meets the vertical timeline line */
.breakout-card::after {
content: '';
position: absolute;
left: -211px; /* 205px back + centering a 12px dot (6px radius) = 205 + 6 */
top: 50%;
width: 12px;
height: 12px;
background: var(--accent);
opacity: 0.6;
border-radius: 50%;
border: 2px solid var(--bg-primary);
transform: translateY(-50%);
pointer-events: none;
}
.breakout-card-info { display: flex; flex-direction: column; gap: 3px; }
.breakout-card-name {
color: var(--accent);
font-weight: 600;
font-size: 0.9rem;
}
.breakout-card-count {
color: var(--text-secondary);
font-size: 0.75rem;
}
.breakout-name-input {
background: var(--bg-primary);
border: 1px solid var(--accent);
border-radius: 4px;
color: var(--accent);
font-weight: 600;
font-size: 0.9rem;
padding: 2px 6px;
outline: none;
width: 180px;
}
.breakout-card-actions { display: flex; gap: 8px; align-items: center; }
.open-panel-btn {
background: transparent;
border: 1px solid var(--accent);
color: var(--accent);
border-radius: 6px;
padding: 4px 10px;
cursor: pointer;
font-size: 0.8rem;
}
.open-panel-btn:hover { background: var(--accent); color: white; }
/* ── + Project button ────────────────────────────────────── */
.project-break-btn {
background: transparent;
border: 1px dashed var(--bg-accent);
color: var(--text-secondary);
border-radius: 6px;
padding: 4px 10px;
cursor: pointer;
font-size: 0.8rem;
}
.project-break-btn:hover { border-color: var(--accent); color: var(--accent); }
/* ── Inline breakout prompt ──────────────────────────────── */
.breakout-prompt {
display: flex;
align-items: center;
gap: 8px;
background: var(--bg-secondary);
border: 1px solid var(--accent);
border-radius: 8px;
padding: 10px 14px;
margin: 0 0 12px 120px;
flex-wrap: wrap;
}
.breakout-prompt-label {
font-size: 0.82rem;
color: var(--text-secondary);
}
.breakout-prompt-input {
flex: 1;
min-width: 140px;
background: var(--bg-primary);
border: 1px solid var(--bg-accent);
border-radius: 6px;
color: var(--text-primary);
padding: 4px 8px;
font-size: 0.85rem;
font-family: inherit;
}
.breakout-prompt-input:focus {
outline: none;
border-color: var(--accent);
}
.timeline {
position: relative;
padding-left: 120px;
}
.timeline::before {
content: '';
position: absolute;
left: 35px;
top: 0;
bottom: 0;
width: 2px;
background: var(--bg-accent);
}
.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: var(--accent);
border-radius: 50%;
border: 3px solid var(--bg-primary);
}
.entry-time {
position: absolute;
left: -105px;
top: 5px;
width: 90px;
text-align: right;
font-size: 0.85rem;
color: var(--text-secondary);
}
.entry-content {
background: var(--bg-secondary);
padding: 0;
border-radius: 8px;
border-left: 3px solid var(--accent);
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-site-badge--linked {
cursor: pointer;
}
.entry-site-badge--linked:hover {
background: rgba(233, 69, 96, 0.25);
border-left-color: var(--accent-hover, #c0392b);
}
.entry-site-badge {
font-size: 0.78rem;
font-weight: 700;
color: var(--accent);
background: rgba(233, 69, 96, 0.1);
padding: 4px 8px;
border-radius: 4px;
text-transform: uppercase;
letter-spacing: 0.3px;
border-left: 2px solid var(--accent);
}
.entry-project-id {
font-size: 0.75rem;
color: #aac4e8;
font-weight: 500;
}
.entry-links {
margin-top: 4px;
font-size: 0.75rem;
color: var(--text-secondary);
}
.link-date {
cursor: pointer;
color: var(--accent);
}
.link-date:hover {
text-decoration: underline;
}
.entry-divider {
width: 1px;
background: var(--bg-accent);
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: var(--text-muted);
}
.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: var(--text-primary);
background: var(--bg-accent);
}
.entry-btn.delete-btn:hover {
color: var(--accent);
background: rgba(233, 69, 96, 0.12);
}
.entry-edit-textarea {
width: 100%;
background: var(--bg-primary);
border: 1px solid var(--accent);
border-radius: 6px;
padding: 8px 10px;
color: var(--text-primary);
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: var(--text-primary);
}
.entry-time-input {
width: 90px;
background: var(--bg-primary);
border: 1px solid var(--accent);
border-radius: 4px;
padding: 2px 4px;
color: var(--text-primary);
font-size: 0.78rem;
}
.entry-time-input:focus {
outline: none;
}
.empty-state {
text-align: center;
color: var(--text-muted);
padding: 60px 20px;
}
.input-section {
background: var(--bg-secondary);
padding: 20px;
border-radius: 12px;
margin-top: 20px;
}
.input-section textarea {
width: 100%;
min-height: 100px;
background: var(--bg-primary);
border: 1px solid var(--bg-accent);
border-radius: 8px;
padding: 15px;
color: var(--text-primary);
font-family: inherit;
font-size: 1rem;
resize: vertical;
}
.input-section textarea:focus {
outline: none;
border-color: var(--accent);
}
.input-section textarea::placeholder {
color: var(--text-muted);
}
.word-count {
color: var(--text-secondary);
font-size: 0.75rem;
}
.input-actions {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 15px;
}
.selected-date {
color: var(--text-secondary);
font-size: 0.9rem;
}
.submit-btn {
background: var(--accent);
border: none;
color: #fff;
padding: 12px 30px;
border-radius: 6px;
cursor: pointer;
font-size: 1rem;
transition: background 0.2s;
}
.submit-btn:hover {
background: var(--accent-hover);
}
.submit-btn:disabled {
background: var(--disabled);
cursor: not-allowed;
}
/* ── Stats Bar ─────────────────────────────────────────── */
.stats-bar {
display: flex;
align-items: center;
gap: 24px;
padding: 10px 30px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--bg-accent);
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: var(--accent);
}
.stat-label {
color: var(--text-secondary);
font-size: 0.78rem;
}
.stat-divider {
width: 1px;
height: 18px;
background: var(--bg-accent);
flex-shrink: 0;
}
.group-filter-select {
background: transparent;
border: none;
color: var(--accent);
font-size: 1.1rem;
font-weight: 600;
font-family: inherit;
cursor: pointer;
outline: none;
-webkit-appearance: none;
appearance: none;
max-width: 120px;
}
.group-filter-select option {
background: var(--bg-secondary);
color: #ccc;
}
#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: var(--bg-accent);
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: var(--accent); }
/* ── Mood Selector ──────────────────────────────────────── */
.mood-selector {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 12px;
}
.mood-label {
font-size: 0.78rem;
color: var(--text-secondary);
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: var(--bg-accent);
}
.mood-btn.selected {
opacity: 1;
border-color: var(--accent);
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 var(--bg-accent);
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: var(--accent);
color: var(--accent);
}
.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: var(--accent);
}
/* ── 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: var(--bg-primary);
border: 1px solid var(--bg-accent);
border-radius: 8px;
padding: 6px 10px;
min-height: 38px;
transition: border-color 0.2s;
}
.tag-chips-row:focus-within {
border-color: var(--accent);
}
.tag-text-input {
background: none;
border: none;
outline: none;
color: var(--text-primary);
font-family: inherit;
font-size: 0.85rem;
min-width: 120px;
flex: 1;
}
.tag-text-input::placeholder {
color: #555;
}
.project-id-input {
width: 100%;
background: var(--bg-primary);
border: 1px solid var(--bg-accent);
border-radius: 8px;
color: var(--text-primary);
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: var(--accent);
}
.project-id-input::placeholder {
color: #555;
}
.project-id-input option {
background: var(--bg-primary);
color: var(--text-primary);
}
.project-select-wrapper {
display: flex;
gap: 4px;
align-items: center;
flex: 1;
}
.project-select-wrapper .project-id-input {
flex: 1;
margin: 0;
appearance: none;
cursor: pointer;
}
.project-refresh-btn {
background: var(--bg-accent);
border: 1px solid var(--bg-accent);
border-radius: 6px;
color: var(--text-secondary);
font-size: 1rem;
padding: 6px 8px;
cursor: pointer;
flex-shrink: 0;
line-height: 1;
}
.project-refresh-btn:hover {
color: var(--text-primary);
border-color: var(--accent);
}
.link-date-input {
width: 100%;
background: var(--bg-primary);
border: 1px solid var(--bg-accent);
border-radius: 8px;
color: var(--text-primary);
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: var(--accent);
}
/* ── 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: var(--text-secondary);
}
.tag-filter-chip {
background: rgba(233, 69, 96, 0.18);
color: var(--accent);
border: 1px solid var(--accent);
border-radius: 20px;
padding: 2px 10px;
font-size: 0.8rem;
font-weight: 600;
}
.tag-filter-clear {
background: none;
border: 1px solid var(--disabled);
color: var(--text-secondary);
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: var(--accent);
color: var(--accent);
}
/* ── Tag View: Date Group Headings ───────────────────────── */
.tag-view-group {
margin-bottom: 30px;
}
.tag-view-date-heading {
font-size: 0.8rem;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
padding-bottom: 10px;
border-bottom: 1px solid var(--bg-accent);
margin-bottom: 14px;
}
.project-group {
margin-bottom: 18px;
}
.project-group-header {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 12px;
background: var(--bg-accent);
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: var(--accent);
text-transform: uppercase;
letter-spacing: 0.07em;
flex: 1;
}
.project-group-count {
font-size: 0.72rem;
color: var(--text-secondary);
}
.project-group-chevron {
font-size: 0.7rem;
color: var(--text-secondary);
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-input-divider"></div>
<div class="header-input-panel">
<div class="mood-selector" id="headerMoodSelector">
<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>
<div class="header-input-row">
<div class="project-select-wrapper">
<select id="projectIdInput" class="project-id-input">
<option value="">— no project —</option>
</select>
<button id="refreshProjectsBtn" class="project-refresh-btn" title="Refresh project list"></button>
</div>
<input id="linkDateInput" class="link-date-input" type="date" title="Link to another day" />
</div>
<textarea id="entryInput" placeholder="Write your journal entry… (Ctrl+Enter to save)"></textarea>
<div class="header-submit-row">
<span class="selected-date" id="selectedDateDisplay"></span>
<button id="insertBreakoutBtn" class="project-break-btn">+ Project</button>
<span class="word-count" id="wordCount">0 words</span>
<button class="submit-btn" id="submitBtn">Add Entry</button>
</div>
</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 class="stat-divider"></div>
<div class="stat-item">
<select id="groupFilterSelect" class="group-filter-select">
<option value="">all</option>
</select>
<span class="stat-label">group</span>
</div>
<div id="statMoodSection"></div>
<div class="stat-heatmap" id="statHeatmap"></div>
<button class="stats-export-btn" id="exportBtn">Export Markdown</button>
</div>
<main>
<div class="main-col">
<div class="timeline-container" id="timelineContainer">
<div class="timeline" id="timeline"></div>
</div>
</div>
<div id="sidePanel" class="side-panel" style="display:none">
<div class="side-panel-header">
<span id="sidePanelTitle"></span>
<button id="closePanelBtn" class="close-panel-btn"></button>
</div>
<div class="side-panel-timeline" id="sidePanelTimeline"></div>
<div class="side-panel-input">
<textarea id="panelEntryInput" placeholder="Add note to project… (Ctrl+Enter to save)"></textarea>
<div class="side-panel-actions">
<button id="panelEditModeBtn" class="panel-edit-mode-btn">Edit</button>
<button id="panelSubmitBtn" class="submit-btn">Add Entry</button>
</div>
</div>
</div>
</main>
<script>
const DB_NAME = 'JournalDB';
const DB_VERSION = 3;
const STORE_NAME = 'entries';
const PROJECTS_STORE = 'projects';
const PROJECT_ENTRIES_STORE = 'project_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 activeGroupFilter = null;
let pendingTags = [];
let activePanelProjectId = null;
let existingProjects = [];
let projectCache = new Map(); // numeric id → { id, name, created }
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 });
}
if (!database.objectStoreNames.contains(PROJECTS_STORE)) {
database.createObjectStore(PROJECTS_STORE, { keyPath: 'id', autoIncrement: true });
}
if (!database.objectStoreNames.contains(PROJECT_ENTRIES_STORE)) {
const peStore = database.createObjectStore(PROJECT_ENTRIES_STORE, { keyPath: 'id', autoIncrement: true });
peStore.createIndex('projectId', 'projectId', { 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;
activeGroupFilter = null;
const gSelect = document.getElementById('groupFilterSelect');
if (gSelect) gSelect.value = '';
await refreshProjectCache();
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) {
if (entry.type === 'breakout') {
return `
<div class="breakout-card" data-id="${entry.id}" data-project-id="${entry.projectId}" data-project-name="${escapeHtml(entry.projectName || '')}">
<div class="breakout-card-info">
<div class="breakout-card-name">🔗 ${escapeHtml(entry.projectName || 'Project')}</div>
<div class="breakout-card-count" id="breakout-count-${entry.id}">— entries</div>
</div>
<div class="breakout-card-actions">
<button class="open-panel-btn" data-id="${entry.id}" data-project-id="${entry.projectId}" data-project-name="${escapeHtml(entry.projectName || '')}">open →</button>
<button class="entry-btn edit-breakout-btn" data-id="${entry.id}">edit</button>
<button class="entry-btn delete-breakout-btn" data-id="${entry.id}">✕</button>
</div>
</div>
`;
}
const date = new Date(entry.date);
let siteBadgeHtml;
if (entry.projectId && typeof entry.projectId === 'number') {
const proj = projectCache.get(entry.projectId);
const projName = proj ? proj.name : `Project #${entry.projectId}`;
siteBadgeHtml = `<div class="entry-site-badge entry-site-badge--linked" data-project-id="${entry.projectId}" data-project-name="${escapeHtml(projName)}" title="Click to open project panel">${escapeHtml(projName)}</div>`;
} else {
siteBadgeHtml = `<div class="entry-site-badge">GENERAL</div>`;
}
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">
${siteBadgeHtml}
<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>
`;
}
// Sort entries chronologically (newest first)
const sortedEntries = entries.sort((a, b) => b.timestamp - a.timestamp);
// Render all entries in chronological order
timeline.innerHTML = sortedEntries.map(buildEntryHtml).join('');
// Load async entry counts for breakout cards
sortedEntries.filter(e => e.type === 'breakout').forEach(async entry => {
const count = await getCombinedProjectEntryCount(entry.projectId);
const el = document.getElementById(`breakout-count-${entry.id}`);
if (el) el.textContent = `${count} ${count === 1 ? 'entry' : 'entries'}`;
});
sortedEntries.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;
// ── Breakout cards ───────────────────────────────────────
timeline.querySelectorAll('.open-panel-btn').forEach(btn => {
btn.addEventListener('click', () => {
openProjectPanel(parseInt(btn.dataset.projectId), btn.dataset.projectName);
});
});
// ── Linked project badges on entries ─────────────────────
timeline.querySelectorAll('.entry-site-badge--linked').forEach(badge => {
badge.addEventListener('click', () => {
openProjectPanel(parseInt(badge.dataset.projectId), badge.dataset.projectName);
});
});
timeline.querySelectorAll('.edit-breakout-btn').forEach(btn => {
btn.addEventListener('click', () => {
const card = btn.closest('.breakout-card');
const id = parseInt(card.dataset.id);
const projectId = parseInt(card.dataset.projectId);
const currentName = card.dataset.projectName;
const nameEl = card.querySelector('.breakout-card-name');
const actionsEl = card.querySelector('.breakout-card-actions');
nameEl.innerHTML = `<input class="breakout-name-input" value="${escapeHtml(currentName)}" maxlength="80" />`;
actionsEl.innerHTML = `
<button class="open-panel-btn save-breakout-name-btn">save</button>
<button class="entry-btn cancel-breakout-edit-btn">cancel</button>
`;
const input = nameEl.querySelector('.breakout-name-input');
input.focus();
input.select();
const save = async () => {
const newName = input.value.trim();
if (!newName) return;
await updateEntry(id, { projectName: newName });
await updateProject(projectId, { name: newName });
await refreshProjectCache();
await renderTimeline();
showToast('Project renamed');
};
actionsEl.querySelector('.save-breakout-name-btn').addEventListener('click', save);
actionsEl.querySelector('.cancel-breakout-edit-btn').addEventListener('click', () => renderTimeline());
input.addEventListener('keydown', e => {
if (e.key === 'Enter') save();
if (e.key === 'Escape') renderTimeline();
});
});
});
timeline.querySelectorAll('.delete-breakout-btn').forEach(btn => {
btn.addEventListener('click', async () => {
if (!confirm('Remove this project breakout from the timeline?')) return;
await deleteBreakoutMarker(parseInt(btn.dataset.id));
await renderTimeline();
});
});
// ── 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()]);
showToast('Entry deleted');
});
});
// ── 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 openProjectPanel(projectId, projectName) {
activePanelProjectId = projectId;
panelEditMode = false;
const editBtn = document.getElementById('panelEditModeBtn');
editBtn.textContent = 'Edit';
editBtn.classList.remove('active');
document.getElementById('sidePanelTitle').textContent = '🔗 ' + projectName;
document.getElementById('sidePanel').style.display = 'flex';
document.querySelector('main').classList.add('main--panel-open');
document.getElementById('panelEntryInput').value = '';
await renderProjectTimeline(projectId);
}
function closeProjectPanel() {
document.getElementById('sidePanel').style.display = 'none';
document.querySelector('main').classList.remove('main--panel-open');
activePanelProjectId = null;
panelEditMode = false;
const editBtn = document.getElementById('panelEditModeBtn');
editBtn.textContent = 'Edit';
editBtn.classList.remove('active');
}
let panelEditMode = false;
async function renderProjectTimeline(projectId) {
const container = document.getElementById('sidePanelTimeline');
container.innerHTML = '<div style="color:var(--text-secondary);font-size:0.85rem;padding:8px 0">Loading…</div>';
const [panelEntries, allEntries] = await Promise.all([
getProjectEntries(projectId),
getAllEntries()
]);
const linkedEntries = allEntries
.filter(e => e.type !== 'breakout' && e.projectId === projectId)
.map(e => ({ ...e, _source: 'timeline' }));
const merged = [
...panelEntries.map(e => ({ ...e, _source: 'panel' })),
...linkedEntries
].sort((a, b) => b.timestamp - a.timestamp);
if (merged.length === 0) {
container.innerHTML = '<div style="color:var(--text-secondary);font-size:0.85rem;padding:8px 0">No entries yet. Add one below.</div>';
return;
}
const byDate = {};
merged.forEach(e => {
const day = e.date.slice(0, 10);
if (!byDate[day]) byDate[day] = [];
byDate[day].push(e);
});
container.innerHTML = Object.keys(byDate).sort((a, b) => b.localeCompare(a)).map(day => {
const label = new Date(day + 'T12:00:00').toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' });
const rows = byDate[day].map(e => {
const t = new Date(e.timestamp);
const time = t.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
const mood = e.mood ? `<span style="margin-right:4px">${e.mood}</span>` : '';
const tags = (e.tags || []).map(tg => `<span class="tag-chip">#${escapeHtml(tg)}</span>`).join('');
const sourceLabel = e._source === 'timeline'
? `<span style="font-size:0.7rem;color:var(--text-secondary);margin-left:4px">[journal]</span>`
: '';
const actions = (panelEditMode && e._source === 'panel')
? `<div class="panel-entry-actions">
<button class="panel-entry-action-btn" data-action="edit" title="Edit">✎</button>
<button class="panel-entry-action-btn del" data-action="delete" title="Delete">×</button>
</div>`
: '';
return `<div class="panel-entry" data-id="${e.id}" data-source="${e._source}" data-text="${escapeHtml(e.text)}">
<div class="panel-entry-time">${time}${sourceLabel}</div>
<div class="panel-entry-body">${mood}${escapeHtml(e.text)}${tags ? '<div style="margin-top:4px">'+tags+'</div>' : ''}</div>
${actions}
</div>`;
}).join('');
return `<div class="panel-date-group"><div class="panel-date-label">${label}</div>${rows}</div>`;
}).join('');
}
function showBreakoutPrompt() {
const existing = document.getElementById('breakoutPrompt');
if (existing) { existing.querySelector('input').focus(); return; }
const prompt = document.createElement('div');
prompt.id = 'breakoutPrompt';
prompt.className = 'breakout-prompt';
const listId = 'breakoutProjectList';
const datalist = document.createElement('datalist');
datalist.id = listId;
existingProjects.forEach(p => {
const opt = document.createElement('option');
opt.value = p.name;
datalist.appendChild(opt);
});
prompt.innerHTML = `
<span class="breakout-prompt-label">Project name:</span>
<input id="breakoutNameInput" class="breakout-prompt-input" type="text" placeholder="e.g. BOM Project" autocomplete="off" list="${listId}">
<button class="open-panel-btn" id="breakoutConfirmBtn">Add</button>
<button class="entry-btn" id="breakoutCancelBtn">Cancel</button>
`;
prompt.appendChild(datalist);
const timeline = document.getElementById('timeline');
timeline.prepend(prompt);
prompt.querySelector('#breakoutNameInput').focus();
const cancel = () => prompt.remove();
const confirm = async () => {
const name = prompt.querySelector('#breakoutNameInput').value.trim();
if (!name) return;
prompt.remove();
const projectId = await createProject(name);
await insertBreakoutMarker(projectId, name, selectedDate);
await renderTimeline();
};
prompt.querySelector('#breakoutConfirmBtn').addEventListener('click', confirm);
prompt.querySelector('#breakoutCancelBtn').addEventListener('click', cancel);
prompt.querySelector('#breakoutNameInput').addEventListener('keydown', e => {
if (e.key === 'Enter') confirm();
if (e.key === 'Escape') cancel();
});
}
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
.filter(e => e.type !== 'breakout' && typeof e.projectId === 'string')
.map(e => e.projectId.trim())
.filter(p => p)
)].sort((a, b) => a.localeCompare(b));
resolve(projects);
};
req.onerror = () => reject(req.error);
});
}
function createProject(name) {
return new Promise((resolve, reject) => {
const tx = db.transaction(PROJECTS_STORE, 'readwrite');
const store = tx.objectStore(PROJECTS_STORE);
const getAll = store.getAll();
getAll.onsuccess = () => {
const existing = getAll.result.find(p => p.name.trim().toLowerCase() === name.trim().toLowerCase());
if (existing) { resolve(existing.id); return; }
const add = store.add({ name: name.trim(), created: new Date().toISOString() });
add.onsuccess = () => resolve(add.result);
add.onerror = () => reject(add.error);
};
getAll.onerror = () => reject(getAll.error);
});
}
function updateProject(id, fields) {
return new Promise((resolve, reject) => {
const tx = db.transaction(PROJECTS_STORE, 'readwrite');
const store = tx.objectStore(PROJECTS_STORE);
const req = store.get(id);
req.onsuccess = () => {
const project = req.result;
if (!project) return reject(new Error('Project not found'));
Object.assign(project, fields);
const putReq = store.put(project);
putReq.onsuccess = () => resolve();
putReq.onerror = () => reject(putReq.error);
};
req.onerror = () => reject(req.error);
});
}
function getAllProjects() {
return new Promise((resolve, reject) => {
const tx = db.transaction(PROJECTS_STORE, 'readonly');
const store = tx.objectStore(PROJECTS_STORE);
const req = store.getAll();
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
async function refreshProjectCache() {
const projects = await getAllProjects();
projectCache = new Map(projects.map(p => [p.id, p]));
existingProjects = projects;
}
async function migrateStringProjectIds() {
const allEntries = await getAllEntries();
const legacyEntries = allEntries.filter(
e => e.type !== 'breakout' && typeof e.projectId === 'string' && e.projectId.trim() !== ''
);
if (legacyEntries.length === 0) return;
const uniqueNames = [...new Set(legacyEntries.map(e => e.projectId.trim()))];
const nameToNumericId = new Map();
for (const name of uniqueNames) {
const numericId = await createProject(name);
nameToNumericId.set(name, numericId);
}
for (const entry of legacyEntries) {
const numericId = nameToNumericId.get(entry.projectId.trim());
await updateEntry(entry.id, { projectId: numericId });
}
}
function saveProjectEntry(projectId, text, date, mood = null, tags = []) {
return new Promise((resolve, reject) => {
const tx = db.transaction(PROJECT_ENTRIES_STORE, 'readwrite');
const store = tx.objectStore(PROJECT_ENTRIES_STORE);
const req = store.add({
projectId,
text,
date: date.toISOString(),
timestamp: date.getTime(),
mood,
tags
});
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
function deleteProjectEntry(id) {
return new Promise((resolve, reject) => {
const tx = db.transaction(PROJECT_ENTRIES_STORE, 'readwrite');
const store = tx.objectStore(PROJECT_ENTRIES_STORE);
const req = store.delete(id);
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
});
}
function updateProjectEntry(id, text) {
return new Promise((resolve, reject) => {
const tx = db.transaction(PROJECT_ENTRIES_STORE, 'readwrite');
const store = tx.objectStore(PROJECT_ENTRIES_STORE);
const getReq = store.get(id);
getReq.onsuccess = () => {
const entry = getReq.result;
if (!entry) { reject(new Error('Entry not found')); return; }
entry.text = text;
const putReq = store.put(entry);
putReq.onsuccess = () => resolve();
putReq.onerror = () => reject(putReq.error);
};
getReq.onerror = () => reject(getReq.error);
});
}
function getProjectEntries(projectId) {
return new Promise((resolve, reject) => {
const tx = db.transaction(PROJECT_ENTRIES_STORE, 'readonly');
const store = tx.objectStore(PROJECT_ENTRIES_STORE);
const idx = store.index('projectId');
const req = idx.getAll(projectId);
req.onsuccess = () => resolve(req.result.sort((a, b) => b.timestamp - a.timestamp));
req.onerror = () => reject(req.error);
});
}
function getProjectEntryCount(projectId) {
return new Promise((resolve, reject) => {
const tx = db.transaction(PROJECT_ENTRIES_STORE, 'readonly');
const store = tx.objectStore(PROJECT_ENTRIES_STORE);
const idx = store.index('projectId');
const req = idx.count(projectId);
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
async function getCombinedProjectEntryCount(projectId) {
const [panelCount, allEntries] = await Promise.all([
getProjectEntryCount(projectId),
getAllEntries()
]);
const timelineCount = allEntries.filter(
e => e.type !== 'breakout' && e.projectId === projectId
).length;
return panelCount + timelineCount;
}
function insertBreakoutMarker(projectId, projectName, date) {
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
const store = tx.objectStore(STORE_NAME);
const req = store.add({
type: 'breakout',
projectId,
projectName,
date: date.toISOString(),
timestamp: date.getTime()
});
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
function deleteBreakoutMarker(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);
});
}
async function populateProjectDropdown() {
await refreshProjectCache();
const select = document.getElementById('projectIdInput');
if (!select || select.tagName !== 'SELECT') return;
const current = select.value;
select.innerHTML = '<option value="">— no project —</option>' +
[...projectCache.values()]
.sort((a, b) => a.name.localeCompare(b.name))
.map(p => `<option value="${p.id}">${escapeHtml(p.name)}</option>`)
.join('');
if (current && projectCache.has(Number(current))) {
select.value = current;
}
}
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;
}
const journalEntries = entries.filter(e => e.type !== 'breakout');
journalEntries.sort((a, b) => a.timestamp - b.timestamp);
let md = `# Journal - ${getMonthName(currentMonth)} ${currentYear}\n\n`;
let currentDay = null;
journalEntries.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', () => {
if (!dbReady) return;
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', () => {
if (!dbReady) return;
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 () => {
if (!dbReady) return;
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 projectIdRaw = document.getElementById('projectIdInput').value;
const projectId = projectIdRaw ? parseInt(projectIdRaw, 10) : null;
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 populateProjectDropdown();
showToast('Entry saved');
} 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.addEventListener('keydown', (e) => {
const typing = e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.isContentEditable;
if (e.key === 'n' && !typing && !e.ctrlKey && !e.metaKey) {
e.preventDefault();
entryInput.focus();
entryInput.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
if (e.key === 'Escape') {
if (activePanelProjectId !== null) closeProjectPanel();
if (document.activeElement !== document.body) document.activeElement.blur();
}
});
document.getElementById('exportBtn').addEventListener('click', exportMarkdown);
document.getElementById('todayBtn').addEventListener('click', () => {
if (!dbReady) return;
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 = '—';
}
// Group filter — populate from real projects
const gSelect = document.getElementById('groupFilterSelect');
if (gSelect) {
const currentVal = gSelect.value;
gSelect.innerHTML = '<option value="">all</option>' +
[...projectCache.values()]
.sort((a, b) => a.name.localeCompare(b.name))
.map(p => `<option value="${p.id}">${escapeHtml(p.name)}</option>`)
.join('');
if (currentVal && projectCache.has(Number(currentVal))) {
gSelect.value = currentVal;
} else {
gSelect.value = activeGroupFilter ? String(activeGroupFilter) : '';
}
}
// 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 activateGroupFilter(projectId) {
const allEntries = await getAllEntries();
const matched = allEntries
.filter(e => e.type !== 'breakout' && e.projectId === projectId)
.sort((a, b) => b.timestamp - a.timestamp);
const proj = projectCache.get(projectId);
const projName = proj ? proj.name : String(projectId);
if (matched.length === 0) {
timeline.innerHTML = `<div class="empty-state"><p>No entries in group <strong>${escapeHtml(projName)}</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 activateTagFilter(tag) {
activeTagFilter = tag;
activeGroupFilter = null;
const gSelect = document.getElementById('groupFilterSelect');
if (gSelect) gSelect.value = '';
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;
}
function showToast(message) {
const existing = document.querySelector('.toast');
if (existing) existing.remove();
const toast = document.createElement('div');
toast.className = 'toast';
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 3500);
}
async function panelSubmit() {
const input = document.getElementById('panelEntryInput');
const text = input.value.trim();
if (!text || activePanelProjectId == null) return;
try {
await saveProjectEntry(activePanelProjectId, text, new Date());
input.value = '';
await renderProjectTimeline(activePanelProjectId);
const countEl = document.querySelector(`.breakout-card[data-project-id="${activePanelProjectId}"] .breakout-card-count`);
if (countEl) {
const count = await getCombinedProjectEntryCount(activePanelProjectId);
countEl.textContent = `${count} ${count === 1 ? 'entry' : 'entries'}`;
}
showToast('Note saved');
} catch (err) {
console.error('Failed to save project entry:', err);
showToast('Error saving note — check console');
}
}
let dbReady = false;
async function init() {
try {
await initDB();
dbReady = true;
await migrateStringProjectIds();
await refreshProjectCache();
await populateProjectDropdown();
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; }));
} catch (err) {
console.error('Journal init failed:', err);
const banner = document.createElement('div');
banner.style.cssText = 'position:fixed;top:0;left:0;right:0;z-index:99999;background:#e94560;color:#fff;padding:12px 20px;font-size:0.9rem;text-align:center';
banner.textContent = `Failed to initialize journal: ${err.message || err}. Try a hard-refresh (Ctrl+Shift+R).`;
document.body.prepend(banner);
}
}
document.getElementById('refreshProjectsBtn').addEventListener('click', () => { if (dbReady) populateProjectDropdown(); });
document.getElementById('groupFilterSelect').addEventListener('change', function() {
if (!dbReady) return;
const val = this.value;
activeGroupFilter = val ? parseInt(val, 10) : null;
if (activeGroupFilter) {
activateGroupFilter(activeGroupFilter);
} else {
activeTagFilter = null;
renderTimeline();
}
});
document.getElementById('closePanelBtn').addEventListener('click', closeProjectPanel);
document.getElementById('insertBreakoutBtn').addEventListener('click', () => { if (dbReady) showBreakoutPrompt(); });
document.getElementById('panelSubmitBtn').addEventListener('click', () => { if (dbReady) panelSubmit(); });
document.getElementById('panelEntryInput').addEventListener('keydown', e => {
if (e.key === 'Enter' && e.ctrlKey) { e.preventDefault(); if (dbReady) panelSubmit(); }
});
document.getElementById('panelEditModeBtn').addEventListener('click', () => {
panelEditMode = !panelEditMode;
const btn = document.getElementById('panelEditModeBtn');
btn.textContent = panelEditMode ? 'Done' : 'Edit';
btn.classList.toggle('active', panelEditMode);
if (activePanelProjectId != null) renderProjectTimeline(activePanelProjectId);
});
document.getElementById('sidePanelTimeline').addEventListener('click', async e => {
const actionBtn = e.target.closest('[data-action]');
if (!actionBtn) return;
const entry = actionBtn.closest('.panel-entry');
if (!entry || entry.dataset.source !== 'panel') return;
const id = parseInt(entry.dataset.id);
if (actionBtn.dataset.action === 'delete') {
try {
await deleteProjectEntry(id);
await renderProjectTimeline(activePanelProjectId);
const countEl = document.querySelector(`.breakout-card[data-project-id="${activePanelProjectId}"] .breakout-card-count`);
if (countEl) {
const count = await getCombinedProjectEntryCount(activePanelProjectId);
countEl.textContent = `${count} ${count === 1 ? 'entry' : 'entries'}`;
}
showToast('Entry deleted');
} catch (err) {
console.error('Failed to delete project entry:', err);
showToast('Error deleting entry');
}
return;
}
if (actionBtn.dataset.action === 'edit') {
const body = entry.querySelector('.panel-entry-body');
const currentText = entry.dataset.text;
body.innerHTML = `<textarea>${escapeHtml(currentText)}</textarea>`;
const textarea = body.querySelector('textarea');
textarea.focus();
textarea.setSelectionRange(textarea.value.length, textarea.value.length);
const save = async () => {
const newText = textarea.value.trim();
if (!newText || newText === currentText) {
await renderProjectTimeline(activePanelProjectId);
return;
}
try {
await updateProjectEntry(id, newText);
await renderProjectTimeline(activePanelProjectId);
showToast('Entry updated');
} catch (err) {
console.error('Failed to update project entry:', err);
showToast('Error updating entry');
}
};
textarea.addEventListener('blur', save, { once: true });
textarea.addEventListener('keydown', ke => {
if (ke.key === 'Enter' && !ke.shiftKey) { ke.preventDefault(); textarea.removeEventListener('blur', save); save(); }
if (ke.key === 'Escape') { textarea.removeEventListener('blur', save); renderProjectTimeline(activePanelProjectId); }
});
}
});
init();
</script>
</body>
</html>