Compare commits

...
41 Commits
Author SHA1 Message Date
joshuacoles 026d0cfd56 Add macos build for arm
Build and Publish Docker Container / build-arm (push) Successful in 1m50s
Build and Publish Docker Container / build (push) Successful in 3m44s
2024-03-04 19:46:13 +00:00
joshuacoles 1a14485c57 Update reads to toggl database 2024-03-04 19:46:07 +00:00
joshuacoles 50730a2b09 Simplify calendar code 2024-03-03 18:57:03 +00:00
joshuacoles 9fc14cffd0 Add Caddyfile to gitignore 2024-03-03 18:47:18 +00:00
joshuacoles e534506894 Update build.yml 2024-02-28 17:20:42 +00:00
joshuacoles 89558cdd1a Remove unnecessary user interaction with donut chart
Build and Publish Docker Container / build (push) Successful in 4m22s
2024-02-28 17:18:56 +00:00
joshuacoles d6b319e6a5 Colour weekends differently by default 2024-02-28 16:49:35 +00:00
joshuacoles f350018eb2 Add support for penalising overtime to the heatmap
Build and Publish Docker Container / build (push) Successful in 3m44s
2024-02-28 16:43:40 +00:00
joshuacoles 65e8899426 Cleanup imports
Build and Publish Docker Container / build (push) Successful in 3m17s
2024-02-25 19:58:31 +00:00
joshuacoles ec91f6df42 Fix sizing of the heatmap and make goal configurable 2024-02-25 19:57:46 +00:00
joshuacoles f2dd382cf3 Improve the heatmap 2024-02-25 19:53:58 +00:00
joshuacoles 058466294b Initial import of AI heatmap 2024-02-25 19:24:58 +00:00
joshuacoles cf1788ebcd Remove testing donut page
Build and Publish Docker Container / build (push) Successful in 3m12s
2024-02-22 14:57:43 +00:00
joshuacoles e67e01fd0a Add Donut component that can render server side (if that ever actually works) 2024-02-22 14:57:30 +00:00
joshuacoles 17edc05ec4 Try to clean it up a little
Build and Publish Docker Container / build (push) Successful in 3m45s
2024-02-13 22:15:25 +00:00
joshuacoles 9752ede109 An unsatisfying solution to the problem
Build and Publish Docker Container / build (push) Failing after 2m57s
2024-02-13 22:06:58 +00:00
joshuacoles 3d0c09bba9 Fix issue attempting to pre-render pages that queried the database
Build and Publish Docker Container / build (push) Successful in 3m48s
2024-02-11 19:21:04 +00:00
joshuacoles 9ba7c34902 You know what, while we are here do a general pnpm update
Build and Publish Docker Container / build (push) Failing after 2m51s
2024-02-11 19:10:04 +00:00
joshuacoles 31cb328caf Update react and typescript to fix build issues
Build and Publish Docker Container / build (push) Failing after 3m4s
2024-02-11 19:08:12 +00:00
joshuacoles e967244158 Parse postgres connection information out of the environment
Build and Publish Docker Container / build (push) Failing after 3m12s
2024-02-11 18:59:32 +00:00
joshuacoles 28b01769e5 Rearrange everything and migrate to talking directly to the database 2024-02-11 18:39:39 +00:00
joshuacoles 363c2fc678 Swap to pnpm 2024-02-11 18:08:16 +00:00
joshuacoles a47eadd68f Cleanup structure 2024-02-11 17:49:29 +00:00
joshuacoles 68893d503d Fix warning for unused variable
Build and Publish Docker Container / build (push) Successful in 4m19s
2024-02-11 17:21:38 +00:00
joshuacoles d8ad2f80b8 Use server actions over swr for retrieving data 2024-02-11 17:20:41 +00:00
joshuacoles b82c8fe979 Split out card components 2024-02-10 08:48:54 +00:00
joshuacoles 0314ccc2b3 Add start and end times to SubjectComparisonCard
Build and Publish Docker Container / build (push) Has started running
2024-02-07 11:52:57 +00:00
joshuacoles 44df8a6fc6 Add OverviewConfig to allow for additional queries, setup sem2 page 2024-02-07 11:51:04 +00:00
joshuacoles 0a89712965 Remove IDEA config, add types for things 2023-12-31 14:47:54 +00:00
joshuacoles c7fcb0990f Fix import 2023-12-25 12:53:32 +00:00
joshuacoles 0ce2096fac I am lazy, remove the new heatmap for now 2023-12-25 12:52:10 +00:00
joshuacoles 52b6b0eaee Bring some more things into the render fn 2023-12-24 18:40:33 +00:00
joshuacoles a61677fe35 Adapt Month labels 2023-12-24 18:33:46 +00:00
joshuacoles c85776a499 Initial import of react-calendar-heatmap code 2023-12-22 17:52:08 +00:00
joshuacoles fb9547991c Fix the tooltips
Build and Publish Docker Container / build (push) Successful in 3m12s
2023-12-22 17:43:18 +00:00
joshuacoles 44184095b8 Add date to hover on days without time tracked
Build and Publish Docker Container / build (push) Failing after 2m37s
2023-12-22 11:42:58 +00:00
joshuacoles 766c2eba3f Revert "Try build on the mac"
Build and Publish Docker Container / build (push) Successful in 2m44s
This reverts commit 17257ac46a.
2023-12-20 23:15:58 +00:00
joshuacoles 17257ac46a Try build on the mac
Build and Publish Docker Container / build (push) Has been cancelled
2023-12-20 23:09:36 +00:00
joshuacoles 69b5476b4a Improve tooltips, fix per day calculation
Build and Publish Docker Container / build (push) Successful in 2m45s
2023-12-20 22:49:38 +00:00
joshuacoles eb87f44b76 Fix grid UI on mobile
Build and Publish Docker Container / build (push) Successful in 2m57s
2023-12-20 22:33:54 +00:00
joshuacoles b0ad968622 Update dev api proxy to use prod endpoint 2023-12-20 19:08:42 +00:00
33 changed files with 7318 additions and 6603 deletions
+27 -1
View File
@@ -25,9 +25,35 @@ jobs:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and Push Docker image -- finn-board
- name: Build and Push Docker image
uses: docker/build-push-action@675965c0e16f1a0f94ecafff969d8c966f92c17b
with:
context: .
push: true
tags: git.joshuacoles.me/joshuacoles/revision-ui:latest
build-arm:
runs-on: macos-latest
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Login to Docker
uses: docker/login-action@v1
with:
registry: git.joshuacoles.me
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and Push Docker image
uses: docker/build-push-action@675965c0e16f1a0f94ecafff969d8c966f92c17b
with:
context: .
push: true
tags: git.joshuacoles.me/joshuacoles/revision-ui:arm
+9
View File
@@ -1,5 +1,8 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# IDEA
/.idea
# dependencies
/node_modules
/.pnp
@@ -34,3 +37,9 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
# Private env files
.env
# Caddyfile for local development when testing weird deploys
/Caddyfile
-5
View File
@@ -1,5 +0,0 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
-6
View File
@@ -1,6 +0,0 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
</profile>
</component>
-8
View File
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/tremor-dashboard.iml" filepath="$PROJECT_DIR$/.idea/tremor-dashboard.iml" />
</modules>
</component>
</project>
-12
View File
@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
<excludeFolder url="file://$MODULE_DIR$/temp" />
<excludeFolder url="file://$MODULE_DIR$/tmp" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
Generated
-6
View File
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>
+18
View File
@@ -0,0 +1,18 @@
import type { Config } from 'jest'
import nextJest from 'next/jest.js'
const createJestConfig = nextJest({
// Provide the path to your Next.js app to load next.config.js and .env files in your test environment
dir: './',
})
// Add any custom config to be passed to Jest
const config: Config = {
coverageProvider: 'v8',
testEnvironment: 'jsdom',
// Add more setup options before each test is run
// setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
}
// createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async
export default createJestConfig(config)
-6
View File
@@ -1,12 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
rewrites: async () => [
{
source: '/api/:path*',
destination: 'http://cosmos:8074/:path*',
},
],
}
module.exports = nextConfig
-6319
View File
File diff suppressed because it is too large Load Diff
+32 -19
View File
@@ -1,37 +1,50 @@
{
"name": "tremor-dashboard",
"name": "revision-tracker",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
"lint": "next lint",
"test": "jest"
},
"dependencies": {
"@heroicons/react": "^1.0.6",
"@tremor/react": "^3.12.0",
"@types/ramda": "^0.29.9",
"@tremor/react": "^3.13.4",
"@types/ramda": "^0.29.10",
"date-fns": "^2.30.0",
"next": "14.0.4",
"heat-calendar": "^1.0.7",
"kysely": "^0.27.2",
"next": "^14.1.0",
"pg": "^8.11.3",
"ramda": "^0.29.1",
"react": "^18",
"react": "^18.2.0",
"react-calendar-heatmap": "^1.9.0",
"react-dom": "^18",
"react-tooltip": "^5.25.0",
"reaviz": "^15.2.1",
"swr": "^2.2.4"
"react-date-heatmap": "^1.0.5",
"react-dom": "^18.2.0",
"react-tooltip": "^5.26.1",
"reaviz": "^15.7.0",
"swr": "^2.2.4",
"zod": "^3.22.4"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^18",
"@types/react-calendar-heatmap": "^1.6.6",
"@types/react-dom": "^18",
"autoprefixer": "^10.0.1",
"eslint": "^8",
"@testing-library/jest-dom": "^6.4.2",
"@testing-library/react": "^14.2.1",
"@types/node": "^20.11.17",
"@types/pg": "^8.11.0",
"@types/react": "^18.2.55",
"@types/react-calendar-heatmap": "^1.6.7",
"@types/react-dom": "^18.2.19",
"autoprefixer": "^10.4.17",
"eslint": "^8.56.0",
"eslint-config-next": "14.0.4",
"postcss": "^8",
"tailwindcss": "^3.3.0",
"typescript": "^5"
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"kysely-codegen": "^0.11.0",
"postcss": "^8.4.35",
"tailwindcss": "^3.4.1",
"ts-node": "^10.9.2",
"typescript": "^5.3.3"
}
}
+6427
View File
File diff suppressed because it is too large Load Diff
-94
View File
@@ -1,94 +0,0 @@
"use client";
import useSWR from "swr";
import {fetcher} from "@/app/utils";
import {useEffect, useState} from "react";
import {Card, DonutChart, Metric, Text, Title} from "@tremor/react";
import * as R from "ramda";
export function SubjectOverviewCard({
projectId,
title
}: {
projectId: number,
title?: string
}) {
const {
data: _project
} = useSWR<any>(`/api/project?select=raw_json&toggl_id=eq.${projectId}`, fetcher);
const [project, setProject] = useState({
name: '',
color: '',
});
useEffect(() => {
if (_project) {
setProject(_project[0].raw_json);
}
}, [_project]);
const {
data,
error,
isLoading,
} = useSWR<any[]>(`/api/time_entry?select=raw_json&project_id=eq.${projectId}&start=gt.2023-12-15T00:00:00.000Z`, fetcher);
const [a, setA] = useState(0)
useEffect(() => {
if (data) {
setA(data
.map((entry) => entry.raw_json.seconds)
.reduce((a, b) => a + b, 0));
}
}, [data]);
return (
<Card style={{ borderColor: project?.color }} decoration={'left'}>
<Title>{title ?? project?.name}</Title>
<Text>Total</Text>
<Metric>{(a / (60 * 60)).toFixed(2)} hours</Metric>
</Card>
)
}
export function SubjectComparisonCard({projectIds}: {
projectIds: number[]
}) {
const {
data,
error,
isLoading,
} = useSWR<any[]>(`/api/time_entry?select=raw_json,project:project_id(name,raw_json)&project_id=in.(${projectIds.join(',')})`, fetcher, {});
const [a, setA] = useState<{ name: string, value: number }[]>([]);
const [colours, setColours] = useState<string[]>([]);
useEffect(() => {
const a = R.toPairs(R.groupBy((entry) => entry.project.name, data ?? []))
.map(([name, entries]) => ({
name,
value: (entries ?? []).map((entry) => entry.raw_json.seconds).reduce((a, b) => a + b, 0),
colour: entries?.[0].project.raw_json.color
}))
setA(a);
setColours(a.map((entry) => entry.colour));
}, [data]);
const valueFormatter = (number: number) => `${(number / (60 * 60)).toFixed(2)} hours`;
return (
<Card className="col">
<Title>Relative Breakdown</Title>
<DonutChart
className="mt-6"
data={a ?? []}
category="value"
index="name"
valueFormatter={valueFormatter}
colors={colours}
/>
</Card>
)
}
+21
View File
@@ -0,0 +1,21 @@
import {NextRequest} from "next/server";
import {getData} from "@/data/fetchData";
import {periodMap} from "@/data/periods";
export async function GET(request: NextRequest): Promise<Response> {
const period = request.nextUrl.searchParams.get('period');
if (period == null) {
return new Response('No period provided', {status: 400});
}
if (!periodMap.hasOwnProperty(period)) {
return new Response('Invalid period', {status: 400});
}
const config = periodMap[period as keyof typeof periodMap];
const data = await getData(config);
return Response.json(data)
}
-92
View File
@@ -1,92 +0,0 @@
"use client";
import {Card, Title} from "@tremor/react";
import {useEffect, useRef, useState} from "react";
import useSWR from "swr";
import {fetcher} from "@/app/utils";
import * as R from 'ramda';
import * as dFns from 'date-fns';
import CalendarHeatmap from 'react-calendar-heatmap';
import 'react-calendar-heatmap/dist/styles.css';
import './calendar-styles.css'
import { Tooltip } from 'react-tooltip';
const initialDate = dFns.parseISO('2023-12-15T00:00:00.000Z')
const endDate = dFns.parseISO('2024-01-20T00:00:00.000Z')
const projectIds = [
195482340,
195519024,
195518593,
195754611,
];
const dailyGoal = 4;
const granularity = 4;
function computeCompletionShade(value: number) {
const linearValue = Math.round((value / dailyGoal) * granularity);
if (linearValue == 0 && value > 0) return 1;
if (linearValue > granularity) return granularity;
return linearValue;
}
function useCalendarData() {
const {
data: timeEntries,
error,
isLoading,
} = useSWR<{
raw_json: {
start: string,
seconds: number,
}
}[]>(`/api/time_entry?select=raw_json&start=gt.${dFns.formatISO(initialDate)}&project_id=in.(${projectIds.join(',')})`, fetcher, {});
const [data, setData] = useState<{
date: Date,
count: number,
}[]>([]);
useEffect(() => {
if (timeEntries == undefined) return;
// Group by day, sum up seconds
const grouped = R.groupBy((entry) => {
return dFns.formatISO(dFns.parseISO(entry.raw_json.start));
}, timeEntries);
const summed = R.mapObjIndexed((entries) => {
return R.sum((entries ?? []).map((entry) => entry.raw_json.seconds))
}, grouped);
setData(Object.entries(summed)
.map(([key, value]) => ({
date: dFns.parseISO(key),
count: value / (60 * 60),
})));
}, [timeEntries]);
return data
}
export function CalendarOverviewCard() {
const data = useCalendarData();
return <Card className="col-span-1">
<Tooltip id="calendar-tooltip"/>
<Title>Overview</Title>
<CalendarHeatmap
showWeekdayLabels={true}
startDate={initialDate}
endDate={endDate}
values={data}
classForValue={value => `color-github-${computeCompletionShade(value?.count ?? 0)}`}
tooltipDataAttrs={(value: any) => {
return value.count ? {
'data-tooltip-id': `calendar-tooltip`,
'data-tooltip-content': `${value.count.toFixed(2)} hours`
} : undefined;
}}
/>
</Card>
}
+2 -2
View File
@@ -5,8 +5,8 @@ import './globals.css'
const inter = Inter({ subsets: ['latin'] })
export const metadata: Metadata = {
title: 'Create Next App',
description: 'Generated by create next app',
title: 'Work Tracker',
description: 'Track time spent on different subjects',
}
export default function RootLayout({
+13 -28
View File
@@ -1,33 +1,18 @@
import {SubjectComparisonCard, SubjectOverviewCard} from "@/app/a.client";
import {CalendarOverviewCard} from "@/app/calendarOverviewCard";
import Link from "next/link";
export default function Home() {
return (
<main className="m-6">
<h1 className="text-3xl font-semibold text-slate-900 dark:text-white my-2">Revision Tracker</h1>
return <main className="m-6 text-slate-900 dark:text-white">
<h1 className="text-3xl font-semibold my-2">Work Tracker</h1>
<div className="grid gap-5 grid-cols-4">
<SubjectOverviewCard
projectId={195482340}
/>
<SubjectOverviewCard
title='Measure Theory'
projectId={195519024}
/>
<SubjectOverviewCard
title='Quantum Mechanics'
projectId={195518593}
/>
<SubjectOverviewCard
projectId={195754611}
/>
<CalendarOverviewCard/>
</div>
<ol>
<li>
<Link href="/sem1-revision" className={"text-blue-500 underline underline-offset-2"}>
Semester 1 Revision
</Link>
</li>
<li>
<Link href="/sem2" className={"text-blue-500 underline underline-offset-2"}>Semester 2</Link>
</li>
</ol>
</main>
)
}
+5
View File
@@ -0,0 +1,5 @@
import OverviewPageWrapper from "@/components/PageWrapper";
export default function Home() {
return <OverviewPageWrapper period={'y5-s1-revision'}/>
}
+5
View File
@@ -0,0 +1,5 @@
import OverviewPageWrapper from "@/components/PageWrapper";
export default function Home() {
return <OverviewPageWrapper period={'y5-s2'}/>
}
-4
View File
@@ -1,4 +0,0 @@
export const fetcher = (
input: string | URL | globalThis.Request,
init?: RequestInit,
) => fetch(input, init).then(res => res.json())
+96
View File
@@ -0,0 +1,96 @@
import * as R from "ramda";
import DonutTooltip from "@/components/DonutTooltip";
import './donut-styles.css'
export type DonutChartData = {
label: string;
colour: string;
value: number;
}[];
export function Donut({
data,
centerLabel,
formatter,
hoverSuffix
}: {
data: DonutChartData,
formatter: (value: number) => string,
centerLabel: string,
hoverSuffix: string,
}) {
const radius = 100 / (2 * Math.PI);
const backgroundColour = "#d2d3d4";
const preppedData = R.sortBy(R.compose(R.negate, R.prop('value')), data);
const total = R.sum(R.pluck('value', preppedData));
const normalisingFactor = 100 / total;
return (
<>
<DonutTooltip/>
<svg width="100%" height="100%" viewBox="0 0 42 42" className="donut">
{/* The hole in the middle of the donut */}
<circle
className="donut-hole"
cx="21"
cy="21"
r={radius}
fill="#fff"
/>
{/* The ring in the background of the donut, if it was not 100% full */}
<circle
className="donut-ring"
cx="21"
cy="21"
r={radius}
fill="transparent"
stroke={backgroundColour}
strokeWidth="3"
/>
{
preppedData.map((segment, index, array) => {
const {
label,
colour,
value
} = segment;
const precedingSegments = array.slice(0, index);
const offset = precedingSegments.reduce((acc, segment) => acc + segment.value, 0);
return (
<circle
data-tooltip-id="donut-tooltip"
data-tooltip-content={`${label}: ${formatter(value)}${hoverSuffix}`}
data-tooltip-place="top"
key={index}
className="donut-segment"
cx="21"
cy="21"
r={radius}
fill="transparent"
stroke={colour}
strokeWidth="3"
strokeDasharray={`${value * normalisingFactor} ${100 - value * normalisingFactor}`}
strokeDashoffset={100 - (offset * normalisingFactor) + 25}
style={{outline: 'none'}}
/>
)
})
}
<g className="chart-text" style={{ userSelect: 'none'}}>
<text x="50%" y="50%" className="chart-number">
{formatter(total)}
</text>
<text x="50%" y="50%" className="chart-label">
{centerLabel}
</text>
</g>
</svg>
</>
);
}
+7
View File
@@ -0,0 +1,7 @@
'use client';
import {Tooltip} from "react-tooltip";
export default function DonutTooltip() {
return <Tooltip id="donut-tooltip"/>
}
+159
View File
@@ -0,0 +1,159 @@
import React from "react";
import * as dFns from "date-fns";
import * as R from 'ramda';
interface DateValue {
date: Date;
count: number;
}
interface Props {
startDate: Date;
endDate: Date;
data: DateValue[];
className?: string;
goal: number;
penaliseOvertime?: boolean;
}
function GitHubCalendarHeatmap({
startDate,
endDate,
data,
className,
goal,
penaliseOvertime = false,
}: Props) {
const numDays = dFns.differenceInDays(endDate, startDate);
// Calculate the number of weeks in the range
const numWeeks = Math.ceil(numDays / 7);
// Calculate the size of each square in the heatmap and the padding between each square
const squareSize = 15;
const padding = 2;
const weekTotalPadding = 10; // Extra padding for the week total row
const totalWidth = numWeeks * (squareSize + padding);
const totalHeight = 8 * (squareSize + padding) + weekTotalPadding; // Added an extra row for week total
// Goal times for the daily and weekly squares
const dayGoalTime = goal;
const weekGoalTime = 5 * dayGoalTime;
const getColorForValue = (value: number, goalTime: number, zeroColor: string): string => {
const buckets = [
{
min: -Infinity,
max: 0,
color: zeroColor,
},
{
min: 0,
max: 0.40,
color: '#d6e685',
},
{
min: 0.40,
max: 0.50,
color: '#8cc665',
},
{
min: 0.50,
max: 0.90,
color: '#44a340',
},
{
min: 0.90,
// If penalising overtime, the max is 1.125, otherwise it's infinity (ie this is the last bucket)
max: penaliseOvertime ? 1.125 : Infinity,
color: '#1e6823',
},
{
min: 1.125,
max: 1.25,
color: '#f59e0b'
},
{
min: 1.25,
max: Infinity,
color: '#ef4444'
},
];
const linearValue = value / goalTime;
return R.find(minMax => minMax.min < linearValue && linearValue <= minMax.max, buckets)!.color;
};
const getWeekTotal = (weekIndex: number): number => {
const weekStart = dFns.addWeeks(dFns.startOfWeek(startDate, {weekStartsOn: 1}), weekIndex);
return data.filter(dv => dFns.isSameWeek(dv.date, weekStart, {weekStartsOn: 1}))
.reduce((total, {count}) => total + count, 0);
};
const dateValues: DateValue[] = dFns.eachDayOfInterval({
start: startDate,
end: endDate,
}).map((date) => {
const foundData = data.find(
({date: dataDate}) => dFns.isEqual(dFns.startOfDay(date), dFns.startOfDay(dataDate))
);
return {
date,
count: foundData ? foundData.count : 0,
}
});
return (
<svg width="100%" height="100%" viewBox={`0 0 ${totalWidth} ${totalHeight}`} className={className}>
{dateValues.map(({
date,
count
}) => {
const weekIndex = dFns.differenceInCalendarWeeks(date, startDate, {weekStartsOn: 1});
const dayOfWeek = (date.getDay() + 6) % 7;
const x = weekIndex * (squareSize + padding);
const y = dayOfWeek * (squareSize + padding);
const color = getColorForValue(count, dayGoalTime, dFns.isWeekend(date) ? '#d4d4d4' : '#eeeeee');
return (
<rect
key={date.toISOString()}
x={x}
y={y}
width={squareSize}
height={squareSize}
fill={color}
data-tooltip-id={`calendar-tooltip`}
data-tooltip-content={count ? `${dFns.format(date, 'EEE do')}: ${count.toFixed(2)} hours` : `${dFns.format(date, 'EEE do')}`}
/>
);
})}
{Array(numWeeks).fill(0)
.map((_, weekIndex) => {
const x = weekIndex * (squareSize + padding);
const y = 7 * (squareSize + padding) + weekTotalPadding; // Position for the week total
const count = getWeekTotal(weekIndex);
const color = getColorForValue(count, weekGoalTime, '#eeeeee');
return (
<rect
key={`week-total-${weekIndex}`}
x={x}
y={y}
width={squareSize}
height={squareSize}
fill={color}
data-tooltip-id={`calendar-tooltip`}
data-tooltip-content={count ? `Week total ${count.toFixed(2)} hours` : ``}
/>
);
})}
</svg>
);
}
export default GitHubCalendarHeatmap;
+59
View File
@@ -0,0 +1,59 @@
"use client";
import {SubjectComparisonCard} from "@/components/cards/subjectComparisonCard";
import {CalendarOverviewCard} from "@/components/cards/calendarOverviewCard";
import {SubjectOverviewCard} from "@/components/cards/subjectOverviewCard";
import useSWR from "swr";
import {Data, OverviewConfig} from "@/data/fetchData";
import {PeriodKey} from "@/data/periods";
function useData(periodKey: PeriodKey, initialData: Data = {
projects: [],
timeEntries: [],
}): Data {
const {data} = useSWR(periodKey, async () => {
const res = await fetch(`/api?period=${periodKey}`);
const json = await res.json();
return json as Data;
});
return data ?? initialData;
}
export default function OverviewPage({
config,
initialData
}: {
config: OverviewConfig,
initialData?: Data,
}) {
const data = useData(config.periodKey, initialData);
return (
<main className="m-6">
<h1 className="text-3xl font-semibold text-slate-900 dark:text-white my-2">{config.title}</h1>
<div className="grid gap-5 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4">
{config.subjects.map((subject) => (
<SubjectOverviewCard
key={subject.projectId}
projectId={subject.projectId}
title={subject.title}
data={data}
/>
))}
<CalendarOverviewCard
data={data}
goal={config.goalHours}
penaliseOvertime={config.penaliseOvertime}
startTime={config.timePeriod.start}
endTime={config.timePeriod.end}
/>
<SubjectComparisonCard data={data}/>
</div>
</main>
)
}
+13
View File
@@ -0,0 +1,13 @@
"use server";
import OverviewPage from "@/components/OverviewPage";
import {getData} from "@/data/fetchData";
import {PeriodKey, periodMap} from "@/data/periods";
export default async function OverviewPageWrapper({period}: {
period: PeriodKey
}) {
const config = periodMap[period];
const data = await getData(config);
return <OverviewPage config={config} initialData={data}/>;
}
@@ -0,0 +1,51 @@
import {Card, Title} from "@tremor/react";
import * as R from 'ramda';
import * as dFns from 'date-fns';
import {Tooltip} from 'react-tooltip';
import {Data} from "@/data/fetchData";
import HeatMap from "@/components/HeatMap";
function useCalendarData(data: Data) {
const timeEntries = data.timeEntries;
// Group by day, sum up seconds
const grouped = R.groupBy((entry) => {
return dFns.formatISO(dFns.startOfDay(dFns.parseISO(entry.start)));
}, timeEntries);
const summed = R.mapObjIndexed((entries) => {
return R.sum((entries ?? []).map((entry) => entry.duration))
}, grouped);
return Object.entries(summed)
.map(([key, value]) => ({
date: dFns.parseISO(key),
count: value / (60 * 60),
}))
}
export function CalendarOverviewCard({
data,
goal,
penaliseOvertime,
startTime,
endTime,
}: {
data: Data,
goal: number,
penaliseOvertime?: boolean,
startTime: string,
endTime: string,
}) {
const initialDate = dFns.parseISO(startTime);
const endDate = dFns.parseISO(endTime);
const calendarData = useCalendarData(data);
return <Card className="col-span-1">
<Tooltip id="calendar-tooltip"/>
<Title>Semester Overview</Title>
<div className="m-2">
<HeatMap startDate={initialDate} endDate={endDate} data={calendarData} goal={goal} penaliseOvertime={penaliseOvertime}/>
</div>
</Card>
}
@@ -0,0 +1,41 @@
import {Card, Title} from "@tremor/react";
import * as R from "ramda";
import {Data} from "@/data/fetchData";
import {Donut, DonutChartData} from "@/components/Donut";
function useBreakdownData(data: Data): DonutChartData {
const sorted = R.sortBy(R.prop('projectId'), data.timeEntries);
const grouped = R.groupWith(R.eqBy(R.prop('projectId')), sorted);
return grouped
.map((entries) => {
const project = data.projects.find(p => p.projectId === entries[0].projectId)!;
const totalDuration = (entries).map((entry) => entry.duration).reduce((a, b) => a + b, 0);
return ({
label: project.name,
value: (totalDuration / (60 * 60)),
colour: project.color,
});
})
}
export function SubjectComparisonCard({
data,
}: {
data: Data,
}) {
const breakdownData = useBreakdownData(data);
return (
<Card className="col">
<Title>Relative Breakdown</Title>
<Donut
data={breakdownData ?? []}
formatter={v => v.toFixed(2)}
centerLabel={"hours"}
hoverSuffix={"h"}
/>
</Card>
)
}
@@ -0,0 +1,27 @@
import {Card, Metric, Text, Title} from "@tremor/react";
import {Data} from "@/data/fetchData";
export function SubjectOverviewCard({
title,
data,
projectId,
}: {
title?: string,
data: Data,
projectId: number,
}) {
const project = data.projects.find((project) => project.projectId === projectId)!;
const totalDuration = data.timeEntries
.filter((entry) => entry.projectId === projectId)
.map((entry) => entry.duration)
.reduce((a, b) => a + b, 0);
return (
<Card style={{borderColor: project?.color}} decoration={'left'}>
<Title>{title ?? project?.name}</Title>
<Text>Total</Text>
<Metric>{(totalDuration / (60 * 60)).toFixed(2)} hours</Metric>
</Card>
)
}
+19
View File
@@ -0,0 +1,19 @@
.chart-text {
font: 16px/1.4em "Montserrat", Arial, sans-serif;
fill: #000;
transform: translateY(0.25em);
}
.chart-number {
font-size: 0.6em;
line-height: 1;
text-anchor: middle;
transform: translateY(-0.25em);
}
.chart-label {
font-size: 0.2em;
text-transform: uppercase;
text-anchor: middle;
transform: translateY(0.7em);
}
+76
View File
@@ -0,0 +1,76 @@
import {DB as Database} from './db' // this is the Database interface we defined earlier
import {Pool} from 'pg'
import {Kysely, PostgresDialect} from 'kysely'
import * as fs from "node:fs";
import {z} from 'zod';
type Env = z.infer<typeof envSchema>;
const envSchema = z.object({
POSTGRES_DB: z.string(),
POSTGRES_HOST: z.string(),
POSTGRES_PORT: z.string().transform((val, ctx) => {
const parsed = parseInt(val);
if (isNaN(parsed)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Not a number",
});
return z.NEVER;
}
return parsed;
}),
POSTGRES_USER: z.string().optional(),
POSTGRES_USER_FILE: z.string().optional(),
POSTGRES_PASSWORD: z.string().optional(),
POSTGRES_PASSWORD_FILE: z.string().optional(),
}).refine(
env => env.POSTGRES_USER || env.POSTGRES_USER_FILE,
{
message: "Either POSTGRES_USER or POSTGRES_USER_FILE must be set",
path: ["POSTGRES_USER", "POSTGRES_USER_FILE"],
}
).refine(
env => env.POSTGRES_PASSWORD || env.POSTGRES_PASSWORD_FILE,
{
message: "Either POSTGRES_PASSWORD or POSTGRES_PASSWORD_FILE must be set",
path: ["POSTGRES_PASSWORD", "POSTGRES_PASSWORD_FILE"],
}
);
const buildTime = process.env.NEXT_PHASE === 'phase-production-build';
const env = buildTime ? 'build-time' : envSchema.parse(process.env);
function fileOrEnv(fileKey: keyof Env, valueKey: keyof Env): string | undefined {
if (env == 'build-time') {
return undefined;
}
const file: string = env[fileKey] as string;
if (file && fs.existsSync(file)) {
return fs.readFileSync(file, 'utf8');
}
return env[valueKey] as string;
}
function getCredentials() {
return {
user: fileOrEnv('POSTGRES_USER_FILE', 'POSTGRES_USER'),
password: fileOrEnv('POSTGRES_PASSWORD_FILE', 'POSTGRES_PASSWORD'),
}
}
export const db = env === 'build-time' ? null : new Kysely<Database>({
dialect: new PostgresDialect({
pool: new Pool({
database: env.POSTGRES_DB,
host: env.POSTGRES_HOST,
port: env.POSTGRES_PORT,
...getCredentials(),
max: 10,
})
}),
})
+69
View File
@@ -0,0 +1,69 @@
import type {ColumnType} from "kysely";
export type Generated<T> = T extends ColumnType<infer S, infer I, infer U>
? ColumnType<S, I | undefined, U>
: ColumnType<T, T | undefined, T>;
export type Int8 = ColumnType<string, bigint | number | string, bigint | number | string>;
export type Json = ColumnType<JsonValue, string, string>;
export type JsonArray = JsonValue[];
export type JsonObject = {
[K in string]?: JsonValue;
};
export type JsonPrimitive = boolean | number | string | null;
export type JsonValue = JsonArray | JsonObject | JsonPrimitive;
export type Timestamp = ColumnType<Date, Date | string, Date | string>;
export interface Client {
archived: boolean;
at: Timestamp;
id: number;
name: string;
server_deleted_at: Timestamp | null;
workspace_id: number;
}
export interface Project {
active: boolean;
client_id: number | null;
color: string;
id: Generated<number>;
name: string;
raw_json: Json;
server_created_at: Timestamp;
server_deleted_at: Timestamp | null;
server_updated_at: Timestamp;
toggl_id: Int8;
workspace_id: Int8;
}
export interface TimeEntry {
description: string;
id: Generated<number>;
project_id: Int8 | null;
raw_json: Json;
server_deleted_at: Timestamp | null;
server_updated_at: Timestamp;
start: Timestamp;
stop: Timestamp;
tags: Generated<Json>;
toggl_id: Int8;
}
export interface TogglPortalSeaqlMigrations {
applied_at: Int8;
version: string;
}
export interface DB {
client: Client;
project: Project;
time_entry: TimeEntry;
toggl_portal_seaql_migrations: TogglPortalSeaqlMigrations;
}
+80
View File
@@ -0,0 +1,80 @@
"use server";
import {db} from "@/data/database";
import * as dFns from "date-fns";
import {PeriodKey} from "@/data/periods";
export interface Data {
projects: {
projectId: number,
name: string,
color: string,
}[],
timeEntries: {
projectId: number,
start: string,
end: string,
duration: number,
// description: string,
}[],
}
export interface OverviewConfig {
title: string,
periodKey: PeriodKey,
subjects: {
title?: string,
projectId: number,
}[],
goalHours: number,
penaliseOvertime?: boolean,
timePeriod: {
start: string,
end: string
},
}
export async function getData(config: OverviewConfig): Promise<Data> {
// If we're in build-time, we don't have access to the database
if (!db) {
return {
projects: [],
timeEntries: [],
}
}
console.log('fetching data from SQL')
let projectIds = config.subjects.map((subject) => subject.projectId.toString());
const projects = await db.selectFrom('project')
.select(['toggl_id', 'name', 'color'])
.where('toggl_id', 'in', projectIds)
.execute()
const timeEntries = await db.selectFrom('time_entry')
.select(['project_id', 'start', 'stop'])
.where('project_id', 'in', projectIds)
.where('start', '>', dFns.parseISO(config.timePeriod.start))
.where('start', '<', dFns.parseISO(config.timePeriod.end))
.execute();
return {
projects: projects.map((project) => ({
projectId: parseInt(project.toggl_id),
name: project.name,
color: project.color,
})),
timeEntries: timeEntries.map((timeEntry) => ({
projectId: parseInt(timeEntry.project_id!),
start: dFns.formatISO(timeEntry.start),
end: dFns.formatISO(timeEntry.stop),
duration: dFns.differenceInSeconds(timeEntry.stop, timeEntry.start)
})),
}
}
+61
View File
@@ -0,0 +1,61 @@
import {OverviewConfig} from "@/data/fetchData";
export const semester1Revision: OverviewConfig = {
title: 'Semester 1 Revision',
periodKey: 'y5-s1-revision',
goalHours: 4,
subjects: [
{
projectId: 195482340,
},
{
title: 'Measure Theory',
projectId: 195519024,
},
{
title: 'Quantum Mechanics',
projectId: 195518593,
},
{
projectId: 195754611,
}
],
timePeriod: {
start: "2023-12-15T00:00:00.000Z",
end: "2024-01-25T00:00:00.000Z"
}
}
export const semester2: OverviewConfig = {
title: 'Semester 2',
periodKey: 'y5-s2',
goalHours: 6,
penaliseOvertime: true,
subjects: [
{
projectId: 195754611,
},
{
projectId: 199383703,
},
{
projectId: 199383691,
},
{
projectId: 198859760,
},
{
projectId: 199383698,
},
],
timePeriod: {
start: "2024-02-03T00:00:00.000Z",
end: "2024-05-10T00:00:00.000Z"
}
}
export type PeriodKey = keyof typeof periodMap;
export const periodMap = {
'y5-s1-revision': semester1Revision,
'y5-s2': semester2,
}