Compare commits

...

4 Commits

Author SHA1 Message Date
e967244158 Parse postgres connection information out of the environment
Some checks failed
Build and Publish Docker Container / build (push) Failing after 3m12s
2024-02-11 18:59:32 +00:00
28b01769e5 Rearrange everything and migrate to talking directly to the database 2024-02-11 18:39:39 +00:00
363c2fc678 Swap to pnpm 2024-02-11 18:08:16 +00:00
a47eadd68f Cleanup structure 2024-02-11 17:49:29 +00:00
17 changed files with 6937 additions and 10127 deletions

3
.gitignore vendored
View File

@ -37,3 +37,6 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
# Private env files
.env

10040
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -15,7 +15,9 @@
"@types/ramda": "^0.29.9",
"date-fns": "^2.30.0",
"heat-calendar": "^1.0.7",
"kysely": "^0.27.2",
"next": "^14.1.0",
"pg": "^8.11.3",
"ramda": "^0.29.1",
"react": "^18",
"react-calendar-heatmap": "^1.9.0",
@ -23,12 +25,13 @@
"react-dom": "^18",
"react-tooltip": "^5.25.0",
"reaviz": "^15.2.1",
"swr": "^2.2.4"
"zod": "^3.22.4"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.1.5",
"@testing-library/react": "^14.1.2",
"@types/node": "^20",
"@types/pg": "^8.11.0",
"@types/react": "^18",
"@types/react-calendar-heatmap": "^1.6.6",
"@types/react-dom": "^18",
@ -37,7 +40,8 @@
"eslint-config-next": "14.0.4",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"postcss": "^8",
"kysely-codegen": "^0.11.0",
"postcss": "^8.4.35",
"tailwindcss": "^3.3.0",
"ts-node": "^10.9.2",
"typescript": "^5"

6676
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,45 +0,0 @@
"use server";
import {OverviewConfig} from "@/app/overviewConfig";
export interface Data {
projects: {
projectId: number,
name: string,
color: string,
}[],
timeEntries: {
projectId: number,
start: string,
end: string,
duration: number,
// description: string,
}[],
}
export async function getData(config: OverviewConfig) {
const projectIds = config.subjects.map((subject) => subject.projectId);
const projectResponse = await fetch(`https://revision.joshuacoles.me/api/project?select=raw_json&toggl_id=in.(${projectIds.join(',')})`);
const projects = await projectResponse.json();
const projectLensed = projects.map((project: any) => ({
projectId: project.raw_json.id,
name: project.raw_json.name,
color: project.raw_json.color,
}));
const timeEntriesResponse = await fetch(`https://revision.joshuacoles.me/api/time_entry?select=project_id,raw_json&project_id=in.(${projectIds.join(',')})&start=gt.${config.timePeriod.start}&start=lt.${config.timePeriod.end}`);
const timeEntries = await timeEntriesResponse.json();
const timeEntriesLensed = timeEntries.map((timeEntry: any) => ({
projectId: timeEntry.project_id,
start: timeEntry.raw_json.start,
end: timeEntry.raw_json.end,
duration: timeEntry.raw_json.seconds,
// description: timeEntry.raw_json.description,
}));
return {
projects: projectLensed,
timeEntries: timeEntriesLensed,
};
}

View File

@ -1,6 +1,18 @@
import OverviewPage from "@/app/OverviewPage";
import {semester1Revision} from "@/app/overviewConfig";
import Link from "next/link";
export default function Home() {
return <OverviewPage config={semester1Revision}/>
return <main className="m-6 text-slate-900 dark:text-white">
<h1 className="text-3xl font-semibold my-2">Work Tracker</h1>
<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>
}

View File

