diff --git a/package.json b/package.json index 9723783..61bbf63 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "prettier-plugin-tailwindcss": "^0.2.3", "react": "^18.2.0", "react-chartjs-2": "^5.2.0", + "react-contribution-calendar": "^1.4.0", "react-dom": "^18.2.0", "react-hook-form": "^7.45.0", "react-router-dom": "^6.17.0", diff --git a/public/DependencyLicenses.txt b/public/DependencyLicenses.txt index 54411de..4ea9152 100644 --- a/public/DependencyLicenses.txt +++ b/public/DependencyLicenses.txt @@ -793,6 +793,9 @@ │ │ ├─ URL: https://github.com/reactchartjs/react-chartjs-2.git │ │ ├─ VendorName: Jeremy Ayerst │ │ └─ VendorUrl: https://github.com/reactchartjs/react-chartjs-2 +│ ├─ react-contribution-calendar@1.4.0 +│ │ ├─ URL: git+https://github.com/SeiwonPark/react-contribution-calendar.git +│ │ └─ VendorUrl: https://github.com/SeiwonPark/react-contribution-calendar#readme │ ├─ react-dom@18.2.0 │ │ ├─ URL: https://github.com/facebook/react.git │ │ └─ VendorUrl: https://reactjs.org/ diff --git a/src/App.css b/src/App.css index 0713abd..a00b7dc 100644 --- a/src/App.css +++ b/src/App.css @@ -45,3 +45,9 @@ html { --color: rgb(0, 69, 198); } } + +/* workaround because library has annoying breakpoints that hide parts of the graph */ +.contribution-calendar > .container { + width: 100% !important; + max-width: 100% !important; +} diff --git a/src/pages/StatisticsPage.tsx b/src/pages/StatisticsPage.tsx index e616429..147a909 100644 --- a/src/pages/StatisticsPage.tsx +++ b/src/pages/StatisticsPage.tsx @@ -1,20 +1,4 @@ -import { Chart, ChartProps } from "react-chartjs-2"; -import { - Chart as ChartJS, - Title, - CategoryScale, - TimeScale, - Tooltip, -} from "chart.js"; -import "chartjs-adapter-luxon"; - -ChartJS.register(Title, CategoryScale, TimeScale, Tooltip); - -import { MatrixController, MatrixElement } from "chartjs-chart-matrix"; - -ChartJS.register(MatrixController, MatrixElement); - -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { DateTime, Duration } from "luxon"; import { useNavigate } from "react-router-dom"; import { useStore } from "../store"; @@ -26,6 +10,8 @@ import { WeekView } from "../components/StatsWeekView"; import { QuickStats } from "../components/TrackPageStats"; import { MonthView } from "../components/StatsMonthViewDays"; import { TaskDistributionPie } from "../components/StatsTaskDistribution"; +import { ContributionCalendar } from "react-contribution-calendar"; +import { useIsDarkTheme } from "../util"; export function StatisticsPage() { const now = DateTime.now(); @@ -139,7 +125,7 @@ export function StatisticsPage() {
-
Current Year
+
Heatmap
Time spend by Task
@@ -178,34 +164,39 @@ export function StatisticsPage() { ); } +const maxFeasableMinutesPerDay = 60 * 8; +// const maxPossibleMinutesPerDay = 60 * 24; + +const day_size = 12; +const free_space = 70; // space for other stuff + function ActivityMap() { - type dataType = { - x: string; - y: string; - d: string; - v: number; + const isDarkTheme = useIsDarkTheme(); + type dataType2 = { + [date: string]: { + level: number; + data: { + // this is not a lib feature yet + custom_tooltip: string; + }; + }; }; - const [data, setData] = useState(null); + const [data2, setData2] = useState(null); const entries = useStore((store) => store.getTrackedEntries()); - const [timeSpan] = useState([ - DateTime.now() - .minus(Duration.fromObject({ year: 1 })) - .toMillis(), - DateTime.now().endOf("day").toMillis(), + const [timeSpan, setTimeSpan] = useState([ + DateTime.now().minus(Duration.fromObject({ year: 1 })), + DateTime.now().endOf("day"), ]); - console.log(entries.length); useEffect(() => { - console.log(entries.length, timeSpan[0], timeSpan[1]); - // idea for later: move work into webworker? - setData(null); + setData2(null); - const dataEntries: dataType[] = []; - let working_day = DateTime.fromMillis(timeSpan[0]); - const end = DateTime.fromMillis(timeSpan[1]); + const data2Entries: dataType2[] = []; + let working_day = timeSpan[0]; + const end = timeSpan[1]; // this is for improving performance a little bit const entries_in_span = getEntriesTouchingTimeframe( entries, @@ -229,167 +220,86 @@ function ActivityMap() { .map((e) => e.duration || 0) .reduce((previous, current) => previous + current, 0); - dataEntries.push({ - x: startOfDay.toISODate() || "", - y: String(startOfDay.weekday), - v: Math.floor(timeSpentThatDay / 60000), // convert to minutes per day - d: startOfDay.toISODate() || "", + const minutes = Math.floor(timeSpentThatDay / 60000); // convert to minutes per day + + const level = Math.min( + Math.max(Math.floor((minutes / maxFeasableMinutesPerDay) * 4), 0), + 4, + ); + + data2Entries.push({ + [startOfDay.toFormat("yyyy-MM-dd")]: { + level, + data: { + custom_tooltip: + startOfDay.toFormat("yyyy-MM-dd") + + "\n" + + Duration.fromObject({ minutes }) + .shiftTo("hours", "minutes") + .toHuman(), + }, + }, }); working_day = working_day.plus({ days: 1 }); } - console.log(dataEntries); + // console.log(dataEntries); - setData(dataEntries); + setData2(data2Entries); }, [`${entries.length}`, timeSpan[0], timeSpan[1]]); - const { options, chartData } = useMemo(() => { - const scales: any = { - y: { - type: "time", - offset: true, - time: { - unit: "day", - round: "day", - isoWeekday: 1, - parser: "E", - displayFormats: { - day: "EEE", - }, - }, - reverse: true, - position: "right", - ticks: { - maxRotation: 0, - autoSkip: true, - padding: 1, - font: { - size: 9, - }, - }, - grid: { - display: false, - drawBorder: false, - tickLength: 0, - }, - }, - x: { - type: "time", - position: "bottom", - offset: true, - time: { - unit: "week", - round: "week", - isoWeekday: 1, - displayFormats: { - week: "MMM dd", - }, - }, - ticks: { - maxRotation: 0, - autoSkip: true, - font: { - size: 9, - }, - }, - grid: { - display: false, - drawBorder: false, - tickLength: 0, - }, - }, - }; - const options: ChartProps< - "matrix", - { x: string; y: string; d: string; v: number }[], - unknown - >["options"] = { - responsive: true, - aspectRatio: 5, - plugins: { - tooltip: { - displayColors: false, - callbacks: { - title(items) { - const context = items[0]; - if (context) { - return ( - context.dataset.data[context.dataIndex] as any as dataType - ).d; - } - }, - label(context) { - const { v } = context.dataset.data[ - context.dataIndex - ] as any as dataType; - return Duration.fromObject({ minutes: v }) - .shiftTo("hours", "minutes") - .toHuman(); - }, - }, - }, - }, - scales: scales, - layout: { - padding: { - top: 10, - }, - }, - }; + const container = useRef(null); + const [availableWidth, setAvailableWidth] = useState(null); - const maxFeasableMinutesPerDay = 60 * 8; - // const maxPossibleMinutesPerDay = 60 * 24; - - const chartData: ChartProps< - "matrix", - { x: string; y: string; d: string; v: number }[], - unknown - >["data"] = { - datasets: [ - { - data: data || [], - backgroundColor(c) { - const minutes = (c.dataset.data[c.dataIndex] as any as dataType).v; - const alpha = minutes / maxFeasableMinutesPerDay; - let green = 255; - if (alpha > 1) { - green = 255 - 255 * (alpha - 1); - } - return `rgba(0,${green},200, ${alpha})`; - }, - borderColor(c) { - const minutes = (c.dataset.data[c.dataIndex] as any as dataType).v; - const alpha = minutes / maxFeasableMinutesPerDay; - let green = 255; - if (alpha > 1) { - green = 255 - 255 * (alpha - 1); - } - return `rgba(0,${green},200, ${alpha})`; - }, - borderWidth: 1, - hoverBackgroundColor: "lightblue", - hoverBorderColor: "blue", - width(c) { - const a = c.chart.chartArea || {}; - return (a.right - a.left) / 53 - 1; - }, - height(c) { - const a = c.chart.chartArea || {}; - return (a.bottom - a.top) / 7 - 1; - }, - }, - ], + useEffect(() => { + const update = () => { + if (container.current) { + const availableWidth = + container.current.getBoundingClientRect().width - 30; + const weeks = Math.floor( + Math.max((availableWidth || 0) - free_space, 0) / day_size, + ); + setTimeSpan([DateTime.now().minus({ weeks }), DateTime.now()]); + setAvailableWidth(availableWidth); + } }; + if (container.current) { + update(); + } + window.addEventListener("resize", update); + return () => window.removeEventListener("resize", update); + }, [container.current]); + + const start = timeSpan[0].toFormat("yyyy-MM-dd"); + const end = timeSpan[1].toFormat("yyyy-MM-dd"); - return { options, chartData }; - }, [data]); + const theme = isDarkTheme ? "dark_winter" : "winter"; return (
- {data === null &&
Loading Data...
} - {data !== null && ( - - )} + {data2 === null &&
Loading Data...
} +
+ {availableWidth && data2 !== null && ( +
+ console.log(data)} + scroll={false} + style={{}} + /> +
+ )} +
); } diff --git a/src/util.ts b/src/util.ts index a43af7c..e27b8dd 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1,3 +1,5 @@ +import { useEffect, useState } from "react"; + // taken from https://blog.logrocket.com/react-suspense-data-fetching/#how-to-use-suspense /** convert promise to suspend */ export function wrapPromise(promise: Promise) { @@ -39,3 +41,24 @@ export function arrayMin(arr: number[]) { } return min; } + +export function useIsDarkTheme() { + const [isDarkTheme, setIsDarkTheme] = useState( + window.matchMedia && + window.matchMedia("(prefers-color-scheme: dark)").matches, + ); + useEffect(() => { + if (!window.matchMedia) { + return; + } + const mm = window.matchMedia("(prefers-color-scheme: dark)"); + const callback = (event: MediaQueryListEvent) => { + setIsDarkTheme(event.matches); + }; + + mm.addEventListener("change", callback); + return () => mm.removeEventListener("change", callback); + }); + + return isDarkTheme; +} diff --git a/yarn.lock b/yarn.lock index 660f4e4..19d0ab0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1623,6 +1623,11 @@ react-chartjs-2@^5.2.0: resolved "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.2.0.tgz" integrity sha512-98iN5aguJyVSxp5U3CblRLH67J8gkfyGNbiK3c+l1QI/G4irHMPQw44aEPmjVag+YKTyQ260NcF82GTQ3bdscA== +react-contribution-calendar@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/react-contribution-calendar/-/react-contribution-calendar-1.4.0.tgz#fda910c14cac6e005ea686cf3757b7af0e271e88" + integrity sha512-LjkLAMejUMLzc0CIGn9l3Iq5nPfkOpzJ9tRyGkul9LcPA4fPEoZNuZSwVUkZD/JQBCBfa/0MF1iuQ9DfgEbjFQ== + react-dom@^18.2.0: version "18.2.0" resolved "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz"