72dc0a8d9a
- 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>
3078 lines
113 KiB
HTML
3078 lines
113 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>
|
||
: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"><</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-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>
|