Compare commits
39
Commits
eb87f44b76
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
026d0cfd56 | ||
|
|
1a14485c57 | ||
|
|
50730a2b09 | ||
|
|
9fc14cffd0 | ||
|
|
e534506894 | ||
|
|
89558cdd1a | ||
|
|
d6b319e6a5 | ||
|
|
f350018eb2 | ||
|
|
65e8899426 | ||
|
|
ec91f6df42 | ||
|
|
f2dd382cf3 | ||
|
|
058466294b | ||
|
|
cf1788ebcd | ||
|
|
e67e01fd0a | ||
|
|
17edc05ec4 | ||
|
|
9752ede109 | ||
|
|
3d0c09bba9 | ||
|
|
9ba7c34902 | ||
|
|
31cb328caf | ||
|
|
e967244158 | ||
|
|
28b01769e5 | ||
|
|
363c2fc678 | ||
|
|
a47eadd68f | ||
|
|
68893d503d | ||
|
|
d8ad2f80b8 | ||
|
|
b82c8fe979 | ||
|
|
0314ccc2b3 | ||
|
|
44df8a6fc6 | ||
|
|
0a89712965 | ||
|
|
c7fcb0990f | ||
|
|
0ce2096fac | ||
|
|
52b6b0eaee | ||
|
|
a61677fe35 | ||
|
|
c85776a499 | ||
|
|
fb9547991c | ||
|
|
44184095b8 | ||
|
|
766c2eba3f | ||
|
|
17257ac46a | ||
|
|
69b5476b4a |
@@ -25,9 +25,35 @@ jobs:
|
|||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
|
||||||
- name: Build and Push Docker image -- finn-board
|
- name: Build and Push Docker image
|
||||||
uses: docker/build-push-action@675965c0e16f1a0f94ecafff969d8c966f92c17b
|
uses: docker/build-push-action@675965c0e16f1a0f94ecafff969d8c966f92c17b
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: true
|
push: true
|
||||||
tags: git.joshuacoles.me/joshuacoles/revision-ui:latest
|
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
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# IDEA
|
||||||
|
/.idea
|
||||||
|
|
||||||
# dependencies
|
# dependencies
|
||||||
/node_modules
|
/node_modules
|
||||||
/.pnp
|
/.pnp
|
||||||
@@ -34,3 +37,9 @@ yarn-error.log*
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
|
# Private env files
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Caddyfile for local development when testing weird deploys
|
||||||
|
/Caddyfile
|
||||||
|
|||||||
Generated
-5
@@ -1,5 +0,0 @@
|
|||||||
# Default ignored files
|
|
||||||
/shelf/
|
|
||||||
/workspace.xml
|
|
||||||
# Editor-based HTTP Client requests
|
|
||||||
/httpRequests/
|
|
||||||
-6
@@ -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>
|
|
||||||
Generated
-8
@@ -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>
|
|
||||||
Generated
-12
@@ -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
@@ -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>
|
|
||||||
@@ -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)
|
||||||
@@ -1,12 +1,6 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
output: 'standalone',
|
output: 'standalone',
|
||||||
rewrites: async () => [
|
|
||||||
{
|
|
||||||
source: '/api/:path*',
|
|
||||||
destination: 'https://revision.joshuacoles.me/api/:path*',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = nextConfig
|
module.exports = nextConfig
|
||||||
|
|||||||
Generated
-6319
File diff suppressed because it is too large
Load Diff
+32
-19
@@ -1,37 +1,50 @@
|
|||||||
{
|
{
|
||||||
"name": "tremor-dashboard",
|
"name": "revision-tracker",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint"
|
"lint": "next lint",
|
||||||
|
"test": "jest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@heroicons/react": "^1.0.6",
|
"@heroicons/react": "^1.0.6",
|
||||||
"@tremor/react": "^3.12.0",
|
"@tremor/react": "^3.13.4",
|
||||||
"@types/ramda": "^0.29.9",
|
"@types/ramda": "^0.29.10",
|
||||||
"date-fns": "^2.30.0",
|
"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",
|
"ramda": "^0.29.1",
|
||||||
"react": "^18",
|
"react": "^18.2.0",
|
||||||
"react-calendar-heatmap": "^1.9.0",
|
"react-calendar-heatmap": "^1.9.0",
|
||||||
"react-dom": "^18",
|
"react-date-heatmap": "^1.0.5",
|
||||||
"react-tooltip": "^5.25.0",
|
"react-dom": "^18.2.0",
|
||||||
"reaviz": "^15.2.1",
|
"react-tooltip": "^5.26.1",
|
||||||
"swr": "^2.2.4"
|
"reaviz": "^15.7.0",
|
||||||
|
"swr": "^2.2.4",
|
||||||
|
"zod": "^3.22.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20",
|
"@testing-library/jest-dom": "^6.4.2",
|
||||||
"@types/react": "^18",
|
"@testing-library/react": "^14.2.1",
|
||||||
"@types/react-calendar-heatmap": "^1.6.6",
|
"@types/node": "^20.11.17",
|
||||||
"@types/react-dom": "^18",
|
"@types/pg": "^8.11.0",
|
||||||
"autoprefixer": "^10.0.1",
|
"@types/react": "^18.2.55",
|
||||||
"eslint": "^8",
|
"@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",
|
"eslint-config-next": "14.0.4",
|
||||||
"postcss": "^8",
|
"jest": "^29.7.0",
|
||||||
"tailwindcss": "^3.3.0",
|
"jest-environment-jsdom": "^29.7.0",
|
||||||
"typescript": "^5"
|
"kysely-codegen": "^0.11.0",
|
||||||
|
"postcss": "^8.4.35",
|
||||||
|
"tailwindcss": "^3.4.1",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"typescript": "^5.3.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+6427
File diff suppressed because it is too large
Load Diff
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -1,91 +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
@@ -5,8 +5,8 @@ import './globals.css'
|
|||||||
const inter = Inter({ subsets: ['latin'] })
|
const inter = Inter({ subsets: ['latin'] })
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Create Next App',
|
title: 'Work Tracker',
|
||||||
description: 'Generated by create next app',
|
description: 'Track time spent on different subjects',
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
|
|||||||
+13
-28
@@ -1,33 +1,18 @@
|
|||||||
import {SubjectComparisonCard, SubjectOverviewCard} from "@/app/a.client";
|
import Link from "next/link";
|
||||||
import {CalendarOverviewCard} from "@/app/calendarOverviewCard";
|
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
return <main className="m-6 text-slate-900 dark:text-white">
|
||||||
<main className="m-6">
|
<h1 className="text-3xl font-semibold my-2">Work Tracker</h1>
|
||||||
<h1 className="text-3xl font-semibold text-slate-900 dark:text-white my-2">Revision Tracker</h1>
|
|
||||||
|
|
||||||
<div className="grid gap-5 grid-cols-1 sm:grid-cols-4">
|
<ol>
|
||||||
<SubjectOverviewCard
|
<li>
|
||||||
projectId={195482340}
|
<Link href="/sem1-revision" className={"text-blue-500 underline underline-offset-2"}>
|
||||||
/>
|
Semester 1 Revision
|
||||||
|
</Link>
|
||||||
<SubjectOverviewCard
|
</li>
|
||||||
title='Measure Theory'
|
<li>
|
||||||
projectId={195519024}
|
<Link href="/sem2" className={"text-blue-500 underline underline-offset-2"}>Semester 2</Link>
|
||||||
/>
|
</li>
|
||||||
|
</ol>
|
||||||
<SubjectOverviewCard
|
|
||||||
title='Quantum Mechanics'
|
|
||||||
projectId={195518593}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SubjectOverviewCard
|
|
||||||
projectId={195754611}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<CalendarOverviewCard/>
|
|
||||||
</div>
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import OverviewPageWrapper from "@/components/PageWrapper";
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return <OverviewPageWrapper period={'y5-s1-revision'}/>
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import OverviewPageWrapper from "@/components/PageWrapper";
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return <OverviewPageWrapper period={'y5-s2'}/>
|
||||||
|
}
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
export const fetcher = (
|
|
||||||
input: string | URL | globalThis.Request,
|
|
||||||
init?: RequestInit,
|
|
||||||
) => fetch(input, init).then(res => res.json())
|
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {Tooltip} from "react-tooltip";
|
||||||
|
|
||||||
|
export default function DonutTooltip() {
|
||||||
|
return <Tooltip id="donut-tooltip"/>
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
})
|
||||||
Vendored
+69
@@ -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;
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user