pie chart next to logbook
This commit is contained in:
69
app.py
69
app.py
@@ -406,10 +406,75 @@ def task_detail(task_id):
|
|||||||
@app.route('/logbook')
|
@app.route('/logbook')
|
||||||
def logbook():
|
def logbook():
|
||||||
if not is_logged_in(): return redirect(url_for('login'))
|
if not is_logged_in(): return redirect(url_for('login'))
|
||||||
|
user_id = get_user_id()
|
||||||
|
|
||||||
|
# --- Statistics Logic ---
|
||||||
|
time_range = request.args.get('range', '24h')
|
||||||
|
now = datetime.now()
|
||||||
|
|
||||||
|
if time_range == '24h':
|
||||||
|
start_date = now - timedelta(hours=24)
|
||||||
|
elif time_range == 'week':
|
||||||
|
start_date = now - timedelta(days=7)
|
||||||
|
elif time_range == 'month':
|
||||||
|
start_date = now - timedelta(days=30)
|
||||||
|
elif time_range == 'year':
|
||||||
|
start_date = now - timedelta(days=365)
|
||||||
|
else:
|
||||||
|
start_date = now - timedelta(hours=24)
|
||||||
|
|
||||||
|
# Calculate sums per activity
|
||||||
|
pipeline_stats = [
|
||||||
|
{'$match': {
|
||||||
|
'user_id': user_id,
|
||||||
|
'end_time': {'$ne': None},
|
||||||
|
'start_time': {'$gte': start_date}
|
||||||
|
}},
|
||||||
|
{'$project': {
|
||||||
|
'activity_id': 1,
|
||||||
|
'duration': {'$subtract': ['$end_time', '$start_time']}
|
||||||
|
}},
|
||||||
|
{'$group': {
|
||||||
|
'_id': '$activity_id',
|
||||||
|
'total_ms': {'$sum': '$duration'}
|
||||||
|
}}
|
||||||
|
]
|
||||||
|
|
||||||
|
stats_raw = list(db.time_entries.aggregate(pipeline_stats))
|
||||||
|
|
||||||
|
chart_data = {
|
||||||
|
'labels': [],
|
||||||
|
'chart_values': [], # Renamed from 'values' to avoid method conflict
|
||||||
|
'colors': []
|
||||||
|
}
|
||||||
|
|
||||||
|
total_ms_all = 0
|
||||||
|
|
||||||
|
for s in stats_raw:
|
||||||
|
# Sum total for all activities
|
||||||
|
total_ms_all += s['total_ms']
|
||||||
|
|
||||||
|
act = db.activities.find_one({'_id': s['_id']})
|
||||||
|
if act:
|
||||||
|
chart_data['labels'].append(act['name'])
|
||||||
|
chart_data['colors'].append(act.get('color', '#ccc'))
|
||||||
|
# Convert milliseconds to hours
|
||||||
|
hours = s['total_ms'] / (1000 * 60 * 60)
|
||||||
|
chart_data['chart_values'].append(round(hours, 2))
|
||||||
|
|
||||||
|
# Format total time display
|
||||||
|
if total_ms_all > 0:
|
||||||
|
total_hours_val = total_ms_all / (1000 * 60 * 60)
|
||||||
|
# If less than 1 hour, show minutes? Or just 0.Xh?
|
||||||
|
# Keeps consistency with pie chart to use hours, but let's make it look nice.
|
||||||
|
total_time_display = f"{round(total_hours_val, 1)}h"
|
||||||
|
else:
|
||||||
|
total_time_display = "0h"
|
||||||
|
|
||||||
|
# --- Existing Logbook Logic ---
|
||||||
# Agrregation to join activities and tasks
|
# Agrregation to join activities and tasks
|
||||||
pipeline = [
|
pipeline = [
|
||||||
{'$match': {'user_id': get_user_id(), 'end_time': {'$ne': None}}},
|
{'$match': {'user_id': user_id, 'end_time': {'$ne': None}}},
|
||||||
{'$sort': {'start_time': -1}},
|
{'$sort': {'start_time': -1}},
|
||||||
{'$lookup': {
|
{'$lookup': {
|
||||||
'from': 'activities',
|
'from': 'activities',
|
||||||
@@ -436,7 +501,7 @@ def logbook():
|
|||||||
duration = entry['end_time'] - entry['start_time']
|
duration = entry['end_time'] - entry['start_time']
|
||||||
entry['duration_str'] = str(duration).split('.')[0] # HH:MM:SS
|
entry['duration_str'] = str(duration).split('.')[0] # HH:MM:SS
|
||||||
|
|
||||||
return render_template('logbook.html', log=log)
|
return render_template('logbook.html', log=log, chart_data=chart_data, current_range=time_range, total_time_display=total_time_display)
|
||||||
|
|
||||||
@app.route('/logbook/<entry_id>', methods=['GET', 'POST'])
|
@app.route('/logbook/<entry_id>', methods=['GET', 'POST'])
|
||||||
def log_entry_detail(entry_id):
|
def log_entry_detail(entry_id):
|
||||||
|
|||||||
@@ -294,11 +294,11 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<nav>
|
<nav>
|
||||||
<div class="brand">
|
<a href="{{ url_for('index') }}" class="brand" style="text-decoration: none;">
|
||||||
<!-- Simple SVG Icon -->
|
<!-- Simple SVG Icon -->
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||||||
OpenTimeTracker
|
OpenTimeTracker
|
||||||
</div>
|
</a>
|
||||||
<div>
|
<div>
|
||||||
{% if session.user_id %}
|
{% if session.user_id %}
|
||||||
<a href="{{ url_for('index') }}">Tracker</a>
|
<a href="{{ url_for('index') }}">Tracker</a>
|
||||||
|
|||||||
@@ -1,90 +1,161 @@
|
|||||||
{% extends "layout.html" %}
|
{% extends "layout.html" %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem;">
|
<div style="display: flex; flex-wrap: wrap; gap: 2rem; align-items: flex-start;">
|
||||||
<h2 style="margin: 0;">Logbook</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Search Bar -->
|
<!-- Left Column: Log Entries -->
|
||||||
<div style="margin-bottom: 2rem;">
|
<div style="flex: 1; min-width: 300px;">
|
||||||
<input type="text" id="logSearchInput" placeholder="Search logbook (Activity, Subcategory, Notes)..." onkeyup="filterLog()"
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem;">
|
||||||
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;">
|
<h2 style="margin: 0;">Logbook</h2>
|
||||||
</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;">
|
|
||||||
<div class="card" style="border-left: 5px solid {{ entry.activity.color }}; transition: transform 0.1s;">
|
|
||||||
<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 %}
|
|
||||||
</h3>
|
|
||||||
<small style="color: #666;">
|
|
||||||
{{ entry.start_time.strftime('%Y-%m-%d %H:%M') }} - {{ entry.end_time.strftime('%H:%M') }}
|
|
||||||
</small>
|
|
||||||
{% if entry.note %}
|
|
||||||
<p class="entry-note" style="margin: 5px 0; font-style: italic; color: #555;">"{{ entry.note }}"</p>
|
|
||||||
{% else %}
|
|
||||||
<!-- Hidden span for consistent JS selection if note empty -->
|
|
||||||
<span class="entry-note" style="display:none;"></span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
<div style="font-weight: bold; font-size: 1.2rem;">
|
|
||||||
{{ entry.duration_str }}
|
|
||||||
</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>
|
</div>
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<script>
|
<!-- Search Bar -->
|
||||||
function filterLog() {
|
<div style="margin-bottom: 2rem;">
|
||||||
const input = document.getElementById('logSearchInput');
|
<input type="text" id="logSearchInput" placeholder="Search logbook (Activity, Subcategory, Notes)..." onkeyup="filterLog()"
|
||||||
const filter = input.value.toLowerCase();
|
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;">
|
||||||
const items = document.getElementsByClassName('log-entry-item');
|
</div>
|
||||||
|
|
||||||
for (let i = 0; i < items.length; i++) {
|
{% if not log %}
|
||||||
const title = items[i].querySelector('.entry-title').textContent || "";
|
<p>No activities recorded yet.</p>
|
||||||
const note = items[i].querySelector('.entry-note').textContent || "";
|
{% else %}
|
||||||
// Combine text content for searching
|
<div id="logbook-container">
|
||||||
const textValue = (title + " " + note).toLowerCase();
|
{% 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;">
|
||||||
|
<div class="card" style="border-left: 5px solid {{ entry.activity.color }}; transition: transform 0.1s;">
|
||||||
|
<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 %}
|
||||||
|
</h3>
|
||||||
|
<small style="color: #666;">
|
||||||
|
{{ entry.start_time.strftime('%Y-%m-%d %H:%M') }} - {{ entry.end_time.strftime('%H:%M') }}
|
||||||
|
</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;">
|
||||||
|
{{ entry.duration_str }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
if (textValue.indexOf(filter) > -1) {
|
{% if entry.tasks %}
|
||||||
items[i].style.display = "";
|
<div style="margin-top: 10px; padding-top: 10px; border-top: 1px solid #eee;">
|
||||||
} else {
|
<strong>Tasks Completed:</strong>
|
||||||
items[i].style.display = "none";
|
<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>
|
||||||
|
<script>
|
||||||
|
// 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
});
|
||||||
|
{% endif %}
|
||||||
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* Small hover effect to indicate clickability */
|
/* Small hover effect to indicate clickability */
|
||||||
a .card:hover {
|
a .card:hover {
|
||||||
transform: scale(1.01);
|
transform: scale(1.01);
|
||||||
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
|
box box: 0 4px 8px rgba(0,0,0,0.15);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user