repos
/ timelite-nextjs master

timelite-nextjs

mirror archived upstream

A dead simple time tracker that keeps everything in local storage. Next.js, no accounts and no server.

handcodedlocalstoragenextjsreactself-hostedserverlesstime-trackingtimer

8.7 KB · 302 lines · JavaScript Raw History
  1import React, { useEffect, useRef, useContext, useMemo } from "react";
  2import Chart from "chart.js/auto";
  3
  4import Page from "../components/page";
  5import { Context } from "../components/context";
  6import strings from "../l10n/summary";
  7
  8import styles from "../styles/pages/summary.module.css";
  9
 10const PALETTE = [
 11  "#6B9E78",
 12  "#C9A84C",
 13  "#C47055",
 14  "#7EAAB8",
 15  "#7DB88C",
 16  "#DDC06A",
 17];
 18
 19const MS = {
 20  minute: 60 * 1000,
 21  hour: 60 * 60 * 1000,
 22  day: 24 * 60 * 60 * 1000,
 23};
 24
 25const formatDuration = (ms, s) => {
 26  if (!ms) return "0" + s.hoursSuffix;
 27  const hours = ms / MS.hour;
 28  if (hours >= 1) return `${hours.toFixed(hours >= 10 ? 0 : 1)}${s.hoursSuffix}`;
 29  const mins = Math.round(ms / MS.minute);
 30  return `${mins}${s.minsSuffix}`;
 31};
 32
 33const formatHours = (ms) => Math.round((ms / MS.hour) * 100) / 100;
 34
 35const startOfDay = (d) => {
 36  const x = new Date(d);
 37  x.setHours(0, 0, 0, 0);
 38  return x;
 39};
 40
 41const Summary = () => {
 42  const { state } = useContext(Context);
 43  strings.setLanguage(state.language);
 44
 45  const tagCanvasRef = useRef(null);
 46  const dayCanvasRef = useRef(null);
 47
 48  const log = state.log;
 49  const hasData = log.length > 0;
 50
 51  // Derived stats ----------------------------------------------------------
 52  const stats = useMemo(() => {
 53    if (!hasData) return null;
 54    const now = Date.now();
 55    const today = startOfDay(now).getTime();
 56    const weekAgo = today - 6 * MS.day;
 57
 58    let totalMs = 0;
 59    let todayMs = 0;
 60    let weekMs = 0;
 61    let longestMs = 0;
 62    const tagSet = new Set();
 63
 64    for (const e of log) {
 65      const start = +new Date(e.start);
 66      const end = +new Date(e.end);
 67      const dur = end - start;
 68      totalMs += dur;
 69      if (dur > longestMs) longestMs = dur;
 70      if (start >= today) todayMs += dur;
 71      if (start >= weekAgo) weekMs += dur;
 72      for (const t of e.tags || []) tagSet.add(t);
 73    }
 74
 75    return {
 76      totalMs,
 77      entryCount: log.length,
 78      todayMs,
 79      weekMs,
 80      longestMs,
 81      tagCount: tagSet.size,
 82    };
 83  }, [log, hasData]);
 84
 85  // Hours per tag
 86  const perTag = useMemo(() => {
 87    if (!hasData) return { labels: [], data: [] };
 88    const map = new Map();
 89    for (const e of log) {
 90      const dur = +new Date(e.end) - +new Date(e.start);
 91      for (const t of e.tags || []) {
 92        map.set(t, (map.get(t) || 0) + dur);
 93      }
 94    }
 95    const entries = [...map.entries()].sort((a, b) => b[1] - a[1]);
 96    return {
 97      labels: entries.map(([t]) => t),
 98      data: entries.map(([, ms]) => formatHours(ms)),
 99    };
100  }, [log, hasData]);
101
102  // Hours per day over last 14 days
103  const perDay = useMemo(() => {
104    if (!hasData) return { labels: [], data: [] };
105    const days = 14;
106    const todayStart = startOfDay(Date.now()).getTime();
107    const buckets = new Array(days).fill(0);
108    // DST-safe day stepping: setDate for the labels, round (not floor) for
109    // the bucket index so the one-hour DST drift cannot shift an entry into
110    // the neighboring day.
111    const labels = new Array(days).fill(0).map((_, i) => {
112      const d = new Date(todayStart);
113      d.setDate(d.getDate() - (days - 1 - i));
114      return `${d.getMonth() + 1}/${d.getDate()}`;
115    });
116    for (const e of log) {
117      const start = +new Date(e.start);
118      const daysAgo = Math.round((todayStart - startOfDay(start).getTime()) / MS.day);
119      if (daysAgo < 0 || daysAgo >= days) continue;
120      const dur = +new Date(e.end) - start;
121      buckets[days - 1 - daysAgo] += dur;
122    }
123    return { labels, data: buckets.map((ms) => formatHours(ms)) };
124  }, [log, hasData]);
125
126  // Chart: hours per tag ---------------------------------------------------
127  useEffect(() => {
128    if (!hasData || !tagCanvasRef.current) return;
129    const chart = new Chart(tagCanvasRef.current, {
130      type: "bar",
131      data: {
132        labels: perTag.labels,
133        datasets: [
134          {
135            label: strings.numHours,
136            data: perTag.data,
137            backgroundColor: perTag.labels.map(
138              (_, i) => PALETTE[i % PALETTE.length] + "cc"
139            ),
140            borderColor: perTag.labels.map(
141              (_, i) => PALETTE[i % PALETTE.length]
142            ),
143            borderWidth: 1,
144            borderRadius: 2,
145          },
146        ],
147      },
148      options: {
149        responsive: true,
150        maintainAspectRatio: true,
151        plugins: {
152          legend: { display: false },
153          tooltip: {
154            backgroundColor: "#13120e",
155            borderColor: "rgba(107,158,120,0.3)",
156            borderWidth: 1,
157            titleFont: { family: "JetBrains Mono, monospace" },
158            bodyFont: { family: "JetBrains Mono, monospace" },
159          },
160        },
161        scales: {
162          x: {
163            ticks: {
164              color: "#a09890",
165              font: { family: "JetBrains Mono, monospace", size: 11 },
166            },
167            grid: { color: "rgba(221,215,205,0.04)" },
168          },
169          y: {
170            ticks: {
171              color: "#a09890",
172              font: { family: "JetBrains Mono, monospace", size: 11 },
173            },
174            grid: { color: "rgba(221,215,205,0.04)" },
175          },
176        },
177      },
178    });
179    return () => chart.destroy();
180  }, [perTag, hasData, state.language]);
181
182  // Chart: hours per day ---------------------------------------------------
183  useEffect(() => {
184    if (!hasData || !dayCanvasRef.current) return;
185    const chart = new Chart(dayCanvasRef.current, {
186      type: "line",
187      data: {
188        labels: perDay.labels,
189        datasets: [
190          {
191            label: strings.numHours,
192            data: perDay.data,
193            fill: true,
194            tension: 0.35,
195            backgroundColor: "rgba(107,158,120,0.14)",
196            borderColor: "#6B9E78",
197            borderWidth: 1.5,
198            pointBackgroundColor: "#7DB88C",
199            pointBorderColor: "#0e0d0a",
200            pointRadius: 3,
201            pointHoverRadius: 5,
202          },
203        ],
204      },
205      options: {
206        responsive: true,
207        maintainAspectRatio: true,
208        plugins: {
209          legend: { display: false },
210          tooltip: {
211            backgroundColor: "#13120e",
212            borderColor: "rgba(107,158,120,0.3)",
213            borderWidth: 1,
214            titleFont: { family: "JetBrains Mono, monospace" },
215            bodyFont: { family: "JetBrains Mono, monospace" },
216          },
217        },
218        scales: {
219          x: {
220            ticks: {
221              color: "#a09890",
222              font: { family: "JetBrains Mono, monospace", size: 10 },
223            },
224            grid: { color: "rgba(221,215,205,0.04)" },
225          },
226          y: {
227            ticks: {
228              color: "#a09890",
229              font: { family: "JetBrains Mono, monospace", size: 10 },
230            },
231            grid: { color: "rgba(221,215,205,0.04)" },
232            beginAtZero: true,
233          },
234        },
235      },
236    });
237    return () => chart.destroy();
238  }, [perDay, hasData, state.language]);
239
240  if (!hasData) {
241    return (
242      <Page title="Summary">
243        <div className="page-grid">
244          <main className="page-main">
245            <h1 className="page-title">{strings.pageTitle}</h1>
246          </main>
247        </div>
248        <div className="empty-state">
249          <p className="empty-state-title">{strings.empty}</p>
250          <p className="empty-state-hint">{strings.emptyHint}</p>
251        </div>
252      </Page>
253    );
254  }
255
256  return (
257    <Page title="Summary">
258      <div className="page-grid">
259        <main className="page-main">
260          <h1 className="page-title">{strings.pageTitle}</h1>
261
262          <div className={styles.tiles}>
263            <Tile label={strings.statTotal}>
264              {formatDuration(stats.totalMs, strings)}
265            </Tile>
266            <Tile label={strings.statToday}>
267              {formatDuration(stats.todayMs, strings)}
268            </Tile>
269            <Tile label={strings.statWeek}>
270              {formatDuration(stats.weekMs, strings)}
271            </Tile>
272            <Tile label={strings.statLongest}>
273              {formatDuration(stats.longestMs, strings)}
274            </Tile>
275            <Tile label={strings.statEntries}>{stats.entryCount}</Tile>
276            <Tile label={strings.statTags}>{stats.tagCount}</Tile>
277          </div>
278
279          <h2 className="section-label">{strings.sectionPerTag}</h2>
280          <div className={styles.chartCard}>
281            <canvas ref={tagCanvasRef} />
282          </div>
283
284          <h2 className="section-label">{strings.sectionPerDay}</h2>
285          <div className={styles.chartCard}>
286            <canvas ref={dayCanvasRef} />
287          </div>
288        </main>
289      </div>
290    </Page>
291  );
292};
293
294const Tile = ({ label, children }) => (
295  <div className={styles.tile}>
296    <span className={styles.tileLabel}>{label}</span>
297    <span className={styles.tileValue}>{children}</span>
298  </div>
299);
300
301export default Summary;