1400 lines
49 KiB
HTML
1400 lines
49 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>EverMem - Memory Hub</title>
|
|
<style>
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
background: #0d1117;
|
|
color: #c9d1d9;
|
|
min-height: 100vh;
|
|
}
|
|
#header {
|
|
padding: 16px 24px;
|
|
border-bottom: 1px solid #30363d;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 16px;
|
|
background: rgba(13, 17, 23, 0.95);
|
|
backdrop-filter: blur(10px);
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 100;
|
|
flex-wrap: wrap;
|
|
}
|
|
#header h1 { font-size: 18px; font-weight: 600; display: flex; align-items: center; gap: 8px; }
|
|
#header .stats {
|
|
font-size: 13px;
|
|
color: #8b949e;
|
|
margin-left: auto;
|
|
}
|
|
.header-controls {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
.api-input, .search-input, .filter-select {
|
|
padding: 6px 12px;
|
|
background: #161b22;
|
|
border: 1px solid #30363d;
|
|
border-radius: 6px;
|
|
color: #c9d1d9;
|
|
font-size: 13px;
|
|
}
|
|
.api-input { width: 180px; }
|
|
.search-input { width: 200px; }
|
|
.filter-select { min-width: 120px; cursor: pointer; }
|
|
.api-input:focus, .search-input:focus, .filter-select:focus {
|
|
outline: none;
|
|
border-color: #FFC53D;
|
|
}
|
|
.api-btn, .refresh-btn {
|
|
padding: 6px 12px;
|
|
background: #FFC53D;
|
|
border: none;
|
|
border-radius: 6px;
|
|
color: #0d1117;
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
transition: background 0.2s;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
}
|
|
.api-btn:hover, .refresh-btn:hover { background: #e6b035; }
|
|
.api-btn:disabled, .refresh-btn:disabled { background: #30363d; color: #8b949e; cursor: not-allowed; }
|
|
.refresh-btn { background: #30363d; color: #c9d1d9; font-weight: 400; }
|
|
.refresh-btn:hover { background: #3d444d; }
|
|
.refresh-btn.spinning svg { animation: spin 1s linear infinite; }
|
|
|
|
#progress-view {
|
|
width: 100%;
|
|
padding: 24px;
|
|
overflow-y: auto;
|
|
}
|
|
.progress-container {
|
|
max-width: 1200px;
|
|
margin: 0 auto;
|
|
}
|
|
.loading-message {
|
|
text-align: center;
|
|
padding: 60px 20px;
|
|
color: #8b949e;
|
|
font-size: 14px;
|
|
}
|
|
.loading-message .spinner {
|
|
width: 32px;
|
|
height: 32px;
|
|
border: 3px solid #30363d;
|
|
border-top-color: #FFC53D;
|
|
border-radius: 50%;
|
|
animation: spin 1s linear infinite;
|
|
margin: 0 auto 16px;
|
|
}
|
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
.error-message {
|
|
background: #f8514922;
|
|
border: 1px solid #f85149;
|
|
border-radius: 8px;
|
|
padding: 16px;
|
|
color: #f85149;
|
|
margin-bottom: 24px;
|
|
}
|
|
.stats-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(4, 1fr);
|
|
gap: 16px;
|
|
margin-bottom: 32px;
|
|
}
|
|
@media (max-width: 768px) {
|
|
.stats-grid { grid-template-columns: repeat(2, 1fr); }
|
|
#header { flex-direction: column; align-items: flex-start; }
|
|
.header-controls { flex-wrap: wrap; }
|
|
.charts-row { flex-direction: column; }
|
|
}
|
|
.stat-card {
|
|
background: #161b22;
|
|
border: 1px solid #30363d;
|
|
border-radius: 12px;
|
|
padding: 20px;
|
|
}
|
|
.stat-card .label {
|
|
font-size: 12px;
|
|
color: #8b949e;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
margin-bottom: 8px;
|
|
}
|
|
.stat-card .value {
|
|
font-size: 32px;
|
|
font-weight: 600;
|
|
color: #f0f6fc;
|
|
}
|
|
.stat-card .trend {
|
|
font-size: 12px;
|
|
color: #FFC53D;
|
|
margin-top: 4px;
|
|
}
|
|
.stat-card .trend.neutral { color: #8b949e; }
|
|
|
|
/* Charts row - side by side */
|
|
.charts-row {
|
|
display: flex;
|
|
gap: 16px;
|
|
margin-bottom: 32px;
|
|
}
|
|
.charts-row > * {
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
|
|
/* Heatmap - GitHub style */
|
|
.heatmap-section {
|
|
background: #161b22;
|
|
border: 1px solid #30363d;
|
|
border-radius: 12px;
|
|
padding: 24px;
|
|
overflow-x: auto;
|
|
}
|
|
.section-title {
|
|
font-size: 14px;
|
|
font-weight: 600;
|
|
color: #f0f6fc;
|
|
margin-bottom: 20px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
.heatmap-months {
|
|
position: relative;
|
|
font-size: 11px;
|
|
color: #8b949e;
|
|
margin-bottom: 8px;
|
|
margin-left: 36px;
|
|
height: 16px;
|
|
}
|
|
.heatmap-months span {
|
|
position: absolute;
|
|
}
|
|
.heatmap-body {
|
|
display: flex;
|
|
gap: 4px;
|
|
}
|
|
.heatmap-days {
|
|
display: flex;
|
|
flex-direction: column;
|
|
font-size: 11px;
|
|
color: #8b949e;
|
|
width: 32px;
|
|
padding-top: 2px;
|
|
}
|
|
.heatmap-days span {
|
|
height: 13px;
|
|
line-height: 13px;
|
|
}
|
|
.heatmap-grid {
|
|
display: flex;
|
|
gap: 3px;
|
|
}
|
|
.heatmap-week {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 3px;
|
|
}
|
|
.heatmap-cell {
|
|
width: 13px;
|
|
height: 13px;
|
|
border-radius: 2px;
|
|
background: #161b22;
|
|
border: 1px solid #21262d;
|
|
cursor: pointer;
|
|
transition: transform 0.1s;
|
|
position: relative;
|
|
}
|
|
.heatmap-cell:hover { transform: scale(1.3); z-index: 10; }
|
|
.heatmap-cell.level-1 { background: #4d3a10; border-color: #4d3a10; }
|
|
.heatmap-cell.level-2 { background: #806200; border-color: #806200; }
|
|
.heatmap-cell.level-3 { background: #cc9a00; border-color: #cc9a00; }
|
|
.heatmap-cell.level-4 { background: #FFC53D; border-color: #FFC53D; }
|
|
.heatmap-tooltip {
|
|
position: fixed;
|
|
background: #1c2128;
|
|
border: 1px solid #30363d;
|
|
border-radius: 6px;
|
|
padding: 8px 12px;
|
|
font-size: 12px;
|
|
color: #c9d1d9;
|
|
pointer-events: none;
|
|
z-index: 1000;
|
|
white-space: nowrap;
|
|
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
|
|
display: none;
|
|
}
|
|
.heatmap-tooltip strong {
|
|
color: #f0f6fc;
|
|
}
|
|
.heatmap-legend {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: flex-end;
|
|
gap: 4px;
|
|
margin-top: 12px;
|
|
font-size: 11px;
|
|
color: #8b949e;
|
|
}
|
|
.heatmap-legend-cell { width: 12px; height: 12px; border-radius: 2px; }
|
|
|
|
.growth-section {
|
|
background: #161b22;
|
|
border: 1px solid #30363d;
|
|
border-radius: 12px;
|
|
padding: 24px;
|
|
}
|
|
.growth-chart {
|
|
display: flex;
|
|
align-items: flex-end;
|
|
gap: 8px;
|
|
height: 150px;
|
|
padding: 0 8px;
|
|
}
|
|
.chart-bar-container {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
height: 100%;
|
|
}
|
|
.chart-bar-wrapper {
|
|
flex: 1;
|
|
width: 100%;
|
|
display: flex;
|
|
align-items: flex-end;
|
|
}
|
|
.chart-bar {
|
|
width: 100%;
|
|
background: linear-gradient(to top, #cc9a00, #FFC53D);
|
|
border-radius: 4px 4px 0 0;
|
|
min-height: 4px;
|
|
transition: height 0.3s ease;
|
|
position: relative;
|
|
}
|
|
.chart-bar:hover { filter: brightness(1.2); }
|
|
.chart-bar .bar-tooltip {
|
|
position: absolute;
|
|
bottom: calc(100% + 8px);
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
background: #0d1117;
|
|
border: 1px solid #30363d;
|
|
padding: 6px 10px;
|
|
border-radius: 6px;
|
|
font-size: 11px;
|
|
white-space: nowrap;
|
|
opacity: 0;
|
|
pointer-events: none;
|
|
transition: opacity 0.2s;
|
|
z-index: 10;
|
|
}
|
|
.chart-bar:hover .bar-tooltip { opacity: 1; }
|
|
.chart-label { font-size: 10px; color: #8b949e; margin-top: 8px; }
|
|
|
|
.timeline-section { margin-bottom: 32px; }
|
|
.timeline { position: relative; padding-left: 32px; }
|
|
.timeline::before {
|
|
content: '';
|
|
position: absolute;
|
|
left: 7px;
|
|
top: 0;
|
|
bottom: 0;
|
|
width: 2px;
|
|
background: #30363d;
|
|
}
|
|
.timeline-day { position: relative; margin-bottom: 24px; }
|
|
.timeline-day:last-child { margin-bottom: 0; }
|
|
.timeline-day.hidden { display: none; }
|
|
.timeline-dot {
|
|
position: absolute;
|
|
left: -32px;
|
|
top: 4px;
|
|
width: 16px;
|
|
height: 16px;
|
|
background: #FFC53D;
|
|
border: 3px solid #0d1117;
|
|
border-radius: 50%;
|
|
z-index: 1;
|
|
}
|
|
.timeline-dot.today {
|
|
background: #FFC53D;
|
|
box-shadow: 0 0 12px rgba(255, 197, 61, 0.4);
|
|
}
|
|
.timeline-dot.highlighted {
|
|
background: #f0883e;
|
|
box-shadow: 0 0 12px rgba(240, 136, 62, 0.4);
|
|
}
|
|
.day-header { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; flex-wrap: wrap; }
|
|
.day-date { font-size: 14px; font-weight: 600; color: #f0f6fc; }
|
|
.day-badge {
|
|
font-size: 11px;
|
|
padding: 2px 8px;
|
|
background: rgba(255, 197, 61, 0.15);
|
|
color: #FFC53D;
|
|
border-radius: 10px;
|
|
}
|
|
.day-stats { font-size: 12px; color: #8b949e; }
|
|
.memory-cards {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
|
gap: 12px;
|
|
}
|
|
.memory-card {
|
|
background: #0d1117;
|
|
border: 1px solid #30363d;
|
|
border-radius: 8px;
|
|
padding: 14px;
|
|
transition: border-color 0.2s, transform 0.2s;
|
|
cursor: pointer;
|
|
}
|
|
.memory-card:hover { border-color: #FFC53D; transform: translateY(-2px); }
|
|
.memory-card.hidden { display: none; }
|
|
.memory-card-header { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
|
|
.memory-emoji { font-size: 16px; line-height: 1; }
|
|
.memory-type-dot { width: 8px; height: 8px; border-radius: 50%; }
|
|
.memory-label { font-size: 13px; font-weight: 500; color: #f0f6fc; flex: 1; }
|
|
.memory-time { font-size: 11px; color: #8b949e; }
|
|
.memory-project {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
font-size: 10px;
|
|
color: #8b949e;
|
|
background: #21262d;
|
|
border: 1px solid #30363d;
|
|
border-radius: 12px;
|
|
padding: 2px 8px;
|
|
margin-bottom: 6px;
|
|
font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace;
|
|
max-width: 100%;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
.memory-content {
|
|
font-size: 12px;
|
|
color: #8b949e;
|
|
line-height: 1.5;
|
|
max-height: 100px;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
.empty-state {
|
|
text-align: center;
|
|
padding: 60px 20px;
|
|
color: #8b949e;
|
|
}
|
|
.empty-state h3 {
|
|
font-size: 18px;
|
|
color: #f0f6fc;
|
|
margin-bottom: 8px;
|
|
}
|
|
.no-results {
|
|
text-align: center;
|
|
padding: 40px 20px;
|
|
color: #8b949e;
|
|
font-size: 14px;
|
|
}
|
|
|
|
/* Modal */
|
|
.modal-overlay {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background: rgba(0, 0, 0, 0.7);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
z-index: 1000;
|
|
opacity: 0;
|
|
visibility: hidden;
|
|
transition: opacity 0.2s, visibility 0.2s;
|
|
}
|
|
.modal-overlay.visible {
|
|
opacity: 1;
|
|
visibility: visible;
|
|
}
|
|
.modal {
|
|
background: #161b22;
|
|
border: 1px solid #30363d;
|
|
border-radius: 12px;
|
|
max-width: 700px;
|
|
width: 90%;
|
|
max-height: 80vh;
|
|
overflow: hidden;
|
|
display: flex;
|
|
flex-direction: column;
|
|
transform: scale(0.95);
|
|
transition: transform 0.2s;
|
|
}
|
|
.modal-overlay.visible .modal {
|
|
transform: scale(1);
|
|
}
|
|
.modal-header {
|
|
padding: 16px 20px;
|
|
border-bottom: 1px solid #30363d;
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 12px;
|
|
}
|
|
.modal-header-content { flex: 1; }
|
|
.modal-title {
|
|
font-size: 16px;
|
|
font-weight: 600;
|
|
color: #f0f6fc;
|
|
margin-bottom: 4px;
|
|
}
|
|
.modal-meta {
|
|
font-size: 12px;
|
|
color: #8b949e;
|
|
}
|
|
.modal-close {
|
|
background: none;
|
|
border: none;
|
|
color: #8b949e;
|
|
cursor: pointer;
|
|
padding: 4px;
|
|
border-radius: 4px;
|
|
transition: color 0.2s, background 0.2s;
|
|
}
|
|
.modal-close:hover {
|
|
color: #f0f6fc;
|
|
background: #30363d;
|
|
}
|
|
.modal-body {
|
|
padding: 20px;
|
|
overflow-y: auto;
|
|
flex: 1;
|
|
}
|
|
.modal-content {
|
|
font-size: 14px;
|
|
color: #c9d1d9;
|
|
line-height: 1.7;
|
|
white-space: pre-wrap;
|
|
}
|
|
|
|
/* Conversation display in modal */
|
|
.conversation-container {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 16px;
|
|
}
|
|
.conversation-message {
|
|
padding: 12px 16px;
|
|
border-radius: 8px;
|
|
background: #0d1117;
|
|
border: 1px solid #30363d;
|
|
}
|
|
.conversation-message.user {
|
|
border-left: 3px solid #FFC53D;
|
|
}
|
|
.conversation-message.assistant {
|
|
border-left: 3px solid #FFC53D;
|
|
}
|
|
.conversation-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
margin-bottom: 8px;
|
|
font-size: 12px;
|
|
}
|
|
.conversation-speaker {
|
|
font-weight: 600;
|
|
color: #f0f6fc;
|
|
}
|
|
.conversation-speaker.user { color: #FFC53D; }
|
|
.conversation-speaker.assistant { color: #FFC53D; }
|
|
.conversation-time {
|
|
color: #8b949e;
|
|
}
|
|
.conversation-text {
|
|
font-size: 13px;
|
|
color: #c9d1d9;
|
|
line-height: 1.6;
|
|
white-space: pre-wrap;
|
|
}
|
|
.modal-section-title {
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
color: #8b949e;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
margin-bottom: 12px;
|
|
padding-bottom: 8px;
|
|
border-bottom: 1px solid #30363d;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="header">
|
|
<h1>
|
|
<span style="font-weight: 700;">EverMind</span> Memory Hub
|
|
</h1>
|
|
<div class="header-controls">
|
|
<label style="font-size: 12px; color: #8b949e;">API Key:</label>
|
|
<input type="password" id="api-key-input" class="api-input" placeholder="Enter your EverMem API key" />
|
|
<button id="load-btn" class="api-btn" onclick="loadData()">Load</button>
|
|
<button id="refresh-btn" class="refresh-btn" onclick="refreshData()" title="Refresh" style="display: none;">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M23 4v6h-6M1 20v-6h6"/>
|
|
<path d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<span class="stats" id="stats">Enter API key to load memories</span>
|
|
</div>
|
|
|
|
<div id="progress-view">
|
|
<div class="progress-container">
|
|
<div id="content">
|
|
<div class="empty-state">
|
|
<h3>Welcome to EverMem Memory Hub</h3>
|
|
<p>Enter your EverMem API key above to view your memory statistics and timeline.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Modal for full memory view -->
|
|
<div id="modal-overlay" class="modal-overlay" onclick="closeModal(event)">
|
|
<div class="modal" onclick="event.stopPropagation()">
|
|
<div class="modal-header">
|
|
<div class="modal-header-content">
|
|
<div class="modal-title" id="modal-title"></div>
|
|
<div class="modal-meta" id="modal-meta"></div>
|
|
</div>
|
|
<button class="modal-close" onclick="closeModal()">
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M18 6L6 18M6 6l12 12"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div class="modal-content" id="modal-content"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
const PROXY_URL = window.location.protocol === 'file:' ? 'http://localhost:3456' : '';
|
|
let memories = [];
|
|
let filteredMemories = [];
|
|
let apiKey = '';
|
|
let proxyAvailable = false;
|
|
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
const hashParams = new URLSearchParams(window.location.hash.slice(1));
|
|
const keyFromUrl = urlParams.get('key') || urlParams.get('apiKey') || hashParams.get('key') || hashParams.get('apiKey');
|
|
|
|
// ===== Emoji Mapping =====
|
|
const EMOJI_MAP = {
|
|
// Programming & Code
|
|
'bug': '🐛', 'debug': '🐛', 'fix': '🔧', 'error': '❌', 'crash': '💥',
|
|
'test': '🧪', 'testing': '🧪', 'unit test': '🧪', 'jest': '🧪',
|
|
'deploy': '🚀', 'deployment': '🚀', 'release': '🚀', 'launch': '🚀', 'ship': '🚀',
|
|
'refactor': '♻️', 'cleanup': '🧹', 'lint': '🧹', 'format': '🧹',
|
|
'api': '🔌', 'endpoint': '🔌', 'rest': '🔌', 'graphql': '🔌', 'webhook': '🔌',
|
|
'database': '🗄️', 'sql': '🗄️', 'query': '🗄️', 'migration': '🗄️', 'schema': '🗄️',
|
|
'auth': '🔐', 'authentication': '🔐', 'login': '🔐', 'password': '🔐', 'oauth': '🔐', 'jwt': '🔐',
|
|
'security': '🛡️', 'vulnerability': '🛡️', 'encrypt': '🛡️', 'ssl': '🛡️', 'cors': '🛡️',
|
|
'performance': '⚡', 'optimize': '⚡', 'speed': '⚡', 'cache': '⚡', 'fast': '⚡',
|
|
'config': '⚙️', 'configuration': '⚙️', 'settings': '⚙️', 'env': '⚙️', 'setup': '⚙️',
|
|
'docker': '🐳', 'container': '🐳', 'kubernetes': '🐳', 'k8s': '🐳',
|
|
'git': '📦', 'commit': '📦', 'branch': '📦', 'merge': '📦', 'pull request': '📦', 'pr': '📦',
|
|
'npm': '📦', 'package': '📦', 'dependency': '📦', 'install': '📦',
|
|
'component': '🧩', 'module': '🧩', 'plugin': '🧩', 'extension': '🧩', 'widget': '🧩',
|
|
'frontend': '🎨', 'ui': '🎨', 'css': '🎨', 'style': '🎨', 'design': '🎨', 'layout': '🎨',
|
|
'responsive': '📱', 'mobile': '📱', 'ios': '📱', 'android': '📱', 'app': '📱',
|
|
'backend': '🖥️', 'server': '🖥️', 'node': '🖥️', 'express': '🖥️',
|
|
'function': '🔣', 'method': '🔣', 'class': '🔣', 'interface': '🔣',
|
|
'variable': '📝', 'constant': '📝', 'type': '📝', 'typescript': '📝',
|
|
'import': '📥', 'export': '📤', 'require': '📥',
|
|
'loop': '🔄', 'iterate': '🔄', 'recursive': '🔄', 'async': '🔄', 'promise': '🔄',
|
|
'log': '📋', 'logging': '📋', 'monitor': '📋', 'trace': '📋', 'console': '📋',
|
|
'regex': '🔍', 'search': '🔍', 'find': '🔍', 'filter': '🔍', 'grep': '🔍',
|
|
'parse': '📖', 'json': '📖', 'xml': '📖', 'yaml': '📖', 'csv': '📖',
|
|
'file': '📄', 'read': '📄', 'write': '📄', 'stream': '📄', 'path': '📄',
|
|
'http': '🌐', 'request': '🌐', 'response': '🌐', 'fetch': '🌐', 'url': '🌐',
|
|
'websocket': '🔗', 'socket': '🔗', 'connection': '🔗', 'tcp': '🔗',
|
|
'email': '📧', 'mail': '📧', 'smtp': '📧', 'notification': '🔔',
|
|
'image': '🖼️', 'photo': '🖼️', 'icon': '🖼️', 'svg': '🖼️', 'canvas': '🖼️',
|
|
'video': '🎬', 'audio': '🎵', 'media': '🎬', 'animation': '🎬',
|
|
'chart': '📊', 'graph': '📊', 'dashboard': '📊', 'analytics': '📊', 'metric': '📊',
|
|
'table': '📋', 'list': '📋', 'grid': '📋', 'pagination': '📋',
|
|
'form': '📝', 'input': '📝', 'validation': '✅', 'submit': '📝',
|
|
'button': '🔘', 'click': '🔘', 'event': '🔘', 'handler': '🔘', 'listener': '🔘',
|
|
'modal': '🪟', 'dialog': '🪟', 'popup': '🪟', 'tooltip': '🪟',
|
|
'menu': '📑', 'navigation': '📑', 'navbar': '📑', 'sidebar': '📑', 'tab': '📑',
|
|
'theme': '🎭', 'dark mode': '🌙', 'light mode': '☀️', 'color': '🎨',
|
|
'font': '🔤', 'text': '🔤', 'typography': '🔤', 'string': '🔤',
|
|
'date': '📅', 'time': '⏰', 'timer': '⏰', 'cron': '⏰', 'schedule': '📅',
|
|
'map': '🗺️', 'location': '📍', 'gps': '📍', 'geolocation': '📍',
|
|
'payment': '💳', 'stripe': '💳', 'billing': '💳', 'invoice': '💳', 'price': '💰',
|
|
'user': '👤', 'profile': '👤', 'account': '👤', 'avatar': '👤', 'role': '👤',
|
|
'team': '👥', 'organization': '👥', 'group': '👥', 'permission': '👥',
|
|
'upload': '⬆️', 'download': '⬇️', 'sync': '🔄', 'backup': '💾',
|
|
'delete': '🗑️', 'remove': '🗑️', 'trash': '🗑️', 'clean': '🗑️',
|
|
'copy': '📋', 'clipboard': '📋', 'paste': '📋', 'duplicate': '📋',
|
|
'sort': '🔀', 'order': '🔀', 'rank': '🔀', 'priority': '🔀',
|
|
'encrypt': '🔒', 'decrypt': '🔓', 'hash': '🔒', 'token': '🔑', 'key': '🔑',
|
|
'warning': '⚠️', 'alert': '⚠️', 'caution': '⚠️',
|
|
'success': '✅', 'complete': '✅', 'done': '✅', 'pass': '✅', 'approve': '✅',
|
|
'fail': '❌', 'failure': '❌', 'reject': '❌', 'deny': '❌',
|
|
'pending': '⏳', 'loading': '⏳', 'wait': '⏳', 'progress': '⏳',
|
|
'todo': '📌', 'task': '📌', 'issue': '📌', 'ticket': '📌', 'jira': '📌',
|
|
'documentation': '📚', 'docs': '📚', 'readme': '📚', 'guide': '📚', 'tutorial': '📚',
|
|
'comment': '💬', 'review': '💬', 'feedback': '💬', 'discuss': '💬',
|
|
'ai': '🤖', 'machine learning': '🤖', 'ml': '🤖', 'model': '🤖', 'train': '🤖',
|
|
'llm': '🧠', 'gpt': '🧠', 'claude': '🧠', 'prompt': '🧠', 'embedding': '🧠',
|
|
'memory': '💭', 'remember': '💭', 'recall': '💭', 'context': '💭',
|
|
'aws': '☁️', 'cloud': '☁️', 'azure': '☁️', 'gcp': '☁️', 's3': '☁️',
|
|
'ci': '🔧', 'cd': '🔧', 'pipeline': '🔧', 'github actions': '🔧', 'workflow': '🔧',
|
|
'linux': '🐧', 'ubuntu': '🐧', 'bash': '🐧', 'shell': '🐧', 'terminal': '🐧',
|
|
'windows': '🪟', 'mac': '🍎', 'macos': '🍎',
|
|
'react': '⚛️', 'vue': '💚', 'angular': '🅰️', 'svelte': '🔥', 'next': '▲',
|
|
'python': '🐍', 'rust': '🦀', 'go': '🐹', 'java': '☕', 'ruby': '💎',
|
|
'html': '🌐', 'dom': '🌐', 'browser': '🌐', 'chrome': '🌐',
|
|
'webpack': '📦', 'vite': '⚡', 'babel': '🔄', 'rollup': '📦',
|
|
'hook': '🪝', 'middleware': '🔗', 'proxy': '🔗', 'gateway': '🔗',
|
|
'state': '🔲', 'redux': '🔲', 'store': '🔲', 'context': '🔲',
|
|
'route': '🛤️', 'router': '🛤️', 'path': '🛤️', 'redirect': '🛤️',
|
|
'scroll': '📜', 'infinite': '♾️', 'lazy': '😴', 'suspend': '😴',
|
|
'drag': '👆', 'drop': '👇', 'resize': '↔️', 'zoom': '🔎',
|
|
'math': '🔢', 'calculate': '🔢', 'number': '🔢', 'algorithm': '🔢',
|
|
'array': '📊', 'object': '📦', 'map': '🗺️', 'set': '📦',
|
|
'error handling': '🛟', 'try catch': '🛟', 'exception': '🛟',
|
|
'interview': '🎤', 'meeting': '📞', 'standup': '📞', 'retro': '📞',
|
|
'architecture': '🏗️', 'design pattern': '🏗️', 'structure': '🏗️', 'scaffold': '🏗️',
|
|
'version': '🏷️', 'semver': '🏷️', 'changelog': '🏷️', 'tag': '🏷️',
|
|
'feature': '✨', 'new': '✨', 'add': '✨', 'create': '✨', 'implement': '✨',
|
|
'update': '🔄', 'upgrade': '🔄', 'change': '🔄', 'modify': '🔄', 'edit': '🔄',
|
|
'project': '📂', 'workspace': '📂', 'repo': '📂', 'repository': '📂',
|
|
'build': '🏗️', 'compile': '🏗️', 'bundle': '🏗️', 'transpile': '🏗️',
|
|
'debug': '🐛', 'breakpoint': '🐛', 'inspect': '🐛', 'devtools': '🐛',
|
|
'network': '🌐', 'bandwidth': '🌐', 'latency': '🌐', 'dns': '🌐',
|
|
'storage': '💾', 'disk': '💾', 'filesystem': '💾', 'volume': '💾',
|
|
'queue': '📬', 'message': '📬', 'pubsub': '📬', 'kafka': '📬', 'rabbitmq': '📬',
|
|
'redis': '🔴', 'mongo': '🍃', 'postgres': '🐘', 'mysql': '🐬', 'sqlite': '📦',
|
|
'graphql': '◼️', 'apollo': '◼️', 'mutation': '◼️',
|
|
'regex': '🔍', 'pattern': '🔍', 'match': '🔍',
|
|
'accessibility': '♿', 'a11y': '♿', 'aria': '♿', 'screen reader': '♿',
|
|
'i18n': '🌍', 'locale': '🌍', 'translate': '🌍', 'internationalization': '🌍',
|
|
'seo': '🔍', 'meta': '🔍', 'sitemap': '🔍', 'robots': '🔍',
|
|
'print': '🖨️', 'pdf': '📄', 'export': '📤',
|
|
'crypto': '🔐', 'blockchain': '⛓️', 'web3': '⛓️', 'nft': '⛓️',
|
|
'game': '🎮', 'animation': '🎬', '3d': '🎮', 'webgl': '🎮',
|
|
'iot': '📡', 'sensor': '📡', 'device': '📡', 'hardware': '📡',
|
|
'data': '📊', 'dataset': '📊', 'etl': '📊', 'transform': '📊',
|
|
'pipeline': '🔧', 'automation': '🤖', 'script': '📜', 'cron': '⏰',
|
|
'documentation': '📚', 'wiki': '📚', 'knowledge': '📚', 'learn': '📚',
|
|
'brainstorm': '💡', 'idea': '💡', 'concept': '💡', 'plan': '💡', 'strategy': '💡',
|
|
'question': '❓', 'help': '❓', 'ask': '❓', 'how': '❓', 'why': '❓',
|
|
'answer': '💬', 'explain': '💬', 'clarify': '💬', 'describe': '💬',
|
|
'research': '🔬', 'investigate': '🔬', 'analyze': '🔬', 'study': '🔬',
|
|
'experiment': '🧪', 'prototype': '🧪', 'poc': '🧪', 'proof': '🧪',
|
|
'estimate': '📐', 'measure': '📐', 'benchmark': '📐', 'compare': '📐'
|
|
};
|
|
|
|
function getMemoryEmoji(text) {
|
|
const lower = text.toLowerCase();
|
|
// Try longer phrases first
|
|
const phrases = Object.keys(EMOJI_MAP).sort((a, b) => b.length - a.length);
|
|
for (const phrase of phrases) {
|
|
if (lower.includes(phrase)) {
|
|
return EMOJI_MAP[phrase];
|
|
}
|
|
}
|
|
return '💭'; // default
|
|
}
|
|
|
|
async function checkProxy() {
|
|
try {
|
|
const response = await fetch(`${PROXY_URL}/health`, { method: 'GET' });
|
|
proxyAvailable = response.ok;
|
|
} catch {
|
|
proxyAvailable = false;
|
|
}
|
|
|
|
if (!proxyAvailable) {
|
|
document.getElementById('content').innerHTML = `
|
|
<div class="error-message">
|
|
<strong>Server not running</strong>
|
|
<p style="margin-top: 8px; color: #c9d1d9;">The Memory Hub requires a local server to communicate with the EverMind API.</p>
|
|
<p style="margin-top: 8px; color: #8b949e;">Start the server with:</p>
|
|
<pre style="margin-top: 8px; padding: 12px; background: #0d1117; border-radius: 6px; font-family: monospace; color: #FFC53D;">node server/proxy.js</pre>
|
|
<p style="margin-top: 8px; color: #8b949e;">Then refresh this page.</p>
|
|
</div>
|
|
`;
|
|
document.getElementById('load-btn').disabled = true;
|
|
return;
|
|
}
|
|
|
|
if (keyFromUrl) {
|
|
document.getElementById('api-key-input').value = keyFromUrl;
|
|
setTimeout(() => loadData(), 100);
|
|
}
|
|
}
|
|
|
|
checkProxy();
|
|
|
|
async function fetchAllMemories() {
|
|
// 1. Get the list of local groups for this API key
|
|
const groupsResponse = await fetch(`${PROXY_URL}/api/groups`, {
|
|
headers: { 'Authorization': `Bearer ${apiKey}` }
|
|
});
|
|
|
|
if (!groupsResponse.ok) {
|
|
throw new Error(`Failed to load groups: ${groupsResponse.status} ${groupsResponse.statusText}`);
|
|
}
|
|
|
|
const groupsData = await groupsResponse.json();
|
|
const groups = groupsData.groups || [];
|
|
|
|
if (groups.length === 0) {
|
|
// No locally-tracked groups → nothing to show. v1 requires a concrete filter
|
|
// (user_id or group_id), so there is no "fetch everything" path.
|
|
return { data: { episodes: [] } };
|
|
}
|
|
|
|
// 2. For each group, paginate /api/v1/memories/get
|
|
const allEpisodes = [];
|
|
const PAGE_SIZE = 100;
|
|
|
|
for (const group of groups) {
|
|
let page = 1;
|
|
while (true) {
|
|
const response = await fetch(`${PROXY_URL}/api/v1/memories/get`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Bearer ${apiKey}`,
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({
|
|
memory_type: 'episodic_memory',
|
|
filters: { group_id: group.id },
|
|
page,
|
|
page_size: PAGE_SIZE,
|
|
rank_by: 'timestamp',
|
|
rank_order: 'desc'
|
|
})
|
|
});
|
|
|
|
if (!response.ok) {
|
|
if (page === 1) {
|
|
const errorData = await response.json().catch(() => ({}));
|
|
// Surface error only for the first failure per group, then skip it
|
|
console.warn(`Group ${group.id} failed:`, errorData.message || response.status);
|
|
}
|
|
break;
|
|
}
|
|
|
|
const payload = await response.json();
|
|
const episodes = payload?.data?.episodes || [];
|
|
if (episodes.length === 0) break;
|
|
|
|
// Attach group_id to each episode so the UI can filter/label by project
|
|
for (const ep of episodes) {
|
|
if (!ep.group_id) ep.group_id = group.id;
|
|
}
|
|
allEpisodes.push(...episodes);
|
|
|
|
const total = payload?.data?.total_count ?? Infinity;
|
|
if (episodes.length < PAGE_SIZE || page * PAGE_SIZE >= total) break;
|
|
page += 1;
|
|
}
|
|
}
|
|
|
|
return { data: { episodes: allEpisodes } };
|
|
}
|
|
|
|
async function loadData() {
|
|
if (!proxyAvailable) {
|
|
alert('Server not running. Please start it first.');
|
|
return;
|
|
}
|
|
|
|
apiKey = document.getElementById('api-key-input').value.trim();
|
|
if (!apiKey) {
|
|
alert('Please enter an API key');
|
|
return;
|
|
}
|
|
|
|
const loadBtn = document.getElementById('load-btn');
|
|
loadBtn.disabled = true;
|
|
loadBtn.textContent = 'Loading...';
|
|
|
|
document.getElementById('content').innerHTML = `
|
|
<div class="loading-message">
|
|
<div class="spinner"></div>
|
|
<p>Loading memories from EverMem Cloud...</p>
|
|
</div>
|
|
`;
|
|
|
|
try {
|
|
const data = await fetchAllMemories();
|
|
memories = transformApiResponse(data);
|
|
filteredMemories = [...memories];
|
|
|
|
if (memories.length === 0) {
|
|
document.getElementById('content').innerHTML = `
|
|
<div class="empty-state">
|
|
<h3>No Memories Found</h3>
|
|
<p>Your EverMem account doesn't have any memories yet. Start using Claude Code with the EverMem plugin to build your memory.</p>
|
|
</div>
|
|
`;
|
|
document.getElementById('stats').textContent = '0 memories';
|
|
} else {
|
|
// Show refresh button
|
|
document.getElementById('refresh-btn').style.display = 'flex';
|
|
renderDashboard();
|
|
populateProjectFilter();
|
|
}
|
|
} catch (error) {
|
|
document.getElementById('content').innerHTML = `
|
|
<div class="error-message">
|
|
<strong>Error loading memories:</strong> ${error.message}
|
|
</div>
|
|
<div class="empty-state">
|
|
<p>Please check your API key and try again.</p>
|
|
</div>
|
|
`;
|
|
} finally {
|
|
loadBtn.disabled = false;
|
|
loadBtn.textContent = 'Load';
|
|
}
|
|
}
|
|
|
|
async function refreshData() {
|
|
const refreshBtn = document.getElementById('refresh-btn');
|
|
refreshBtn.classList.add('spinning');
|
|
refreshBtn.disabled = true;
|
|
await loadData();
|
|
refreshBtn.classList.remove('spinning');
|
|
refreshBtn.disabled = false;
|
|
}
|
|
|
|
function populateProjectFilter() {
|
|
const select = document.getElementById('project-filter');
|
|
const groups = new Set();
|
|
memories.forEach(m => { if (m.groupId) groups.add(m.groupId); });
|
|
|
|
select.innerHTML = '<option value="">All Projects</option>';
|
|
groups.forEach(g => {
|
|
const displayName = g.replace(/^claude-code:/, '');
|
|
const option = document.createElement('option');
|
|
option.value = g;
|
|
option.textContent = displayName.length > 40 ? '...' + displayName.slice(-40) : displayName;
|
|
select.appendChild(option);
|
|
});
|
|
}
|
|
|
|
function filterMemories() {
|
|
const searchTerm = document.getElementById('search-input').value.toLowerCase();
|
|
const projectFilter = document.getElementById('project-filter').value;
|
|
|
|
filteredMemories = memories.filter(m => {
|
|
const matchesSearch = !searchTerm ||
|
|
m.content.toLowerCase().includes(searchTerm) ||
|
|
m.subject.toLowerCase().includes(searchTerm);
|
|
const matchesProject = !projectFilter || m.groupId === projectFilter;
|
|
return matchesSearch && matchesProject;
|
|
});
|
|
|
|
renderTimeline();
|
|
updateStats();
|
|
}
|
|
|
|
function updateStats() {
|
|
const showing = filteredMemories.length;
|
|
const total = memories.length;
|
|
const suffix = showing === total ? '' : ` (showing ${showing})`;
|
|
document.getElementById('stats').textContent =
|
|
`${total} memories${suffix} | Last updated: ${new Date().toLocaleTimeString()}`;
|
|
}
|
|
|
|
function transformApiResponse(apiResponse) {
|
|
const episodes = apiResponse?.data?.episodes;
|
|
if (!Array.isArray(episodes)) return [];
|
|
|
|
return episodes.map((ep, i) => {
|
|
// v1 get → content in ep.episode; v1 search → content in ep.summary.
|
|
// Accept both so this transform works regardless of upstream endpoint.
|
|
const content = ep.episode || ep.summary || ep.content || '';
|
|
const timestamp = ep.timestamp || ep.create_time || new Date().toISOString();
|
|
const date = new Date(timestamp);
|
|
|
|
return {
|
|
id: ep.id || ep.message_id || `mem_${i}`,
|
|
content,
|
|
subject: ep.subject || '',
|
|
groupId: ep.group_id || '',
|
|
participants: ep.participants || [],
|
|
timestamp,
|
|
date: formatLocalDate(date),
|
|
time: date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }),
|
|
score: ep.score || 0,
|
|
memoryType: ep.memory_type,
|
|
summary: ep.summary || ''
|
|
};
|
|
}).filter(m => m.content);
|
|
}
|
|
|
|
function renderDashboard() {
|
|
const stats = calculateStats();
|
|
updateStats();
|
|
|
|
document.getElementById('content').innerHTML = `
|
|
<div class="stats-grid">
|
|
<div class="stat-card">
|
|
<div class="label">Total Memories</div>
|
|
<div class="value">${stats.totalMemories}</div>
|
|
<div class="trend">${stats.memoriesThisWeek > 0 ? `+${stats.memoriesThisWeek} this week` : 'No new this week'}</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="label">Projects</div>
|
|
<div class="value">${stats.uniqueGroups}</div>
|
|
<div class="trend neutral">Unique workspaces</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="label">Active Days</div>
|
|
<div class="value">${stats.activeDays}</div>
|
|
<div class="trend neutral">Streak: ${stats.currentStreak} ${stats.currentStreak === 1 ? 'day' : 'days'}</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="label">Avg / Day</div>
|
|
<div class="value">${stats.avgPerDay}</div>
|
|
<div class="trend neutral">Memories per active day</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="charts-row">
|
|
<div class="heatmap-section">
|
|
<div class="section-title">Memory Activity (Last 6 Months)</div>
|
|
<div class="heatmap-months" id="heatmap-months"></div>
|
|
<div class="heatmap-body">
|
|
<div class="heatmap-days">
|
|
<span></span>
|
|
<span>Mon</span>
|
|
<span></span>
|
|
<span>Wed</span>
|
|
<span></span>
|
|
<span>Fri</span>
|
|
<span></span>
|
|
</div>
|
|
<div class="heatmap-grid" id="heatmap"></div>
|
|
</div>
|
|
<div class="heatmap-legend">
|
|
Less
|
|
<div class="heatmap-legend-cell" style="background: #161b22; border: 1px solid #21262d;"></div>
|
|
<div class="heatmap-legend-cell" style="background: #4d3a10;"></div>
|
|
<div class="heatmap-legend-cell" style="background: #806200;"></div>
|
|
<div class="heatmap-legend-cell" style="background: #cc9a00;"></div>
|
|
<div class="heatmap-legend-cell" style="background: #FFC53D;"></div>
|
|
More
|
|
</div>
|
|
<div class="heatmap-tooltip" id="heatmap-tooltip"></div>
|
|
</div>
|
|
|
|
<div class="growth-section">
|
|
<div class="section-title">Last 7 Days</div>
|
|
<div class="growth-chart" id="growth-chart"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="timeline-section">
|
|
<div class="section-title" style="margin-bottom: 16px;">Daily Memory Timeline</div>
|
|
<div class="timeline-filters" style="display: flex; gap: 12px; margin-bottom: 20px;">
|
|
<input type="text" id="search-input" class="search-input" placeholder="Search memories..." style="flex: 1; max-width: 300px;" oninput="filterMemories()" />
|
|
<select id="project-filter" class="filter-select" onchange="filterMemories()">
|
|
<option value="">All Projects</option>
|
|
</select>
|
|
</div>
|
|
<div class="timeline" id="timeline"></div>
|
|
</div>
|
|
`;
|
|
|
|
buildHeatmap(stats.memoriesByDate);
|
|
buildGrowthChart(stats.memoriesByDate);
|
|
buildTimeline();
|
|
}
|
|
|
|
function calculateStats() {
|
|
const now = new Date();
|
|
const oneWeekAgo = new Date(now);
|
|
oneWeekAgo.setDate(oneWeekAgo.getDate() - 7);
|
|
|
|
const memoriesByDate = {};
|
|
const uniqueGroups = new Set();
|
|
let memoriesThisWeek = 0;
|
|
|
|
memories.forEach(mem => {
|
|
memoriesByDate[mem.date] = (memoriesByDate[mem.date] || 0) + 1;
|
|
if (mem.groupId) uniqueGroups.add(mem.groupId);
|
|
|
|
const memDate = new Date(mem.date);
|
|
if (memDate >= oneWeekAgo) memoriesThisWeek++;
|
|
});
|
|
|
|
let currentStreak = 0;
|
|
const todayStr = formatLocalDate(now);
|
|
let checkDate = new Date(now);
|
|
|
|
while (true) {
|
|
const dateStr = formatLocalDate(checkDate);
|
|
if (memoriesByDate[dateStr]) {
|
|
currentStreak++;
|
|
checkDate.setDate(checkDate.getDate() - 1);
|
|
} else if (dateStr !== todayStr) {
|
|
break;
|
|
} else {
|
|
checkDate.setDate(checkDate.getDate() - 1);
|
|
}
|
|
}
|
|
|
|
const activeDays = Object.keys(memoriesByDate).length;
|
|
const avgPerDay = activeDays > 0 ? (memories.length / activeDays).toFixed(1) : 0;
|
|
|
|
return {
|
|
totalMemories: memories.length,
|
|
uniqueGroups: uniqueGroups.size,
|
|
activeDays,
|
|
avgPerDay,
|
|
memoriesThisWeek,
|
|
currentStreak,
|
|
memoriesByDate
|
|
};
|
|
}
|
|
|
|
function buildHeatmap(memoriesByDate) {
|
|
const heatmapContainer = document.getElementById('heatmap');
|
|
const monthsContainer = document.getElementById('heatmap-months');
|
|
const today = new Date();
|
|
|
|
// 6 months = ~182 days
|
|
const startDate = new Date(today);
|
|
startDate.setDate(startDate.getDate() - 182);
|
|
while (startDate.getDay() !== 0) {
|
|
startDate.setDate(startDate.getDate() - 1);
|
|
}
|
|
|
|
// Calculate dynamic thresholds based on actual data
|
|
const counts = Object.values(memoriesByDate).filter(c => c > 0);
|
|
let t1 = 1, t2 = 2, t3 = 3, t4 = 4;
|
|
if (counts.length > 0) {
|
|
counts.sort((a, b) => a - b);
|
|
const q1 = counts[Math.floor(counts.length * 0.25)] || 1;
|
|
const q2 = counts[Math.floor(counts.length * 0.5)] || 2;
|
|
const q3 = counts[Math.floor(counts.length * 0.75)] || 3;
|
|
t1 = Math.max(1, q1);
|
|
t2 = Math.max(t1 + 1, q2);
|
|
t3 = Math.max(t2 + 1, q3);
|
|
t4 = t3 + 1;
|
|
}
|
|
|
|
// Build weeks (columns)
|
|
const weeks = [];
|
|
let currentWeek = [];
|
|
const months = new Map();
|
|
let currentDate = new Date(startDate);
|
|
|
|
while (currentDate <= today) {
|
|
const dateStr = formatLocalDate(currentDate);
|
|
const monthKey = currentDate.toLocaleDateString('en-US', { month: 'short' });
|
|
|
|
if (!months.has(monthKey)) {
|
|
months.set(monthKey, weeks.length);
|
|
}
|
|
|
|
currentWeek.push({
|
|
date: dateStr,
|
|
count: memoriesByDate[dateStr] || 0,
|
|
dayOfWeek: currentDate.getDay()
|
|
});
|
|
|
|
if (currentDate.getDay() === 6) {
|
|
weeks.push(currentWeek);
|
|
currentWeek = [];
|
|
}
|
|
|
|
currentDate.setDate(currentDate.getDate() + 1);
|
|
}
|
|
|
|
if (currentWeek.length > 0) {
|
|
weeks.push(currentWeek);
|
|
}
|
|
|
|
// Render month labels
|
|
monthsContainer.innerHTML = '';
|
|
const weekWidth = 16;
|
|
|
|
months.forEach((weekIndex, monthName) => {
|
|
const span = document.createElement('span');
|
|
span.textContent = monthName;
|
|
span.style.left = `${weekIndex * weekWidth}px`;
|
|
monthsContainer.appendChild(span);
|
|
});
|
|
|
|
// Render weeks (columns)
|
|
heatmapContainer.innerHTML = '';
|
|
weeks.forEach((week, weekIndex) => {
|
|
const weekDiv = document.createElement('div');
|
|
weekDiv.className = 'heatmap-week';
|
|
|
|
if (weekIndex === 0 && week[0].dayOfWeek !== 0) {
|
|
for (let i = 0; i < week[0].dayOfWeek; i++) {
|
|
const emptyCell = document.createElement('div');
|
|
emptyCell.className = 'heatmap-cell';
|
|
emptyCell.style.visibility = 'hidden';
|
|
weekDiv.appendChild(emptyCell);
|
|
}
|
|
}
|
|
|
|
week.forEach(day => {
|
|
const cell = document.createElement('div');
|
|
cell.className = 'heatmap-cell';
|
|
cell.dataset.date = day.date;
|
|
cell.dataset.count = day.count;
|
|
|
|
if (day.count >= t4) cell.classList.add('level-4');
|
|
else if (day.count >= t3) cell.classList.add('level-3');
|
|
else if (day.count >= t2) cell.classList.add('level-2');
|
|
else if (day.count >= t1) cell.classList.add('level-1');
|
|
|
|
cell.addEventListener('mouseenter', showHeatmapTooltip);
|
|
cell.addEventListener('mouseleave', hideHeatmapTooltip);
|
|
cell.addEventListener('click', () => scrollToDay(day.date));
|
|
weekDiv.appendChild(cell);
|
|
});
|
|
|
|
if (weekIndex === weeks.length - 1 && week[week.length - 1].dayOfWeek !== 6) {
|
|
for (let i = week[week.length - 1].dayOfWeek + 1; i <= 6; i++) {
|
|
const emptyCell = document.createElement('div');
|
|
emptyCell.className = 'heatmap-cell';
|
|
emptyCell.style.visibility = 'hidden';
|
|
weekDiv.appendChild(emptyCell);
|
|
}
|
|
}
|
|
|
|
heatmapContainer.appendChild(weekDiv);
|
|
});
|
|
}
|
|
|
|
function scrollToDay(dateStr) {
|
|
const dayEl = document.querySelector(`.timeline-day[data-date="${dateStr}"]`);
|
|
if (dayEl) {
|
|
// Remove any previous highlights
|
|
document.querySelectorAll('.timeline-dot.highlighted').forEach(d => d.classList.remove('highlighted'));
|
|
// Highlight the target day
|
|
const dot = dayEl.querySelector('.timeline-dot');
|
|
if (dot) dot.classList.add('highlighted');
|
|
// Scroll into view
|
|
dayEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
// Remove highlight after 2 seconds
|
|
setTimeout(() => { if (dot) dot.classList.remove('highlighted'); }, 2000);
|
|
}
|
|
}
|
|
|
|
function showHeatmapTooltip(e) {
|
|
const tooltip = document.getElementById('heatmap-tooltip');
|
|
const cell = e.target;
|
|
const date = cell.dataset.date;
|
|
const count = parseInt(cell.dataset.count);
|
|
|
|
const dateObj = new Date(date + 'T12:00:00');
|
|
const formattedDate = dateObj.toLocaleDateString('en-US', {
|
|
weekday: 'short',
|
|
month: 'short',
|
|
day: 'numeric',
|
|
year: 'numeric'
|
|
});
|
|
|
|
const memoryText = count === 1 ? 'memory' : 'memories';
|
|
tooltip.innerHTML = `<strong>${count} ${memoryText}</strong> on ${formattedDate}`;
|
|
tooltip.style.display = 'block';
|
|
|
|
const rect = cell.getBoundingClientRect();
|
|
tooltip.style.left = `${rect.left + rect.width / 2}px`;
|
|
tooltip.style.top = `${rect.top - 40}px`;
|
|
tooltip.style.transform = 'translateX(-50%)';
|
|
}
|
|
|
|
function hideHeatmapTooltip() {
|
|
const tooltip = document.getElementById('heatmap-tooltip');
|
|
tooltip.style.display = 'none';
|
|
}
|
|
|
|
function buildGrowthChart(memoriesByDate) {
|
|
const chartContainer = document.getElementById('growth-chart');
|
|
const today = new Date();
|
|
const weekdays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
|
|
const dailyData = [];
|
|
for (let i = 6; i >= 0; i--) {
|
|
const date = new Date(today);
|
|
date.setDate(date.getDate() - i);
|
|
const dateStr = formatLocalDate(date);
|
|
dailyData.push({
|
|
date: weekdays[date.getDay()],
|
|
count: memoriesByDate[dateStr] || 0
|
|
});
|
|
}
|
|
|
|
const maxCount = Math.max(...dailyData.map(d => d.count), 1);
|
|
|
|
dailyData.forEach(day => {
|
|
const barContainer = document.createElement('div');
|
|
barContainer.className = 'chart-bar-container';
|
|
|
|
const barWrapper = document.createElement('div');
|
|
barWrapper.className = 'chart-bar-wrapper';
|
|
|
|
const bar = document.createElement('div');
|
|
bar.className = 'chart-bar';
|
|
bar.style.height = day.count > 0 ? `${(day.count / maxCount) * 100}%` : '4px';
|
|
bar.style.opacity = day.count > 0 ? '1' : '0.2';
|
|
|
|
const barTooltip = document.createElement('div');
|
|
barTooltip.className = 'bar-tooltip';
|
|
barTooltip.textContent = `${day.count} memories`;
|
|
bar.appendChild(barTooltip);
|
|
|
|
const label = document.createElement('div');
|
|
label.className = 'chart-label';
|
|
label.textContent = day.date;
|
|
|
|
barWrapper.appendChild(bar);
|
|
barContainer.appendChild(barWrapper);
|
|
barContainer.appendChild(label);
|
|
chartContainer.appendChild(barContainer);
|
|
});
|
|
}
|
|
|
|
function buildTimeline() {
|
|
renderTimeline();
|
|
}
|
|
|
|
function renderTimeline() {
|
|
const timelineContainer = document.getElementById('timeline');
|
|
if (!timelineContainer) return;
|
|
|
|
const byDate = {};
|
|
filteredMemories.forEach(mem => {
|
|
if (!byDate[mem.date]) byDate[mem.date] = [];
|
|
byDate[mem.date].push(mem);
|
|
});
|
|
|
|
const dates = Object.keys(byDate).sort().reverse();
|
|
const todayStr = formatLocalDate(new Date());
|
|
|
|
if (dates.length === 0) {
|
|
timelineContainer.innerHTML = '<div class="no-results">No memories match your search.</div>';
|
|
return;
|
|
}
|
|
|
|
timelineContainer.innerHTML = '';
|
|
|
|
dates.forEach(date => {
|
|
const dayMemories = byDate[date].sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
|
|
const dayDiv = document.createElement('div');
|
|
dayDiv.className = 'timeline-day';
|
|
dayDiv.dataset.date = date;
|
|
|
|
const dot = document.createElement('div');
|
|
dot.className = 'timeline-dot' + (date === todayStr ? ' today' : '');
|
|
dayDiv.appendChild(dot);
|
|
|
|
const header = document.createElement('div');
|
|
header.className = 'day-header';
|
|
|
|
const dateDisplay = new Date(date + 'T12:00:00').toLocaleDateString('en-US', {
|
|
weekday: 'long', month: 'short', day: 'numeric'
|
|
});
|
|
|
|
header.innerHTML = `
|
|
<span class="day-date">${dateDisplay}</span>
|
|
${date === todayStr ? '<span class="day-badge">Today</span>' : ''}
|
|
<span class="day-stats">${dayMemories.length} ${dayMemories.length === 1 ? 'memory' : 'memories'}</span>
|
|
`;
|
|
dayDiv.appendChild(header);
|
|
|
|
const cardsDiv = document.createElement('div');
|
|
cardsDiv.className = 'memory-cards';
|
|
|
|
dayMemories.forEach(memory => {
|
|
const card = document.createElement('div');
|
|
card.className = 'memory-card';
|
|
card.onclick = () => openModal(memory);
|
|
|
|
const displaySubject = memory.subject || (memory.content.length > 40 ? memory.content.slice(0, 40) + '...' : memory.content);
|
|
const displayContent = memory.content.length > 150 ? memory.content.slice(0, 150) + '...' : memory.content;
|
|
const projectName = memory.groupId ? memory.groupId.replace(/^claude-code:/, '') : '';
|
|
const emoji = getMemoryEmoji(memory.subject || memory.content);
|
|
|
|
card.innerHTML = `
|
|
<div class="memory-card-header">
|
|
<span class="memory-emoji">${emoji}</span>
|
|
<span class="memory-label">${escapeHtml(displaySubject)}</span>
|
|
<span class="memory-time">${memory.time}</span>
|
|
</div>
|
|
${projectName ? `<div class="memory-project">\uD83D\uDCC1 ${escapeHtml(projectName)}</div>` : ''}
|
|
<div class="memory-content">${escapeHtml(displayContent)}</div>
|
|
`;
|
|
cardsDiv.appendChild(card);
|
|
});
|
|
|
|
dayDiv.appendChild(cardsDiv);
|
|
timelineContainer.appendChild(dayDiv);
|
|
});
|
|
}
|
|
|
|
function openModal(memory) {
|
|
const projectName = memory.groupId ? memory.groupId.replace(/^claude-code:/, '') : '';
|
|
const dateStr = new Date(memory.timestamp).toLocaleDateString('en-US', {
|
|
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit'
|
|
});
|
|
|
|
document.getElementById('modal-title').textContent = memory.subject || 'Memory';
|
|
document.getElementById('modal-meta').innerHTML = `${dateStr}${projectName ? ` · <span style="font-family: monospace;">${escapeHtml(projectName)}</span>` : ''}`;
|
|
document.getElementById('modal-content').textContent = memory.content;
|
|
|
|
document.getElementById('modal-overlay').classList.add('visible');
|
|
document.body.style.overflow = 'hidden';
|
|
}
|
|
|
|
function closeModal(event) {
|
|
if (event && event.target !== event.currentTarget) return;
|
|
document.getElementById('modal-overlay').classList.remove('visible');
|
|
document.body.style.overflow = '';
|
|
}
|
|
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Escape') closeModal();
|
|
});
|
|
|
|
function escapeHtml(text) {
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
// Format date as YYYY-MM-DD using local timezone
|
|
function formatLocalDate(date) {
|
|
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|