Compare commits

..

No commits in common. "e967244158dd3491e4db49029e868d44de24a510" and "68893d503db7676bb211c13376b63c02824cd95b" have entirely different histories.

17 changed files with 10127 additions and 6937 deletions

3
.gitignore vendored
View File

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

10040
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

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

6676
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

45
src/app/fetchData.ts Normal file
View File

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

View File

@ -1,18 +1,6 @@
import Link from "next/link"; import OverviewPage from "@/app/OverviewPage";
import {semester1Revision} from "@/app/overviewConfig";
export default function Home() { export default function Home() {
return <main className="m-6 text-slate-900 dark:text-white"> return <OverviewPage config={semester1Revision}/>
<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,8 +1,9 @@
import OverviewPage, {OverviewConfig} from "@/components/OverviewPage"; import OverviewPage from "@/app/OverviewPage";
import {OverviewConfig} from "@/app/overviewConfig";
const semester2: OverviewConfig = { const semester2: OverviewConfig = {
title: 'Semester 2', title: 'Semester 2',
goalHours: 7.5, goalHours: 4,
subjects: [ subjects: [
{ {
projectId: 195754611, projectId: 195754611,

4
src/app/utils.ts Normal file
View File

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

View File

@ -1,72 +0,0 @@
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
View File

@ -1,70 +0,0 @@
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;
}

View File

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

View File

@ -1,35 +0,0 @@
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,
})),
}
}