/ app / pages / index.vue
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>