@ -1,20 +1,6 @@
export interface OverviewConfig {
title: string,
import OverviewPage, {OverviewConfig} from "@/components/OverviewPage";
subjects: {
title?: string,
projectId: number,
}[],
goalHours: number,
timePeriod: {
start: string,
end: string
},
}
export const semester1Revision: OverviewConfig = {
const semester1Revision: OverviewConfig = {
title: 'Semester 1 Revision',
goalHours: 4,
subjects: [
@ -38,3 +24,7 @@ export const semester1Revision: OverviewConfig = {
end: "2024-01-25T00:00:00.000Z"
}
}
export default function Home() {
return <OverviewPage config={semester1Revision}/>
}

View File

@ -1,9 +1,8 @@
import OverviewPage from "@/app/OverviewPage";
import {OverviewConfig} from "@/app/overviewConfig";
import OverviewPage, {OverviewConfig} from "@/components/OverviewPage";
const semester2: OverviewConfig = {
title: 'Semester 2',
goalHours: 4,
goalHours: 7.5,
subjects: [
{
projectId: 195754611,

View File

@ -1,4 +0,0 @@
export const fetcher = (
input: string | URL | globalThis.Request,
init?: RequestInit,
) => fetch(input, init).then(res => res.json())

View File

@ -1,13 +1,28 @@
import {SubjectComparisonCard} from "@/app/cards/subjectComparisonCard";
import {CalendarOverviewCard} from "@/app/cards/calendarOverviewCard";
import {OverviewConfig} from "@/app/overviewConfig";
import {SubjectOverviewCard} from "@/app/cards/subjectOverviewCard";
import {getData} from "@/app/fetchData";
import {SubjectComparisonCard} from "@/components/cards/subjectComparisonCard";
import {CalendarOverviewCard} from "@/components/cards/calendarOverviewCard";
import {SubjectOverviewCard} from "@/components/cards/subjectOverviewCard";
import {getDataSQL} from "@/data/fetchWithSQL";
export interface OverviewConfig {
title: string,
subjects: {
title?: string,
projectId: number,
}[],
goalHours: number,
timePeriod: {
start: string,
end: string
},
}
export default async function OverviewPage({config}: {
config: OverviewConfig
}) {
const data = await getData(config);
const data = await getDataSQL(config);
return (
<main className="m-6">
@ -25,6 +40,7 @@ export default async function OverviewPage({config}: {
<CalendarOverviewCard
data={data}
goal={config.goalHours}
startTime={config.timePeriod.start}
endTime={config.timePeriod.end}
/>

View File

@ -4,16 +4,19 @@ 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 '../../app/calendar-styles.css'
import {Tooltip} from 'react-tooltip';
import {Data} from "@/app/fetchData";
import {Data} from "@/data/fetchData";
const dailyGoal = 4;
const granularity = 4;
function computeCompletionShade(value: number) {
function computeCompletionShade(value: number, dailyGoal: number) {
const linearValue = Math.round((value / dailyGoal) * granularity);
// If we did something, but not enough to reach the first level, return 1
if (linearValue == 0 && value > 0) return 1;
// Clamp to the granularity
if (linearValue > granularity) return granularity;
return linearValue;
}
@ -50,10 +53,12 @@ function useCalendarData(data: Data, initialDate: Date, endDate: Date) {
export function CalendarOverviewCard({
data,
goal,
startTime,
endTime
endTime,
}: {
data: Data,
goal: number,
startTime: string,
endTime: string,
}) {
@ -69,7 +74,7 @@ export function CalendarOverviewCard({
startDate={initialDate}
endDate={endDate}
values={calendarData}
classForValue={value => `color-github-${computeCompletionShade(value?.count ?? 0)}`}
classForValue={value => `color-github-${computeCompletionShade(value?.count ?? 0, goal)}`}
tooltipDataAttrs={(value: any) => {
return value.date ? {
'data-tooltip-id': `calendar-tooltip`,

View File

@ -2,7 +2,7 @@
import {Card, DonutChart, Title} from "@tremor/react";
import * as R from "ramda";
import {Data} from "@/app/fetchData";
import {Data} from "@/data/fetchData";
function useBreakdownData(data: Data): {
name: string,

View File

@ -1,5 +1,5 @@
import {Card, Metric, Text, Title} from "@tremor/react";
import {Data} from "@/app/fetchData";
import {Data} from "@/data/fetchData";
export function SubjectOverviewCard({
title,

72
src/data/database.ts Normal file
View File

@ -0,0 +1,72 @@
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';
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 env = envSchema.parse(process.env);
function fileOrEnv(fileKey: keyof typeof env, valueKey: keyof typeof env): string | 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'),
}
}
const dialect = new PostgresDialect({
pool: new Pool({
database: env.POSTGRES_DB,
host: env.POSTGRES_HOST,
port: env.POSTGRES_PORT,
...getCredentials(),
max: 10,
})
})
export const db = new Kysely<Database>({
dialect,
})

70
src/data/db.d.ts vendored Normal file
View File

@ -0,0 +1,70 @@
import type {ColumnType, JSONColumnType} 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;
id: Generated<number>;
name: string;
raw_json: JSONColumnType<{
color: string;
id: number;
name: string;
}>;
toggl_id: Int8;
workspace_id: Int8;
}
export interface TimeEntry {
description: string;
id: Generated<number>;
project_id: Int8 | null;
raw_json: JSONColumnType<{
start: string;
end: string;
seconds: number;
}>;
start: Timestamp;
stop: Timestamp;
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;
}

17
src/data/fetchData.ts Normal file
View File

@ -0,0 +1,17 @@
"use server";
export interface Data {
projects: {
projectId: number,
name: string,
color: string,
}[],
timeEntries: {
projectId: number,
start: string,
end: string,
duration: number,
// description: string,
}[],
}

35
src/data/fetchWithSQL.ts Normal file
View File

@ -0,0 +1,35 @@
import {OverviewConfig} from "@/components/OverviewPage";
import {Data} from "@/data/fetchData";
import {db} from "@/data/database";
import * as dFns from "date-fns";
export async function getDataSQL(config: OverviewConfig): Promise<Data> {
let projectIds = config.subjects.map((subject) => subject.projectId.toString());
const projects = await db.selectFrom('project')
.select('raw_json')
.where('project.toggl_id', 'in', projectIds)
.execute();
const timeEntries = await db.selectFrom('time_entry')
.select(['project_id', 'raw_json'])
.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: project.raw_json.id,
name: project.raw_json.name,
color: project.raw_json.color,
})),
timeEntries: timeEntries.map((timeEntry) => ({
projectId: parseInt(timeEntry.project_id!),
start: timeEntry.raw_json.start,
end: timeEntry.raw_json.end,
duration: timeEntry.raw_json.seconds,
})),
}
}