Initial commit of dashboard

This commit is contained in:
Oracle 2026-06-14 18:13:56 +02:00
parent 59d24aa206
commit adc39db441
Signed by: Oracle
SSH key fingerprint: SHA256:x4/RtnjUyuHkdvmwNDsWSfcfF1V5PNr3OpriZqOvCX8
21 changed files with 797 additions and 20 deletions

View file

@ -6,18 +6,29 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" href="/favicon.ico" />
<meta name="generator" content={Astro.generator} />
<title>Astro Basics</title>
<title>Time Tracking Dashboard</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
margin: 0;
width: 100%;
height: 100%;
background: #0d0d1a;
color: #e0e0e0;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
#app {
max-width: 1400px;
margin: 0 auto;
padding: 24px;
}
</style>
</head>
<body>
<slot />
</body>
</html>
<style>
html,
body {
margin: 0;
width: 100%;
height: 100%;
}
</style>

View file

@ -1,11 +1,364 @@
---
import Welcome from '../components/Welcome.astro';
import Layout from '../layouts/Layout.astro';
// Welcome to Astro! Wondering what to do next? Check out the Astro documentation at https://docs.astro.build
// Don't want to use any of this? Delete everything in this file, the `assets`, `components`, and `layouts` directories, and start fresh.
const TOKEN = import.meta.env.PUBLIC_ACCESS_TOKEN || '';
---
<Layout>
<Welcome />
</Layout>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" href="/favicon.ico" />
<meta name="generator" content={Astro.generator} />
<title>Time Tracking Dashboard</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/apexcharts@3.49.2/dist/apexcharts.css" />
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
margin: 0; width: 100%; height: 100%;
background: #0d0d1a; color: #e0e0e0;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
#app { max-width: 1400px; margin: 0 auto; padding: 24px; }
.dashboard { animation: fadeIn 0.5s ease-in; }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
.title { font-size: 28px; font-weight: 600; color: #c4a7ff; margin-bottom: 24px; }
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-bottom: 32px; }
.stat-card { background: rgba(139, 92, 246, 0.1); border: 1px solid rgba(139, 92, 246, 0.2); border-radius: 12px; padding: 20px; text-align: center; backdrop-filter: blur(10px); }
.stat-value { font-size: 32px; font-weight: 700; color: #c4a7ff; margin-bottom: 4px; }
.stat-label { font-size: 13px; color: #a0a0b8; text-transform: uppercase; letter-spacing: 0.5px; }
.charts-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(500px, 1fr)); gap: 16px; margin-bottom: 16px; }
.chart-card { background: rgba(139, 92, 246, 0.08); border: 1px solid rgba(139, 92, 246, 0.15); border-radius: 12px; padding: 20px; backdrop-filter: blur(10px); }
.chart-card.full-width { grid-column: 1 / -1; }
.chart-title { font-size: 16px; font-weight: 500; color: #c4a7ff; margin-bottom: 16px; }
.chart { width: 100%; min-height: 350px; }
.loading { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 60vh; }
.spinner { width: 48px; height: 48px; border: 4px solid rgba(139, 92, 246, 0.2); border-top-color: #c4a7ff; border-radius: 50%; animation: spin 0.8s linear infinite; margin-bottom: 16px; }
@keyframes spin { to { transform: rotate(360deg); } }
.error { text-align: center; padding: 40px; }
.error h2 { color: #ff6b6b; margin-bottom: 12px; }
.error p { color: #a0a0b8; }
</style>
</head>
<body>
<div id="app" data-token={TOKEN}>
<div id="loading" class="loading">
<div class="spinner"></div>
<p>Loading time tracking data...</p>
</div>
<div id="dashboard" class="dashboard" style="display: none;">
<h1 class="title">Time Tracking Dashboard</h1>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value" id="time-today">-</div>
<div class="stat-label">Time Worked Today</div>
</div>
<div class="stat-card">
<div class="stat-value" id="time-week">-</div>
<div class="stat-label">Time Worked This Week</div>
</div>
<div class="stat-card">
<div class="stat-value" id="time-month">-</div>
<div class="stat-label">Time Worked This Month</div>
</div>
<div class="stat-card">
<div class="stat-value" id="tracked-items">-</div>
<div class="stat-label">Tracked Items</div>
</div>
</div>
<div class="charts-grid">
<div class="chart-card">
<h2 class="chart-title">Time Worked - Last 2 Weeks</h2>
<div id="chart-weekly" class="chart"></div>
</div>
<div class="chart-card">
<h2 class="chart-title">Time Worked - Last Month</h2>
<div id="chart-monthly" class="chart"></div>
</div>
</div>
<div class="charts-grid">
<div class="chart-card">
<h2 class="chart-title">Time by Issue</h2>
<div id="chart-issues" class="chart"></div>
</div>
<div class="chart-card">
<h2 class="chart-title">Time by Pull Request</h2>
<div id="chart-prs" class="chart"></div>
</div>
</div>
<div class="chart-card full-width">
<h2 class="chart-title">Time Distribution - Issues vs Pull Requests</h2>
<div id="chart-distribution" class="chart"></div>
</div>
</div>
<div id="error" class="error" style="display: none;">
<h2>Error Loading Data</h2>
<p id="error-message">Failed to fetch time tracking data.</p>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/apexcharts@3.49.2/dist/apexcharts.min.js"></script>
<script>
(function() {
var TOKEN = document.getElementById('app').getAttribute('data-token') || '';
var API_BASE = 'https://bitfreedom.net/code/api/v1';
function formatDuration(seconds) {
var h = Math.floor(seconds / 3600);
var m = Math.floor((seconds % 3600) / 60);
if (h > 0) return h + 'h ' + m + 'm';
return m + 'm';
}
function formatDurationDetailed(seconds) {
var days = Math.floor(seconds / 86400);
var h = Math.floor((seconds % 86400) / 3600);
var m = Math.floor((seconds % 3600) / 60);
if (days > 0) return days + 'd ' + h + 'h ' + m + 'm';
if (h > 0) return h + 'h ' + m + 'm';
return m + 'm';
}
async function fetchTimes() {
var since = getSinceDate();
var url = API_BASE + '/user/times?access_token=' + TOKEN + '&since=' + since;
var resp = await fetch(url);
if (!resp.ok) throw new Error('API error: ' + resp.status);
return resp.json();
}
function getTodayStart() {
var now = new Date();
return new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
}
function getWeekStart() {
var now = new Date();
var day = now.getDay();
var diff = now.getDate() - day + (day === 0 ? -6 : 1);
var monday = new Date(now.setDate(diff));
monday.setHours(0, 0, 0, 0);
return monday.getTime();
}
function getLast30Days() {
var now = new Date();
var monthAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
return { now: now.getTime(), monthAgo: monthAgo.getTime() };
}
function getMonthStart() {
var now = new Date();
var monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
monthStart.setHours(0, 0, 0, 0);
return monthStart.toISOString();
}
function getSinceDate() {
var now = new Date();
var day = now.getDay();
var diff = now.getDate() - day + (day === 0 ? -6 : 1);
var monday = new Date(now.setDate(diff));
monday.setHours(0, 0, 0, 0);
return monday.toISOString();
}
function getDayKey(dateStr) {
var d = new Date(dateStr);
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
}
function getDayLabel(dateStr) {
var d = new Date(dateStr);
return String(d.getMonth() + 1).padStart(2, '0') + '/' + String(d.getDate()).padStart(2, '0');
}
function truncateTitle(t, max) {
max = max || 40;
return t.length > max ? t.substring(0, max) + '...' : t;
}
function renderCharts(timesData) {
var todayTotal = 0;
var weekTotal = 0;
var monthTotal = 0;
var todayStart = getTodayStart();
var weekStart = getWeekStart();
var monthAgo = getLast30Days().monthAgo;
var monthStart = new Date(new Date().getFullYear(), new Date().getMonth(), 1).getTime();
var dayMap = {};
var issuesData = {};
var prsData = {};
var issuesTotal = 0;
var prsTotal = 0;
var trackedItems = new Set();
for (var i = 0; i < timesData.length; i++) {
var entry = timesData[i];
var timeSec = entry.time || 0;
var created = new Date(entry.created).getTime();
if (created >= todayStart) todayTotal += timeSec;
if (created >= weekStart) weekTotal += timeSec;
if (created >= monthStart) monthTotal += timeSec;
var dayKey = getDayKey(entry.created);
if (!dayMap[dayKey]) dayMap[dayKey] = 0;
dayMap[dayKey] += timeSec;
if (entry.issue) {
var itemId = entry.issue.pull_request ? 'pr_' + entry.issue.id : 'issue_' + entry.issue.id;
trackedItems.add(itemId);
var title = '#' + entry.issue.number + ' - ' + (entry.issue.title || 'Unknown');
if (entry.issue.pull_request) {
prsData[title] = (prsData[title] || 0) + timeSec;
prsTotal += timeSec;
} else {
issuesData[title] = (issuesData[title] || 0) + timeSec;
issuesTotal += timeSec;
}
}
}
document.getElementById('time-today').textContent = formatDurationDetailed(todayTotal);
document.getElementById('time-week').textContent = formatDurationDetailed(weekTotal);
document.getElementById('time-month').textContent = formatDurationDetailed(monthTotal);
document.getElementById('tracked-items').textContent = trackedItems.size;
var chartBase = {
chart: { type: 'area', height: 350, background: 'transparent', toolbar: { show: true }, zoom: { enabled: true } },
theme: { mode: 'dark' },
stroke: { curve: 'smooth', width: 2 },
dataLabels: { enabled: false },
grid: { borderColor: 'rgba(139, 92, 246, 0.15)' },
tooltip: { formatter: function(val) { return formatDuration(val * 3600); } }
};
// Weekly chart (14 days)
var weeklyData = [];
var weeklyLabels = [];
var now = new Date();
for (var i = 13; i >= 0; i--) {
var d = new Date(now);
d.setDate(d.getDate() - i);
var key = getDayKey(d.toISOString());
weeklyLabels.push(String(d.getMonth() + 1).padStart(2, '0') + '/' + String(d.getDate()).padStart(2, '0'));
weeklyData.push((dayMap[key] || 0) / 3600);
}
new ApexCharts(document.querySelector("#chart-weekly"), {
chart: chartBase.chart,
theme: chartBase.theme,
xaxis: { type: 'category', labels: { formatter: function(val) { return weeklyLabels[parseInt(val) || 0]; }, style: { colors: '#a0a0b8', fontSize: '11px' } }, axisTicks: { show: true }, axisBorder: { show: true, color: 'rgba(139, 92, 246, 0.2)' } },
yaxis: { labels: { formatter: function(val) { return val ? val.toFixed(1) : '0'; } } },
series: [{ name: 'Hours', data: weeklyData }],
stroke: chartBase.stroke,
dataLabels: chartBase.dataLabels,
grid: chartBase.grid,
tooltip: chartBase.tooltip,
colors: ['#7c3aed'],
fill: { type: 'gradient', gradient: { shade: 'dark', gradientToColors: ['#8b5cf6'], shadeIntensity: 1, type: 'horizontal', opacityFrom: 0.6, opacityTo: 0.2, stops: [0, 100] } }
}).render();
// Monthly chart (30 days)
var monthlyData = [];
var monthlyLabels = [];
for (var i = 29; i >= 0; i--) {
var d = new Date(now);
d.setDate(d.getDate() - i);
var key = getDayKey(d.toISOString());
monthlyLabels.push(getDayLabel(d.toISOString()));
monthlyData.push((dayMap[key] || 0) / 3600);
}
new ApexCharts(document.querySelector("#chart-monthly"), {
chart: chartBase.chart,
theme: chartBase.theme,
xaxis: { type: 'category', labels: { formatter: function(val) { return monthlyLabels[parseInt(val) || 0]; }, style: { colors: '#a0a0b8', fontSize: '11px' } }, axisTicks: { show: true }, axisBorder: { show: true, color: 'rgba(139, 92, 246, 0.2)' } },
yaxis: { labels: { formatter: function(val) { return val ? val.toFixed(1) : '0'; } } },
series: [{ name: 'Hours', data: monthlyData }],
stroke: chartBase.stroke,
dataLabels: chartBase.dataLabels,
grid: chartBase.grid,
tooltip: chartBase.tooltip,
colors: ['#7c3aed'],
fill: { type: 'gradient', gradient: { shade: 'dark', gradientToColors: ['#a78bfa'], shadeIntensity: 1, type: 'horizontal', opacityFrom: 0.6, opacityTo: 0.2, stops: [0, 100] } }
}).render();
// Issues chart - horizontal bar
var issueTitles = Object.keys(issuesData);
var issueHours = issueTitles.map(function(t) { return parseFloat((issuesData[t] / 3600).toFixed(2)); });
new ApexCharts(document.querySelector("#chart-issues"), {
chart: { type: 'bar', height: 350, background: 'transparent', toolbar: { show: true } },
theme: { mode: 'dark' },
series: [{ name: 'Hours', data: issueHours }],
plotOptions: { bar: { horizontal: true, borderRadius: 4 } },
xaxis: { categories: issueTitles.map(truncateTitle), labels: { style: { colors: '#a0a0b8', fontSize: '11px' } } },
yaxis: { labels: { style: { colors: '#a0a0b8' } } },
colors: ['#22c55e'],
fill: { type: 'solid', opacity: 0.8 },
grid: { borderColor: 'rgba(139, 92, 246, 0.15)' },
dataLabels: { enabled: true, formatter: function(val) { return val.toFixed(1) + 'h'; }, style: { colors: ['#fff'] } },
tooltip: { formatter: function(val) { return formatDuration(val * 3600); } },
legend: { show: false }
}).render();
// PRs chart - horizontal bar
var prTitles = Object.keys(prsData);
var prHours = prTitles.map(function(t) { return parseFloat((prsData[t] / 3600).toFixed(2)); });
new ApexCharts(document.querySelector("#chart-prs"), {
chart: { type: 'bar', height: 350, background: 'transparent', toolbar: { show: true } },
theme: { mode: 'dark' },
series: [{ name: 'Hours', data: prHours }],
plotOptions: { bar: { horizontal: true, borderRadius: 4 } },
xaxis: { categories: prTitles.map(truncateTitle), labels: { style: { colors: '#a0a0b8', fontSize: '11px' } } },
yaxis: { labels: { style: { colors: '#a0a0b8' } } },
colors: ['#3b82f6'],
fill: { type: 'solid', opacity: 0.8 },
grid: { borderColor: 'rgba(139, 92, 246, 0.15)' },
dataLabels: { enabled: true, formatter: function(val) { return val.toFixed(1) + 'h'; }, style: { colors: ['#fff'] } },
tooltip: { formatter: function(val) { return formatDuration(val * 3600); } },
legend: { show: false }
}).render();
// Distribution chart - donut
new ApexCharts(document.querySelector("#chart-distribution"), {
chart: { type: 'donut', height: 400, background: 'transparent' },
theme: { mode: 'dark' },
series: [parseFloat((issuesTotal / 3600).toFixed(1)), parseFloat((prsTotal / 3600).toFixed(1))],
labels: ['Issues', 'Pull Requests'],
colors: ['#22c55e', '#3b82f6'],
fill: { type: 'solid', opacity: 0.9 },
legend: { position: 'bottom', labels: { colors: '#a0a0b8' } },
dataLabels: { formatter: function(val) { return val.toFixed(1) + 'h'; } },
stroke: { width: 2, colors: ['#0d0d1a'] },
tooltip: { formatter: function(val) { return formatDuration(val / 100 * (issuesTotal + prsTotal)); } }
}).render();
}
function showError(msg) {
document.getElementById('loading').style.display = 'none';
document.getElementById('error').style.display = 'flex';
document.getElementById('error-message').textContent = msg;
}
async function main() {
try {
var timesData = await fetchTimes();
renderCharts(timesData);
document.getElementById('loading').style.display = 'none';
document.getElementById('dashboard').style.display = 'block';
} catch (err) {
console.error(err);
showError(err.message || 'Failed to fetch data');
}
}
main();
})();
</script>
</body>
</html>