heartwood every commit a ring
8.5 KB raw
import React, { useEffect, useRef, useContext, useMemo } from "react";
import Chart from "chart.js/auto";

import Page from "../components/page";
import { Context } from "../components/context";
import strings from "../l10n/summary";

import styles from "../styles/pages/summary.module.css";

const PALETTE = [
  "#6B9E78",
  "#C9A84C",
  "#C47055",
  "#7EAAB8",
  "#7DB88C",
  "#DDC06A",
];

const MS = {
  minute: 60 * 1000,
  hour: 60 * 60 * 1000,
  day: 24 * 60 * 60 * 1000,
};

const formatDuration = (ms, s) => {
  if (!ms) return "0" + s.hoursSuffix;
  const hours = ms / MS.hour;
  if (hours >= 1) return `${hours.toFixed(hours >= 10 ? 0 : 1)}${s.hoursSuffix}`;
  const mins = Math.round(ms / MS.minute);
  return `${mins}${s.minsSuffix}`;
};

const formatHours = (ms) => Math.round((ms / MS.hour) * 100) / 100;

const startOfDay = (d) => {
  const x = new Date(d);
  x.setHours(0, 0, 0, 0);
  return x;
};

const Summary = () => {
  const { state } = useContext(Context);
  strings.setLanguage(state.language);

  const tagCanvasRef = useRef(null);
  const dayCanvasRef = useRef(null);

  const log = state.log;
  const hasData = log.length > 0;

  // Derived stats ----------------------------------------------------------
  const stats = useMemo(() => {
    if (!hasData) return null;
    const now = Date.now();
    const today = startOfDay(now).getTime();
    const weekAgo = today - 6 * MS.day;

    let totalMs = 0;
    let todayMs = 0;
    let weekMs = 0;
    let longestMs = 0;
    const tagSet = new Set();

    for (const e of log) {
      const start = +new Date(e.start);
      const end = +new Date(e.end);
      const dur = end - start;
      totalMs += dur;
      if (dur > longestMs) longestMs = dur;
      if (start >= today) todayMs += dur;
      if (start >= weekAgo) weekMs += dur;
      for (const t of e.tags || []) tagSet.add(t);
    }

    return {
      totalMs,
      entryCount: log.length,
      todayMs,
      weekMs,
      longestMs,
      tagCount: tagSet.size,
    };
  }, [log, hasData]);

  // Hours per tag
  const perTag = useMemo(() => {
    if (!hasData) return { labels: [], data: [] };
    const map = new Map();
    for (const e of log) {
      const dur = +new Date(e.end) - +new Date(e.start);
      for (const t of e.tags || []) {
        map.set(t, (map.get(t) || 0) + dur);
      }
    }
    const entries = [...map.entries()].sort((a, b) => b[1] - a[1]);
    return {
      labels: entries.map(([t]) => t),
      data: entries.map(([, ms]) => formatHours(ms)),
    };
  }, [log, hasData]);

  // Hours per day over last 14 days
  const perDay = useMemo(() => {
    if (!hasData) return { labels: [], data: [] };
    const days = 14;
    const todayStart = startOfDay(Date.now()).getTime();
    const buckets = new Array(days).fill(0);
    const labels = new Array(days).fill(0).map((_, i) => {
      const d = new Date(todayStart - (days - 1 - i) * MS.day);
      return `${d.getMonth() + 1}/${d.getDate()}`;
    });
    for (const e of log) {
      const start = +new Date(e.start);
      const daysAgo = Math.floor((todayStart - startOfDay(start).getTime()) / MS.day);
      if (daysAgo < 0 || daysAgo >= days) continue;
      const dur = +new Date(e.end) - start;
      buckets[days - 1 - daysAgo] += dur;
    }
    return { labels, data: buckets.map((ms) => formatHours(ms)) };
  }, [log, hasData]);

  // Chart: hours per tag ---------------------------------------------------
  useEffect(() => {
    if (!hasData || !tagCanvasRef.current) return;
    const chart = new Chart(tagCanvasRef.current, {
      type: "bar",
      data: {
        labels: perTag.labels,
        datasets: [
          {
            label: strings.numHours,
            data: perTag.data,
            backgroundColor: perTag.labels.map(
              (_, i) => PALETTE[i % PALETTE.length] + "cc"
            ),
            borderColor: perTag.labels.map(
              (_, i) => PALETTE[i % PALETTE.length]
            ),
            borderWidth: 1,
            borderRadius: 2,
          },
        ],
      },
      options: {
        responsive: true,
        maintainAspectRatio: true,
        plugins: {
          legend: { display: false },
          tooltip: {
            backgroundColor: "#13120e",
            borderColor: "rgba(107,158,120,0.3)",
            borderWidth: 1,
            titleFont: { family: "JetBrains Mono, monospace" },
            bodyFont: { family: "JetBrains Mono, monospace" },
          },
        },
        scales: {
          x: {
            ticks: {
              color: "#a09890",
              font: { family: "JetBrains Mono, monospace", size: 11 },
            },
            grid: { color: "rgba(221,215,205,0.04)" },
          },
          y: {
            ticks: {
              color: "#a09890",
              font: { family: "JetBrains Mono, monospace", size: 11 },
            },
            grid: { color: "rgba(221,215,205,0.04)" },
          },
        },
      },
    });
    return () => chart.destroy();
  }, [perTag, hasData, state.language]);

  // Chart: hours per day ---------------------------------------------------
  useEffect(() => {
    if (!hasData || !dayCanvasRef.current) return;
    const chart = new Chart(dayCanvasRef.current, {
      type: "line",
      data: {
        labels: perDay.labels,
        datasets: [
          {
            label: strings.numHours,
            data: perDay.data,
            fill: true,
            tension: 0.35,
            backgroundColor: "rgba(107,158,120,0.14)",
            borderColor: "#6B9E78",
            borderWidth: 1.5,
            pointBackgroundColor: "#7DB88C",
            pointBorderColor: "#0e0d0a",
            pointRadius: 3,
            pointHoverRadius: 5,
          },
        ],
      },
      options: {
        responsive: true,
        maintainAspectRatio: true,
        plugins: {
          legend: { display: false },
          tooltip: {
            backgroundColor: "#13120e",
            borderColor: "rgba(107,158,120,0.3)",
            borderWidth: 1,
            titleFont: { family: "JetBrains Mono, monospace" },
            bodyFont: { family: "JetBrains Mono, monospace" },
          },
        },
        scales: {
          x: {
            ticks: {
              color: "#a09890",
              font: { family: "JetBrains Mono, monospace", size: 10 },
            },
            grid: { color: "rgba(221,215,205,0.04)" },
          },
          y: {
            ticks: {
              color: "#a09890",
              font: { family: "JetBrains Mono, monospace", size: 10 },
            },
            grid: { color: "rgba(221,215,205,0.04)" },
            beginAtZero: true,
          },
        },
      },
    });
    return () => chart.destroy();
  }, [perDay, hasData, state.language]);

  if (!hasData) {
    return (
      <Page title="Summary">
        <div className="page-grid">
          <main className="page-main">
            <h1 className="page-title">{strings.pageTitle}</h1>
          </main>
        </div>
        <div className="empty-state">
          <p className="empty-state-title">{strings.empty}</p>
          <p className="empty-state-hint">{strings.emptyHint}</p>
        </div>
      </Page>
    );
  }

  return (
    <Page title="Summary">
      <div className="page-grid">
        <main className="page-main">
          <h1 className="page-title">{strings.pageTitle}</h1>

          <div className={styles.tiles}>
            <Tile label={strings.statTotal}>
              {formatDuration(stats.totalMs, strings)}
            </Tile>
            <Tile label={strings.statToday}>
              {formatDuration(stats.todayMs, strings)}
            </Tile>
            <Tile label={strings.statWeek}>
              {formatDuration(stats.weekMs, strings)}
            </Tile>
            <Tile label={strings.statLongest}>
              {formatDuration(stats.longestMs, strings)}
            </Tile>
            <Tile label={strings.statEntries}>{stats.entryCount}</Tile>
            <Tile label={strings.statTags}>{stats.tagCount}</Tile>
          </div>

          <h2 className="section-label">{strings.sectionPerTag}</h2>
          <div className={styles.chartCard}>
            <canvas ref={tagCanvasRef} />
          </div>

          <h2 className="section-label">{strings.sectionPerDay}</h2>
          <div className={styles.chartCard}>
            <canvas ref={dayCanvasRef} />
          </div>
        </main>
      </div>
    </Page>
  );
};

const Tile = ({ label, children }) => (
  <div className={styles.tile}>
    <span className={styles.tileLabel}>{label}</span>
    <span className={styles.tileValue}>{children}</span>
  </div>
);

export default Summary;