index.vue
1 <template> 2 <NuxtLayout name="default"> 3 <div class="stats-dashboard"> 4 <div class="chart-container"> 5 <div class="chart" ref="chartContainer"></div> 6 </div> 7 8 <div class="metrics-tables"> 9 <div class="section"> 10 <div class="text"> 11 <h2>PROJECTS</h2> 12 <p 13 class="extend" 14 @click="openListModal('Projects', sortedProjects)"> 15 <LucideMaximize :size="16" /> 16 DETAILS 17 </p> 18 </div> 19 <div v-if="sortedProjects.length > 0" class="list"> 20 <div 21 v-for="project in sortedProjects.slice(0, 8)" 22 :key="project.name" 23 class="item" 24 :style="{ 25 '--percentage': `${ 26 sortedProjects.length > 0 && sortedProjects[0]!.seconds > 0 27 ? ( 28 (project.seconds / sortedProjects[0]!.seconds) * 29 100 30 ).toFixed(1) 31 : 0 32 }%`, 33 }"> 34 <div class="name">{{ project.name }}</div> 35 <div class="percentage"> 36 {{ ((project.seconds / stats.totalSeconds) * 100).toFixed(1) }}% 37 </div> 38 <div class="time">{{ formatTime(project.seconds) }}</div> 39 </div> 40 </div> 41 <p v-else class="no-data">No data available</p> 42 </div> 43 44 <div class="section"> 45 <div class="text"> 46 <h2>LANGUAGES</h2> 47 <p 48 class="extend" 49 @click="openListModal('Languages', languageBreakdown)"> 50 <LucideMaximize :size="16" /> 51 DETAILS 52 </p> 53 </div> 54 <div v-if="languageBreakdown.length > 0" class="list"> 55 <div 56 v-for="language in languageBreakdown.slice(0, 8)" 57 :key="language.name" 58 class="item" 59 :style="{ 60 '--percentage': `${ 61 languageBreakdown.length > 0 && 62 languageBreakdown[0]!.seconds > 0 63 ? ( 64 (language.seconds / languageBreakdown[0]!.seconds) * 65 100 66 ).toFixed(1) 67 : 0 68 }%`, 69 }"> 70 <div class="name">{{ language.name || "Unknown" }}</div> 71 <div class="percentage"> 72 {{ 73 ((language.seconds / stats.totalSeconds) * 100).toFixed(1) 74 }}% 75 </div> 76 <div class="time"> 77 {{ formatTime(language.seconds) }} 78 </div> 79 </div> 80 </div> 81 <p v-else class="no-data">No data available</p> 82 </div> 83 84 <div class="section"> 85 <div class="text"> 86 <h2>EDITORS</h2> 87 <p 88 class="extend" 89 @click="openListModal('Editors', editorBreakdown)"> 90 <LucideMaximize :size="16" /> 91 DETAILS 92 </p> 93 </div> 94 <div v-if="editorBreakdown.length > 0" class="list"> 95 <div 96 v-for="editor in editorBreakdown.slice(0, 8)" 97 :key="editor.name" 98 class="item" 99 :style="{ 100 '--percentage': `${ 101 editorBreakdown.length > 0 && editorBreakdown[0]!.seconds > 0 102 ? ( 103 (editor.seconds / editorBreakdown[0]!.seconds) * 104 100 105 ).toFixed(1) 106 : 0 107 }%`, 108 }"> 109 <div class="name">{{ editor.name || "Unknown" }}</div> 110 <div class="percentage"> 111 {{ ((editor.seconds / stats.totalSeconds) * 100).toFixed(1) }}% 112 </div> 113 <div class="time"> 114 {{ formatTime(editor.seconds) }} 115 </div> 116 </div> 117 </div> 118 <p v-else class="no-data">No data available</p> 119 </div> 120 121 <div class="section"> 122 <div class="text"> 123 <h2>FILES</h2> 124 <p class="extend" @click="openListModal('Files', fileBreakdown)"> 125 <LucideMaximize :size="16" /> 126 DETAILS 127 </p> 128 </div> 129 <div v-if="fileBreakdown.length > 0" class="list"> 130 <div 131 v-for="file in fileBreakdown.slice(0, 8)" 132 :key="file.name" 133 class="item" 134 :style="{ 135 '--percentage': `${ 136 fileBreakdown.length > 0 && fileBreakdown[0]!.seconds > 0 137 ? ( 138 (file.seconds / fileBreakdown[0]!.seconds) * 139 100 140 ).toFixed(1) 141 : 0 142 }%`, 143 }"> 144 <div class="name">{{ file.name || "Unknown" }}</div> 145 <div class="percentage"> 146 {{ ((file.seconds / stats.totalSeconds) * 100).toFixed(1) }}% 147 </div> 148 <div class="time"> 149 {{ formatTime(file.seconds) }} 150 </div> 151 </div> 152 </div> 153 <p v-else class="no-data">No data available</p> 154 </div> 155 156 <div class="section"> 157 <div class="text"> 158 <h2>OPERATING SYSTEMS</h2> 159 <p 160 class="extend" 161 @click="openListModal('Operating Systems', osBreakdown)"> 162 <LucideMaximize :size="16" /> 163 DETAILS 164 </p> 165 </div> 166 <div v-if="osBreakdown.length > 0" class="list"> 167 <div 168 v-for="os in osBreakdown.slice(0, 8)" 169 :key="os.name" 170 class="item" 171 :style="{ 172 '--percentage': `${ 173 osBreakdown.length > 0 && osBreakdown[0]!.seconds > 0 174 ? ((os.seconds / osBreakdown[0]!.seconds) * 100).toFixed(1) 175 : 0 176 }%`, 177 }"> 178 <div class="name">{{ os.name || "Unknown" }}</div> 179 <div class="percentage"> 180 {{ ((os.seconds / stats.totalSeconds) * 100).toFixed(1) }}% 181 </div> 182 <div class="time"> 183 {{ formatTime(os.seconds) }} 184 </div> 185 </div> 186 </div> 187 <p v-else class="no-data">No data available</p> 188 </div> 189 190 <div class="section"> 191 <div class="text"> 192 <h2>BRANCHES</h2> 193 <p 194 class="extend" 195 @click="openListModal('Branches', branchBreakdown)"> 196 <LucideMaximize :size="16" /> 197 DETAILS 198 </p> 199 </div> 200 <div v-if="branchBreakdown.length > 0" class="list"> 201 <div 202 v-for="branch in branchBreakdown.slice(0, 8)" 203 :key="branch.name" 204 class="item" 205 :style="{ 206 '--percentage': `${ 207 branchBreakdown.length > 0 && branchBreakdown[0]!.seconds > 0 208 ? ( 209 (branch.seconds / branchBreakdown[0]!.seconds) * 210 100 211 ).toFixed(1) 212 : 0 213 }%`, 214 }"> 215 <div class="name">{{ branch.name || "Unknown" }}</div> 216 <div class="percentage"> 217 {{ ((branch.seconds / stats.totalSeconds) * 100).toFixed(1) }}% 218 </div> 219 <div class="time"> 220 {{ formatTime(branch.seconds) }} 221 </div> 222 </div> 223 </div> 224 <p v-else class="no-data">No data available</p> 225 </div> 226 </div> 227 </div> 228 229 <UiListModal 230 :open="showListModal" 231 :title="modalTitle" 232 :items="modalItems" 233 :totalSeconds="stats.totalSeconds" 234 :formatTime="formatTime" 235 @close="showListModal = false" /> 236 </NuxtLayout> 237 </template> 238 239 <script setup lang="ts"> 240 import { ref, computed, watch, onMounted, onUnmounted } from "vue"; 241 import { LucideMaximize } from "lucide-vue-next"; 242 import type { User } from "@prisma/client"; 243 import { Key } from "@waradu/keyboard"; 244 import * as statsLib from "~~/lib/stats"; 245 import type { Heartbeat } from "~~/lib/stats"; 246 import { 247 Chart, 248 CategoryScale, 249 LinearScale, 250 LineElement, 251 PointElement, 252 LineController, 253 Tooltip, 254 Filler, 255 } from "chart.js"; 256 import { useTimeRangeOptions } from "~/composables/useTimeRangeOptions"; 257 import UiListModal from "~/components/Ui/ListModal.vue"; 258 259 Chart.register( 260 CategoryScale, 261 LinearScale, 262 LineElement, 263 PointElement, 264 LineController, 265 Tooltip, 266 Filler 267 ); 268 269 type ItemWithTime = { 270 name: string; 271 seconds: number; 272 }; 273 274 const toast = useToast(); 275 const userState = useState<User | null>("user"); 276 const chartContainer = ref<HTMLElement | null>(null); 277 const projectSort = ref<"time" | "name">("time"); 278 const uniqueLanguages = ref(0); 279 let chart: Chart | null = null; 280 281 const showListModal = ref(false); 282 const modalTitle = ref(""); 283 const modalItems = ref<ItemWithTime[]>([]); 284 285 const stats = ref(statsLib.getStats()); 286 const timeRange = ref(statsLib.getTimeRange()); 287 const { formatTime } = statsLib; 288 const { timeRangeOptions } = useTimeRangeOptions(); 289 290 watch( 291 [() => statsLib.getStats(), () => statsLib.getTimeRange()], 292 ([newStats, newTimeRange]) => { 293 stats.value = newStats; 294 timeRange.value = newTimeRange; 295 } 296 ); 297 298 const months = [ 299 "Jan", 300 "Feb", 301 "Mar", 302 "Apr", 303 "May", 304 "Jun", 305 "Jul", 306 "Aug", 307 "Sep", 308 "Oct", 309 "Nov", 310 "Dec", 311 ] as const; 312 313 watch( 314 () => stats.value, 315 (newStats) => { 316 if (newStats) { 317 uniqueLanguages.value = Object.keys(newStats.languages || {}).length; 318 } 319 if (chart) { 320 updateChart(); 321 } 322 }, 323 { immediate: true, deep: true } 324 ); 325 326 const sortedProjects = computed(() => { 327 if (!stats.value || !stats.value.projects) return []; 328 329 const projects: ItemWithTime[] = Object.entries(stats.value.projects).map( 330 ([name, seconds]) => ({ 331 name, 332 seconds: seconds as number, 333 }) 334 ); 335 336 if (projectSort.value === "time") { 337 return projects.sort((a, b) => b.seconds - a.seconds); 338 } else { 339 return projects.sort((a, b) => a.name.localeCompare(b.name)); 340 } 341 }); 342 343 const languageBreakdown = computed(() => { 344 if (!stats.value || !stats.value.languages) return []; 345 346 const languages: ItemWithTime[] = Object.entries(stats.value.languages).map( 347 ([name, seconds]) => ({ 348 name: name || "Unknown", 349 seconds: seconds as number, 350 }) 351 ); 352 353 return languages.sort((a, b) => b.seconds - a.seconds); 354 }); 355 356 const editorBreakdown = computed(() => { 357 if (!stats.value || !stats.value.editors) return []; 358 359 const editors: ItemWithTime[] = Object.entries(stats.value.editors).map( 360 ([name, seconds]) => ({ 361 name: name || "Unknown", 362 seconds: seconds as number, 363 }) 364 ); 365 366 return editors.sort((a, b) => b.seconds - a.seconds); 367 }); 368 369 const osBreakdown = computed(() => { 370 if (!stats.value || !stats.value.os) return []; 371 372 const osArray: ItemWithTime[] = Object.entries(stats.value.os).map( 373 ([name, seconds]) => ({ 374 name: name || "Unknown", 375 seconds: seconds as number, 376 }) 377 ); 378 379 return osArray.sort((a, b) => b.seconds - a.seconds); 380 }); 381 382 const fileBreakdown = computed(() => { 383 if (!stats.value || !stats.value.files) return []; 384 385 const files: ItemWithTime[] = Object.entries(stats.value.files).map( 386 ([name, seconds]) => ({ 387 name: name || "Unknown", 388 seconds: seconds as number, 389 }) 390 ); 391 392 return files.sort((a, b) => b.seconds - a.seconds); 393 }); 394 395 const branchBreakdown = computed(() => { 396 if (!stats.value || !stats.value.branches) return []; 397 398 const branches: ItemWithTime[] = Object.entries(stats.value.branches).map( 399 ([name, seconds]) => ({ 400 name: name || "Unknown", 401 seconds: seconds as number, 402 }) 403 ); 404 405 return branches.sort((a, b) => b.seconds - a.seconds); 406 }); 407 408 function openListModal(title: string, items: ItemWithTime[]) { 409 modalTitle.value = title; 410 modalItems.value = items; 411 showListModal.value = true; 412 } 413 414 const HEARTBEAT_INTERVAL_SECONDS = 30; 415 416 async function fetchUserData() { 417 if (userState.value) return userState.value; 418 419 try { 420 const data = await $fetch("/api/user"); 421 userState.value = data as User; 422 423 if (data?.keystrokeTimeout) { 424 statsLib.setKeystrokeTimeout(data.keystrokeTimeout); 425 } 426 427 return data; 428 } catch (error) { 429 console.error("Error fetching user data:", error); 430 return null; 431 } 432 } 433 434 onMounted(async () => { 435 await fetchUserData(); 436 timeRangeOptions.value.forEach( 437 (option: { key: string; value: statsLib.TimeRange }) => { 438 if (option.key && option.value) { 439 useKeybind( 440 [Key[option.key as keyof typeof Key]], 441 async () => { 442 statsLib.setTimeRange(option.value); 443 }, 444 { prevent: true } 445 ); 446 } 447 } 448 ); 449 450 if (chartContainer.value) { 451 renderChart(); 452 } 453 }); 454 455 useKeybind( 456 [Key.Alt, Key.L], 457 async () => { 458 await logout(); 459 }, 460 { prevent: true, ignoreIfEditable: true } 461 ); 462 463 onUnmounted(() => { 464 if (chart) { 465 chart.destroy(); 466 chart = null; 467 } 468 }); 469 470 function renderChart() { 471 if (!chartContainer.value || !stats.value) return; 472 473 import("chart.js").then((module) => { 474 const { 475 Chart, 476 CategoryScale, 477 LinearScale, 478 LineElement, 479 PointElement, 480 LineController, 481 Tooltip, 482 Filler, 483 } = module; 484 485 Chart.register( 486 CategoryScale, 487 LinearScale, 488 LineElement, 489 PointElement, 490 LineController, 491 Tooltip, 492 Filler 493 ); 494 495 const ctx = document.createElement("canvas"); 496 chartContainer.value?.appendChild(ctx); 497 498 const chartConfig = getChartConfig(); 499 500 chart = new Chart(ctx, { 501 type: "line", 502 data: { 503 labels: chartConfig.labels, 504 datasets: [ 505 { 506 label: "Coding Time (hours)", 507 data: chartConfig.data, 508 borderColor: "#ff6200", 509 borderWidth: 3, 510 pointBackgroundColor: "#ff6200", 511 pointRadius: 0, 512 pointHoverRadius: 4, 513 fill: "start", 514 tension: 0, 515 stepped: false, 516 }, 517 ], 518 }, 519 options: { 520 responsive: true, 521 maintainAspectRatio: false, 522 animation: false, 523 elements: { 524 line: { 525 tension: 0, 526 borderJoinStyle: "miter", 527 }, 528 point: { 529 hitRadius: 10, 530 }, 531 }, 532 scales: { 533 x: { 534 grid: { 535 display: false, 536 }, 537 ticks: { 538 maxRotation: 0, 539 autoSkip: true, 540 font: { 541 size: 12, 542 family: "ChivoMono", 543 }, 544 color: "#666666", 545 }, 546 border: { 547 display: false, 548 }, 549 }, 550 y: { 551 beginAtZero: true, 552 border: { 553 display: false, 554 }, 555 grid: { 556 color: "rgba(255, 255, 255, 0.05)", 557 drawTicks: false, 558 }, 559 ticks: { 560 font: { 561 size: 12, 562 family: "ChivoMono", 563 }, 564 color: "#666666", 565 padding: 8, 566 callback: function (value) { 567 const numValue = Number(value); 568 if (numValue === 0) return "0m"; 569 const hours = Math.floor(numValue); 570 const minutes = Math.round((numValue - hours) * 60); 571 if (hours === 0) return minutes > 0 ? `${minutes}m` : "0h"; 572 return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`; 573 }, 574 }, 575 }, 576 }, 577 interaction: { 578 mode: "index", 579 intersect: false, 580 }, 581 plugins: { 582 tooltip: { 583 backgroundColor: "#2b2b2b", 584 borderColor: "#ffffff1a", 585 borderWidth: 1, 586 titleColor: "#e6e6e6", 587 bodyColor: "#e6e6e6", 588 padding: 12, 589 cornerRadius: 0, 590 displayColors: false, 591 titleFont: { 592 family: "ChivoMono", 593 weight: 500, 594 size: 14, 595 }, 596 bodyFont: { 597 family: "ChivoMono", 598 size: 14, 599 }, 600 callbacks: { 601 title: function (tooltipItems) { 602 return tooltipItems[0]!.label; 603 }, 604 label: function (context) { 605 const value = context.parsed.y || 0; 606 const numValue = Number(value); 607 const hours = Math.floor(numValue); 608 const minutes = Math.round((numValue - hours) * 60); 609 return `${hours}h ${minutes > 0 ? `${minutes}m` : ""}`; 610 }, 611 }, 612 }, 613 legend: { 614 display: false, 615 }, 616 }, 617 hover: { 618 mode: "index", 619 intersect: false, 620 }, 621 }, 622 }); 623 }); 624 } 625 626 function updateChart() { 627 if (!chart || !stats.value) { 628 return; 629 } 630 631 const chartConfig = getChartConfig(); 632 chart.data.labels = chartConfig.labels; 633 chart.data.datasets[0]!.data = chartConfig.data; 634 chart.update(); 635 } 636 637 function getChartConfig() { 638 const today = new Date(); 639 const yesterday = new Date(today); 640 yesterday.setDate(yesterday.getDate() - 1); 641 642 let labels: string[] = []; 643 let data: number[] = []; 644 645 const getDateLabel = (date: Date) => 646 `${date.getDate()} ${months[date.getMonth()]}`; 647 const getMonthLabel = (date: Date) => months[date.getMonth()]; 648 const getMonthYearLabel = (date: Date) => 649 `${months[date.getMonth()]} ${date.getFullYear()}`; 650 const getHourLabel = (hour: number) => 651 `${hour.toString().padStart(2, "0")}:00`; 652 653 switch (timeRange.value) { 654 case "today": 655 case "yesterday": { 656 labels = Array.from({ length: 24 }, (_, i) => getHourLabel(i)); 657 data = Array(24).fill(0); 658 659 return { 660 labels, 661 data: getSingleDayChartData(data), 662 }; 663 } 664 665 case "week": { 666 labels = Array.from({ length: 7 }, (_, i) => { 667 const date = new Date(yesterday); 668 date.setDate(date.getDate() - i); 669 return getDateLabel(date); 670 }).reverse(); 671 672 data = processSummaries(labels); 673 return { labels, data }; 674 } 675 676 case "month": 677 case "last-90-days": { 678 const daysToGoBack = timeRange.value === "month" ? 29 : 89; 679 const startDate = new Date(yesterday); 680 startDate.setDate(startDate.getDate() - daysToGoBack); 681 682 const days: Date[] = []; 683 let currentDate = new Date(startDate); 684 while (currentDate <= yesterday) { 685 days.push(new Date(currentDate)); 686 currentDate.setDate(currentDate.getDate() + 1); 687 } 688 689 labels = days.map(getDateLabel); 690 data = processSummaries(labels); 691 return { labels, data }; 692 } 693 694 case "month-to-date": { 695 const startDate = new Date(yesterday); 696 startDate.setDate(1); 697 698 const days: Date[] = []; 699 let currentDate = new Date(startDate); 700 while (currentDate <= yesterday) { 701 days.push(new Date(currentDate)); 702 currentDate.setDate(currentDate.getDate() + 1); 703 } 704 705 labels = days.map(getDateLabel); 706 data = processSummaries(labels); 707 return { labels, data }; 708 } 709 710 case "last-month": { 711 const lastMonthStart = new Date( 712 today.getFullYear(), 713 today.getMonth() - 1, 714 1 715 ); 716 const lastMonthEnd = new Date(today.getFullYear(), today.getMonth(), 0); 717 718 const days: Date[] = []; 719 let currentDate = new Date(lastMonthStart); 720 while (currentDate <= lastMonthEnd) { 721 days.push(new Date(currentDate)); 722 currentDate.setDate(currentDate.getDate() + 1); 723 } 724 725 labels = days.map(getDateLabel); 726 data = processSummaries(labels); 727 return { labels, data }; 728 } 729 730 case "year-to-date": 731 case "last-12-months": { 732 const monthCount = 733 timeRange.value === "year-to-date" ? today.getMonth() + 1 : 12; 734 735 labels = Array.from({ length: monthCount }, (_, i) => { 736 const monthIndex = (today.getMonth() - i + 12) % 12; 737 return months[monthIndex] as string; 738 }).reverse(); 739 740 data = Array(labels.length).fill(0); 741 742 if (!stats.value?.summaries?.length) { 743 return { labels, data }; 744 } 745 746 const monthlyTotals = new Map<string, number>(); 747 for (const summary of stats.value.summaries) { 748 const date = new Date(summary.date); 749 const monthName = months[date.getMonth()] as string; 750 monthlyTotals.set( 751 monthName, 752 (monthlyTotals.get(monthName) || 0) + summary.totalSeconds / 3600 753 ); 754 } 755 756 for (let i = 0; i < labels.length; i++) { 757 const totalHours = monthlyTotals.get(labels[i]!) || 0; 758 data[i] = totalHours; 759 } 760 761 return { labels, data }; 762 } 763 764 case "all-time": { 765 if (stats.value?.summaries?.length > 0) { 766 const dates = stats.value.summaries.map( 767 (summary) => new Date(summary.date) 768 ); 769 const minDate = new Date(Math.min(...dates.map((d) => d.getTime()))); 770 const maxDate = new Date(Math.max(...dates.map((d) => d.getTime()))); 771 772 if (maxDate.getFullYear() - minDate.getFullYear() > 0) { 773 const monthsWithYears: Date[] = []; 774 let currentDate = new Date( 775 minDate.getFullYear(), 776 minDate.getMonth(), 777 1 778 ); 779 780 while (currentDate <= maxDate) { 781 monthsWithYears.push(new Date(currentDate)); 782 currentDate.setMonth(currentDate.getMonth() + 1); 783 } 784 785 labels = monthsWithYears.map(getMonthYearLabel); 786 data = Array(labels.length).fill(0); 787 788 for (const summary of stats.value.summaries) { 789 const date = new Date(summary.date); 790 const labelKey = getMonthYearLabel(date); 791 const labelIndex = labels.indexOf(labelKey); 792 793 if (labelIndex !== -1 && data[labelIndex] !== undefined) { 794 data[labelIndex] += summary.totalSeconds / 3600; 795 } 796 } 797 798 return { labels, data }; 799 } 800 } 801 802 labels = Array.from({ length: 12 }, (_, i) => { 803 const date = new Date(today); 804 date.setMonth(date.getMonth() - i); 805 return getMonthLabel(date) as string; 806 }).reverse(); 807 808 data = Array(labels.length).fill(0); 809 810 if (stats.value?.summaries?.length) { 811 for (const summary of stats.value.summaries) { 812 const date = new Date(summary.date); 813 const monthName = months[date.getMonth()] as string; 814 const labelIndex = labels.indexOf(monthName); 815 816 if (labelIndex !== -1 && data[labelIndex] !== undefined) { 817 data[labelIndex] += summary.totalSeconds / 3600; 818 } 819 } 820 } 821 822 return { labels, data }; 823 } 824 825 default: { 826 labels = Array.from({ length: 30 }, (_, i) => { 827 const date = new Date(today); 828 date.setDate(date.getDate() - i); 829 return getDateLabel(date); 830 }).reverse(); 831 832 data = processSummaries(labels); 833 return { labels, data }; 834 } 835 } 836 } 837 838 function processSummaries(labels: string[]): number[] { 839 const result = Array(labels.length).fill(0); 840 if (!stats.value?.summaries?.length) return result; 841 842 const labelMap = new Map<string, number>(); 843 for (let i = 0; i < labels.length; i++) { 844 labelMap.set(labels[i]!, i); 845 } 846 847 for (const summary of stats.value.summaries) { 848 const date = new Date(summary.date); 849 const dateString = `${date.getDate()} ${months[date.getMonth()]}`; 850 const index = labelMap.get(dateString); 851 852 if (index !== undefined) { 853 result[index] += summary.totalSeconds / 3600; 854 } 855 } 856 857 return result; 858 } 859 860 function getSingleDayChartData(result: number[]): number[] { 861 if ( 862 stats.value?.summaries?.length > 0 && 863 stats.value.summaries[0]!.hourlyData 864 ) { 865 const summary = stats.value.summaries[0]; 866 if (!summary) return result; 867 868 for (let hour = 0; hour < 24; hour++) { 869 result[hour] = summary.hourlyData[hour]!.seconds / 3600; 870 } 871 872 return result; 873 } 874 875 const relevantHeartbeats = stats.value?.heartbeats; 876 877 if (!relevantHeartbeats?.length) return result; 878 879 const now = new Date(); 880 let startDate, endDate; 881 882 if (timeRange.value === statsLib.TimeRangeEnum.TODAY) { 883 startDate = new Date(now); 884 startDate.setHours(0, 0, 0, 0); 885 886 endDate = new Date(now); 887 endDate.setHours(23, 59, 59, 999); 888 } else { 889 const yesterdayDate = new Date(now); 890 yesterdayDate.setDate(yesterdayDate.getDate() - 1); 891 892 startDate = new Date(yesterdayDate); 893 startDate.setHours(0, 0, 0, 0); 894 895 endDate = new Date(yesterdayDate); 896 endDate.setHours(23, 59, 59, 999); 897 } 898 899 const filteredHeartbeats = relevantHeartbeats.filter((hb) => { 900 const timestamp = 901 typeof hb.timestamp === "string" ? parseInt(hb.timestamp) : hb.timestamp; 902 const hbDate = new Date(timestamp); 903 const hbTime = hbDate.getTime(); 904 905 return hbTime >= startDate.getTime() && hbTime <= endDate.getTime(); 906 }); 907 908 const heartbeatsByProject = groupHeartbeatsByProject(filteredHeartbeats); 909 910 for (const projectKey in heartbeatsByProject) { 911 const projectBeats = heartbeatsByProject[projectKey]?.sort((a, b) => { 912 const aTime = 913 typeof a.timestamp === "string" 914 ? parseInt(a.timestamp) 915 : Number(a.timestamp); 916 const bTime = 917 typeof b.timestamp === "string" 918 ? parseInt(b.timestamp) 919 : Number(b.timestamp); 920 return aTime - bTime; 921 }); 922 923 if (!projectBeats) { 924 continue; 925 } 926 927 for (let i = 0; i < projectBeats.length; i++) { 928 const currentBeat = projectBeats[i]; 929 const previousBeat = i > 0 ? projectBeats[i - 1] : undefined; 930 const durationSeconds = calculateInlinedDuration( 931 currentBeat!, 932 previousBeat 933 ); 934 935 const timestamp = 936 typeof currentBeat?.timestamp === "string" 937 ? parseInt(currentBeat.timestamp) 938 : Number(currentBeat?.timestamp); 939 940 const ts = new Date(timestamp); 941 const localHour = ts.getHours(); 942 943 if (localHour >= 0 && localHour < 24) { 944 result[localHour]! += durationSeconds / 3600; 945 } 946 } 947 } 948 949 return result; 950 } 951 952 function calculateInlinedDuration( 953 current: Heartbeat, 954 previous?: Heartbeat 955 ): number { 956 const keystrokeTimeoutSecs = statsLib.getKeystrokeTimeout() * 60; 957 958 if (!previous) { 959 return HEARTBEAT_INTERVAL_SECONDS; 960 } 961 962 const currentTs = 963 typeof current.timestamp === "string" 964 ? parseInt(current.timestamp) 965 : Number(current.timestamp); 966 967 const previousTs = 968 typeof previous.timestamp === "string" 969 ? parseInt(previous.timestamp) 970 : Number(previous.timestamp); 971 972 const diffSeconds = Math.round((currentTs - previousTs) / 1000); 973 974 if (diffSeconds < keystrokeTimeoutSecs) { 975 return diffSeconds; 976 } else { 977 return HEARTBEAT_INTERVAL_SECONDS; 978 } 979 } 980 981 function groupHeartbeatsByProject( 982 heartbeats: Heartbeat[] 983 ): Record<string, Heartbeat[]> { 984 const result: Record<string, Heartbeat[]> = {}; 985 986 for (const hb of heartbeats) { 987 const projectKey = hb.project || "unknown"; 988 if (!result[projectKey]) { 989 result[projectKey] = []; 990 } 991 result[projectKey].push(hb); 992 } 993 994 return result; 995 } 996 997 async function logout() { 998 try { 999 window.location.href = "/api/auth/logout"; 1000 } catch (e: any) { 1001 toast.error(e.data?.message || "Logout failed"); 1002 } 1003 } 1004 1005 useSeoMeta({ 1006 title: "Ziit - Coding Statistics", 1007 description: "Track your coding time and productivity with Ziit", 1008 ogTitle: "Ziit - Coding Statistics", 1009 ogDescription: "Track your coding time and productivity with Ziit", 1010 ogImage: "https://ziit.app/logo.webp", 1011 ogUrl: "https://ziit.app", 1012 ogSiteName: "Ziit", 1013 twitterTitle: "Ziit - Coding Statistics", 1014 twitterDescription: "Track your coding time and productivity with Ziit", 1015 twitterImage: "https://ziit.app/logo.webp", 1016 twitterCard: "summary", 1017 twitterCreator: "@pandadev_", 1018 twitterSite: "@pandadev_", 1019 author: "PandaDEV", 1020 }); 1021 1022 useHead({ 1023 htmlAttrs: { lang: "en" }, 1024 link: [ 1025 { 1026 rel: "canonical", 1027 href: "https://ziit.app", 1028 }, 1029 { 1030 rel: "icon", 1031 type: "image/ico", 1032 href: "/favicon.ico", 1033 }, 1034 ], 1035 script: [ 1036 { 1037 type: "application/ld+json", 1038 innerHTML: JSON.stringify({ 1039 "@context": "https://schema.org", 1040 "@type": "WebSite", 1041 name: "Ziit", 1042 url: "https://ziit.app", 1043 }), 1044 }, 1045 ], 1046 }); 1047 1048 definePageMeta({ scrollToTop: true }); 1049 </script> 1050 1051 <style lang="scss"> 1052 @use "~~/styles/index.scss"; 1053 </style>