349 lines
18 KiB
HTML
349 lines
18 KiB
HTML
{% extends "layout.html" %}
|
|
{% block content %}
|
|
<div style="display: flex; flex-wrap: wrap; gap: 2rem; align-items: flex-start;">
|
|
|
|
<!-- Left Column: Log Entries -->
|
|
<div style="flex: 1; min-width: 300px;">
|
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem;">
|
|
<h2 style="margin: 0;">Logbook</h2>
|
|
<button onclick="document.getElementById('manualEntryForm').style.display = document.getElementById('manualEntryForm').style.display === 'none' ? 'block' : 'none'"
|
|
class="btn" style="padding: 5px 12px; font-weight: bold;">
|
|
+ Add Entry
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Manual Entry Form (Hidden by default) -->
|
|
<div id="manualEntryForm" style="display: none; margin-bottom: 2rem; background: #fff; padding: 1.5rem; border: 1px solid var(--border-dim); border-radius: var(--radius-md); box-shadow: 0 4px 6px rgba(0,0,0,0.05);">
|
|
<h3 style="margin-top: 0; margin-bottom: 1rem; font-size: 1.1rem;">Add Log Entry Retrospectively</h3>
|
|
<form action="{{ url_for('add_manual_entry') }}" method="POST">
|
|
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 1rem;">
|
|
<div>
|
|
<label style="display: block; margin-bottom: 0.5rem; font-size: 0.9rem;">Activity</label>
|
|
<select name="activity_id" id="activitySelect" onchange="updateSubcategories()" required style="width: 100%; padding: 8px; border: 1px solid var(--border-dim); border-radius: 4px;">
|
|
<option value="" disabled selected>Select Activity</option>
|
|
{% for activity in activities %}
|
|
<option value="{{ activity._id }}" data-subcategories='{{ activity.get("subcategories", [])|tojson }}'>{{ activity.name }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label style="display: block; margin-bottom: 0.5rem; font-size: 0.9rem;">Subcategory</label>
|
|
<!-- Text Input (Default / Fallback) -->
|
|
<input type="text" id="subcatInput" name="subcategory" placeholder="e.g. Development" style="width: 100%; padding: 8px; border: 1px solid var(--border-dim); border-radius: 4px;">
|
|
|
|
<!-- Select Input (Hidden by default) -->
|
|
<select id="subcatSelect" name="subcategory_select" disabled style="display: none; width: 100%; padding: 8px; border: 1px solid var(--border-dim); border-radius: 4px;">
|
|
<option value="">-- Select Subcategory --</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 1rem;">
|
|
<div>
|
|
<label style="display: block; margin-bottom: 0.5rem; font-size: 0.9rem;">Start Time</label>
|
|
<input type="datetime-local" name="start_time" required class="date-input" style="width: 100%; padding: 8px; border: 1px solid var(--border-dim); border-radius: 4px;">
|
|
</div>
|
|
<div>
|
|
<label style="display: block; margin-bottom: 0.5rem; font-size: 0.9rem;">End Time</label>
|
|
<input type="datetime-local" name="end_time" required class="date-input" style="width: 100%; padding: 8px; border: 1px solid var(--border-dim); border-radius: 4px;">
|
|
</div>
|
|
</div>
|
|
|
|
<div style="margin-bottom: 1rem;">
|
|
<label style="display: block; margin-bottom: 0.5rem; font-size: 0.9rem;">Note</label>
|
|
<textarea name="note" rows="2" style="width: 100%; padding: 8px; border: 1px solid var(--border-dim); border-radius: 4px; resize: vertical;"></textarea>
|
|
</div>
|
|
|
|
<div style="text-align: right;">
|
|
<button type="button" onclick="document.getElementById('manualEntryForm').style.display = 'none'" class="btn" style="background: transparent; color: #666; margin-right: 0.5rem;">Cancel</button>
|
|
<button type="submit" class="btn">Save Entry</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
|
|
<!-- Search Bar -->
|
|
<div style="margin-bottom: 2rem;">
|
|
<input type="text" id="logSearchInput" placeholder="Search logbook (Activity, Subcategory, Notes)..." onkeyup="filterLog()"
|
|
style="background-image: url('data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20fill%3D%22none%22%20stroke%3D%22%23999%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Ccircle%20cx%3D%2211%22%20cy%3D%2211%22%20r%3D%228%22%2F%3E%3Cline%20x1%3D%2221%22%20y1%3D%2221%22%20x2%3D%2216.65%22%20y2%3D%2216.65%22%2F%3E%3C%2Fsvg%3E'); background-repeat: no-repeat; background-position: 10px center; background-size: 16px; padding-left: 36px;">
|
|
</div>
|
|
|
|
{% if not log %}
|
|
<p>No activities recorded yet.</p>
|
|
{% else %}
|
|
<div id="logbook-container">
|
|
{% for entry in log %}
|
|
<!-- Wrapped whole card in a link to the detail view -->
|
|
<a href="{{ url_for('log_entry_detail', entry_id=entry._id) }}" class="log-entry-item" style="text-decoration: none; color: inherit; display: block;">
|
|
<!-- Highlight running entries with a different border style or shadow -->
|
|
<div class="card" style="border-left: 5px solid {{ entry.activity.color }}; transition: transform 0.1s; {% if entry.is_running %}background: #fffdf8; border: 1px solid #ffd700; border-left-width: 5px;{% endif %}">
|
|
<div style="display: flex; justify-content: space-between; align-items: center;">
|
|
<div>
|
|
<h3 style="margin: 0;" class="entry-title">
|
|
{{ entry.activity.name }}
|
|
{% if entry.subcategory %}
|
|
<span class="entry-subcat" style="font-size: 0.8rem; background: #eee; padding: 2px 8px; border-radius: 10px; color: #555; vertical-align: middle;">
|
|
{{ entry.subcategory }}
|
|
</span>
|
|
{% endif %}
|
|
{% if entry.is_running %}
|
|
<span style="font-size: 0.7rem; background: #27ae60; color: white; padding: 2px 6px; border-radius: 4px; vertical-align: middle; margin-left: 5px; text-transform: uppercase; letter-spacing: 0.5px;">
|
|
Running
|
|
</span>
|
|
{% endif %}
|
|
</h3>
|
|
<small style="color: #666;">
|
|
{{ entry.start_time.strftime('%Y-%m-%d %H:%M') }} -
|
|
{% if entry.is_running %}
|
|
<span style="color: #27ae60; font-weight: bold;">Now</span>
|
|
{% else %}
|
|
{{ entry.end_time.strftime('%H:%M') }}
|
|
{% endif %}
|
|
</small>
|
|
{% if entry.note %}
|
|
<p class="entry-note" style="margin: 5px 0; font-style: italic; color: #555;">"{{ entry.note }}"</p>
|
|
{% else %}
|
|
<span class="entry-note" style="display:none;"></span>
|
|
{% endif %}
|
|
</div>
|
|
<div style="font-weight: bold; font-size: 1.2rem; text-align: right;">
|
|
<!-- Live ticking class and data attribute -->
|
|
<span class="{% if entry.is_running %}live-timer{% endif %}" {% if entry.is_running %}data-start="{{ entry.start_time.timestamp() * 1000 }}"{% endif %}>
|
|
{{ entry.duration_str }}
|
|
</span>
|
|
{% if entry.is_running %}
|
|
<div style="font-size: 0.7rem; color: #27ae60; animation: pulse 2s infinite;">● Live</div>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
|
|
{% if entry.tasks %}
|
|
<div style="margin-top: 10px; padding-top: 10px; border-top: 1px solid #eee;">
|
|
<strong>Tasks Completed:</strong>
|
|
<ul style="margin: 5px 0; padding-left: 20px;">
|
|
{% for task in entry.tasks %}
|
|
<li>{{ task.name }} <small>({{ task.completed_at.strftime('%H:%M') if task.completed_at else 'Done' }})</small></li>
|
|
{% endfor %}
|
|
</ul>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
</a>
|
|
{% endfor %}
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<!-- Right Column: Statistics Sidebar -->
|
|
<div style="flex: 0 0 350px; position: sticky; top: 100px; max-width: 100%;">
|
|
<div class="card">
|
|
<h3 style="margin-bottom: 1rem;">Time Distribution</h3>
|
|
|
|
<!-- Total Hours Display -->
|
|
<div style="text-align: center; margin-bottom: 1.5rem; padding-bottom: 1.5rem; border-bottom: 1px solid var(--border-dim);">
|
|
<div style="font-size: 3rem; font-weight: 700; color: var(--text-primary); line-height: 1;">{{ total_time_display }}</div>
|
|
<div style="font-size: 0.85rem; color: var(--text-secondary); margin-top: 5px;">Total Tracked</div>
|
|
</div>
|
|
|
|
<!-- Range Selector -->
|
|
<div style="display: flex; gap: 5px; margin-bottom: 1.5rem; justify-content: center;">
|
|
<a href="{{ url_for('logbook', range='24h') }}" class="btn" style="padding: 4px 10px; font-size: 0.8rem; {% if current_range != '24h' %}background: transparent; color: var(--text-secondary); border: 1px solid var(--border-dim);{% endif %}">24h</a>
|
|
<a href="{{ url_for('logbook', range='week') }}" class="btn" style="padding: 4px 10px; font-size: 0.8rem; {% if current_range != 'week' %}background: transparent; color: var(--text-secondary); border: 1px solid var(--border-dim);{% endif %}">Week</a>
|
|
<a href="{{ url_for('logbook', range='month') }}" class="btn" style="padding: 4px 10px; font-size: 0.8rem; {% if current_range != 'month' %}background: transparent; color: var(--text-secondary); border: 1px solid var(--border-dim);{% endif %}">Month</a>
|
|
<a href="{{ url_for('logbook', range='year') }}" class="btn" style="padding: 4px 10px; font-size: 0.8rem; {% if current_range != 'year' %}background: transparent; color: var(--text-secondary); border: 1px solid var(--border-dim);{% endif %}">Year</a>
|
|
</div>
|
|
|
|
{% if chart_data.chart_values|sum > 0 %}
|
|
<canvas id="timeChart" width="300" height="300"></canvas>
|
|
{% else %}
|
|
<p style="text-align: center; color: var(--text-secondary); padding: 2rem 0;">No data for this period.</p>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<!-- Scripts -->
|
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
|
<style>
|
|
@keyframes pulse {
|
|
0% { opacity: 1; }
|
|
50% { opacity: 0.5; }
|
|
100% { opacity: 1; }
|
|
}
|
|
</style>
|
|
<script>
|
|
// Live Timer Update
|
|
setInterval(() => {
|
|
document.querySelectorAll('.live-timer').forEach(el => {
|
|
const start = parseInt(el.getAttribute('data-start'));
|
|
if (!start) return;
|
|
|
|
const diff = Date.now() - start;
|
|
if (diff < 0) return;
|
|
|
|
const hours = Math.floor(diff / (1000 * 60 * 60));
|
|
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
|
|
const seconds = Math.floor((diff % (1000 * 60)) / 1000);
|
|
|
|
el.textContent =
|
|
(hours < 10 ? "0" + hours : hours) + ":" +
|
|
(minutes < 10 ? "0" + minutes : minutes) + ":" +
|
|
(seconds < 10 ? "0" + seconds : seconds);
|
|
});
|
|
}, 1000);
|
|
|
|
// Initialize datetime inputs with safe defaults if needed
|
|
window.addEventListener('load', () => {
|
|
const now = new Date();
|
|
now.setMinutes(now.getMinutes() - now.getTimezoneOffset());
|
|
const nowStr = now.toISOString().slice(0, 16);
|
|
|
|
// Optionally set default values for inputs that are empty
|
|
document.querySelectorAll('input[type="datetime-local"].date-input').forEach(input => {
|
|
if (!input.value) input.value = nowStr;
|
|
});
|
|
|
|
// Initialize subcategories check
|
|
updateSubcategories();
|
|
});
|
|
|
|
function updateSubcategories() {
|
|
const activitySelect = document.getElementById('activitySelect');
|
|
const subcatInput = document.getElementById('subcatInput');
|
|
const subcatSelect = document.getElementById('subcatSelect');
|
|
|
|
const selectedOption = activitySelect.options[activitySelect.selectedIndex];
|
|
let subcats = [];
|
|
|
|
try {
|
|
if (selectedOption && selectedOption.dataset.subcategories) {
|
|
subcats = JSON.parse(selectedOption.dataset.subcategories);
|
|
}
|
|
} catch (e) {
|
|
console.error("Error parsing subcategories", e);
|
|
}
|
|
|
|
if (subcats && subcats.length > 0) {
|
|
// Show Dropdown
|
|
subcatInput.style.display = 'none';
|
|
subcatInput.disabled = true; // Disable so it's not sent
|
|
|
|
subcatSelect.style.display = 'block';
|
|
subcatSelect.disabled = false;
|
|
|
|
// Clear and populate
|
|
subcatSelect.innerHTML = '<option value="">-- Select Subcategory --</option>';
|
|
subcats.forEach(sc => {
|
|
const opt = document.createElement('option');
|
|
opt.value = sc;
|
|
opt.textContent = sc;
|
|
subcatSelect.appendChild(opt);
|
|
});
|
|
} else {
|
|
// Show Text Input
|
|
subcatInput.style.display = 'block';
|
|
subcatInput.disabled = false;
|
|
|
|
subcatSelect.style.display = 'none';
|
|
subcatSelect.disabled = true;
|
|
}
|
|
}
|
|
|
|
// Background Sync (Polls for new tasks or status changes)
|
|
let lastContentHash = "";
|
|
setInterval(() => {
|
|
if (document.hidden) return;
|
|
|
|
// Fetch current page in background to check for updates
|
|
fetch(window.location.href)
|
|
.then(res => res.text())
|
|
.then(html => {
|
|
const parser = new DOMParser();
|
|
const doc = parser.parseFromString(html, 'text/html');
|
|
const newContainer = doc.getElementById('logbook-container');
|
|
const oldContainer = document.getElementById('logbook-container');
|
|
|
|
if (newContainer && oldContainer) {
|
|
const newContent = newContainer.innerHTML;
|
|
const oldContent = oldContainer.innerHTML;
|
|
|
|
// Simple string comparison. If different, replace.
|
|
// Note: This replaces the list if *anything* changed (time update server side, new task, stop timer)
|
|
if (newContent !== lastContentHash && lastContentHash !== "") {
|
|
// Only update DOM if logic changed, ignoring simple server side time drift (handled by JS above)
|
|
// But since server renders static time in loop, it will always differ slightly.
|
|
// However, we want to sync Structure (new tasks, status).
|
|
// Let's replace. The user wants "live sync".
|
|
oldContainer.innerHTML = newContent;
|
|
}
|
|
lastContentHash = newContent;
|
|
}
|
|
})
|
|
.catch(e => console.error("Logbook background sync failed", e));
|
|
}, 4000); // Check every 4 seconds
|
|
|
|
// Search Functionality
|
|
function filterLog() {
|
|
const input = document.getElementById('logSearchInput');
|
|
const filter = input.value.toLowerCase();
|
|
const items = document.getElementsByClassName('log-entry-item');
|
|
|
|
for (let i = 0; i < items.length; i++) {
|
|
const title = items[i].querySelector('.entry-title').textContent || "";
|
|
const note = items[i].querySelector('.entry-note').textContent || "";
|
|
const subcat = items[i].querySelector('.entry-subcat') ? items[i].querySelector('.entry-subcat').textContent : "";
|
|
const textValue = (title + " " + note + " " + subcat).toLowerCase();
|
|
|
|
if (textValue.indexOf(filter) > -1) {
|
|
items[i].style.display = "";
|
|
} else {
|
|
items[i].style.display = "none";
|
|
}
|
|
}
|
|
}
|
|
|
|
// Chart.js implementation
|
|
{% if chart_data.chart_values|sum > 0 %}
|
|
const ctx = document.getElementById('timeChart').getContext('2d');
|
|
const myChart = new Chart(ctx, {
|
|
type: 'doughnut',
|
|
data: {
|
|
labels: {{ chart_data.labels|tojson }},
|
|
datasets: [{
|
|
data: {{ chart_data.chart_values|tojson }},
|
|
backgroundColor: {{ chart_data.colors|tojson }},
|
|
borderWidth: 1
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
plugins: {
|
|
legend: {
|
|
position: 'bottom',
|
|
labels: {
|
|
boxWidth: 12,
|
|
font: { size: 11 }
|
|
}
|
|
},
|
|
tooltip: {
|
|
callbacks: {
|
|
label: function(context) {
|
|
return context.label + ': ' + context.raw + ' hours';
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
{% endif %}
|
|
</script>
|
|
|
|
<style>
|
|
/* Small hover effect to indicate clickability */
|
|
a .card:hover {
|
|
transform: scale(1.01);
|
|
box box: 0 4px 8px rgba(0,0,0,0.15);
|
|
}
|
|
</style>
|
|
{% endblock %}
|