diff --git a/.gitignore b/.gitignore index 0109a4b..e6a6fd4 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,6 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +# Private env files +.env diff --git a/package.json b/package.json index ae87194..d3c8434 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "@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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2c8f1db..e862463 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -64,6 +64,9 @@ devDependencies: '@types/node': specifier: ^20 version: 20.0.0 + '@types/pg': + specifier: ^8.11.0 + version: 8.11.0 '@types/react': specifier: ^18 version: 18.0.0 @@ -1387,6 +1390,14 @@ packages: /@types/node@20.0.0: resolution: {integrity: sha512-cD2uPTDnQQCVpmRefonO98/PPijuOnnEy5oytWJFPY1N9aJCz2wJ5kSGWO+zJoed2cY2JxQh6yBuUq4vIn61hw==} + /@types/pg@8.11.0: + resolution: {integrity: sha512-sDAlRiBNthGjNFfvt0k6mtotoVYVQ63pA8R4EMWka7crawSR60waVYR0HAgmPRs/e2YaeJTD/43OoZ3PFw80pw==} + dependencies: + '@types/node': 20.0.0 + pg-protocol: 1.6.0 + pg-types: 4.0.2 + dev: true + /@types/prop-types@15.7.11: resolution: {integrity: sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==} @@ -4862,6 +4873,10 @@ packages: es-abstract: 1.22.3 dev: true + /obuf@1.1.2: + resolution: {integrity: sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==} + dev: true + /once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} dependencies: @@ -4978,6 +4993,11 @@ packages: resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} engines: {node: '>=4.0.0'} + /pg-numeric@1.0.2: + resolution: {integrity: sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw==} + engines: {node: '>=4'} + dev: true + /pg-pool@3.6.1(pg@8.11.3): resolution: {integrity: sha512-jizsIzhkIitxCGfPRzJn1ZdcosIt3pz9Sh3V01fm1vZnbnCMgmGl5wvGGdNN2EL9Rmb0EcFoCkixH4Pu+sP9Og==} peerDependencies: @@ -4998,6 +5018,19 @@ packages: postgres-date: 1.0.7 postgres-interval: 1.2.0 + /pg-types@4.0.2: + resolution: {integrity: sha512-cRL3JpS3lKMGsKaWndugWQoLOCoP+Cic8oseVcbr0qhPzYD5DWXK+RZ9LY9wxRf7RQia4SCwQlXk0q6FCPrVng==} + engines: {node: '>=10'} + dependencies: + pg-int8: 1.0.1 + pg-numeric: 1.0.2 + postgres-array: 3.0.2 + postgres-bytea: 3.0.0 + postgres-date: 2.1.0 + postgres-interval: 3.0.0 + postgres-range: 1.1.4 + dev: true + /pg@8.11.3: resolution: {integrity: sha512-+9iuvG8QfaaUrrph+kpF24cXkH1YOOUeArRNYIxq1viYHZagBxrTno7cecY1Fa44tJeZvaoG+Djpkc3JwehN5g==} engines: {node: '>= 8.0.0'} @@ -5132,20 +5165,46 @@ packages: resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} engines: {node: '>=4'} + /postgres-array@3.0.2: + resolution: {integrity: sha512-6faShkdFugNQCLwucjPcY5ARoW1SlbnrZjmGl0IrrqewpvxvhSLHimCVzqeuULCbG0fQv7Dtk1yDbG3xv7Veog==} + engines: {node: '>=12'} + dev: true + /postgres-bytea@1.0.0: resolution: {integrity: sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==} engines: {node: '>=0.10.0'} + /postgres-bytea@3.0.0: + resolution: {integrity: sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw==} + engines: {node: '>= 6'} + dependencies: + obuf: 1.1.2 + dev: true + /postgres-date@1.0.7: resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} engines: {node: '>=0.10.0'} + /postgres-date@2.1.0: + resolution: {integrity: sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA==} + engines: {node: '>=12'} + dev: true + /postgres-interval@1.2.0: resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} engines: {node: '>=0.10.0'} dependencies: xtend: 4.0.2 + /postgres-interval@3.0.0: + resolution: {integrity: sha512-BSNDnbyZCXSxgA+1f5UU2GmwhoI0aU5yMxRGO8CdFEcY2BQF9xm/7MqKnYoM1nJDk8nONNWDk9WeSmePFhQdlw==} + engines: {node: '>=12'} + dev: true + + /postgres-range@1.1.4: + resolution: {integrity: sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==} + dev: true + /prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} diff --git a/src/app/overviewConfig.ts b/src/app/overviewConfig.ts deleted file mode 100644 index 8b13789..0000000 --- a/src/app/overviewConfig.ts +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/app/sem1-revision/page.tsx b/src/app/sem1-revision/page.tsx index 3ca5a76..da35ccc 100644 --- a/src/app/sem1-revision/page.tsx +++ b/src/app/sem1-revision/page.tsx @@ -1,4 +1,4 @@ -import OverviewPage, {OverviewConfig} from "@/app/OverviewPage"; +import OverviewPage, {OverviewConfig} from "@/components/OverviewPage"; const semester1Revision: OverviewConfig = { title: 'Semester 1 Revision', diff --git a/src/app/sem2/page.tsx b/src/app/sem2/page.tsx index 2e1bae9..18e3bdf 100644 --- a/src/app/sem2/page.tsx +++ b/src/app/sem2/page.tsx @@ -1,4 +1,4 @@ -import OverviewPage, {OverviewConfig} from "@/app/OverviewPage"; +import OverviewPage, {OverviewConfig} from "@/components/OverviewPage"; const semester2: OverviewConfig = { title: 'Semester 2', diff --git a/src/app/utils.ts b/src/app/utils.ts deleted file mode 100644 index 0f2e323..0000000 --- a/src/app/utils.ts +++ /dev/null @@ -1,4 +0,0 @@ -export const fetcher = ( - input: string | URL | globalThis.Request, - init?: RequestInit, -) => fetch(input, init).then(res => res.json()) diff --git a/src/app/OverviewPage.tsx b/src/components/OverviewPage.tsx similarity index 76% rename from src/app/OverviewPage.tsx rename to src/components/OverviewPage.tsx index 8d98b3e..e81e86a 100644 --- a/src/app/OverviewPage.tsx +++ b/src/components/OverviewPage.tsx @@ -1,7 +1,8 @@ -import {SubjectComparisonCard} from "@/app/cards/subjectComparisonCard"; -import {CalendarOverviewCard} from "@/app/cards/calendarOverviewCard"; -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 {getData} from "@/data/fetchData"; +import {getDataSQL} from "@/data/fetchWithSQL"; export interface OverviewConfig { title: string, @@ -22,7 +23,7 @@ export interface OverviewConfig { export default async function OverviewPage({config}: { config: OverviewConfig }) { - const data = await getData(config); + const data = await getDataSQL(config); return (
diff --git a/src/app/cards/calendarOverviewCard.tsx b/src/components/cards/calendarOverviewCard.tsx similarity index 97% rename from src/app/cards/calendarOverviewCard.tsx rename to src/components/cards/calendarOverviewCard.tsx index 8ba32de..b5692c2 100644 --- a/src/app/cards/calendarOverviewCard.tsx +++ b/src/components/cards/calendarOverviewCard.tsx @@ -4,9 +4,9 @@ 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 granularity = 4; diff --git a/src/app/cards/subjectComparisonCard.tsx b/src/components/cards/subjectComparisonCard.tsx similarity index 97% rename from src/app/cards/subjectComparisonCard.tsx rename to src/components/cards/subjectComparisonCard.tsx index bbdefdb..6ea5a92 100644 --- a/src/app/cards/subjectComparisonCard.tsx +++ b/src/components/cards/subjectComparisonCard.tsx @@ -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, diff --git a/src/app/cards/subjectOverviewCard.tsx b/src/components/cards/subjectOverviewCard.tsx similarity index 95% rename from src/app/cards/subjectOverviewCard.tsx rename to src/components/cards/subjectOverviewCard.tsx index fed12a3..0666558 100644 --- a/src/app/cards/subjectOverviewCard.tsx +++ b/src/components/cards/subjectOverviewCard.tsx @@ -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, diff --git a/src/data/database.ts b/src/data/database.ts new file mode 100644 index 0000000..d85a827 --- /dev/null +++ b/src/data/database.ts @@ -0,0 +1,35 @@ +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"; + +function fileOrEnv(fileKey: string, valueKey: string): string | undefined { + const file = process.env[fileKey]; + + if (file && fs.existsSync(file)) { + return fs.readFileSync(file, 'utf8'); + } + + return process.env[valueKey]; +} + +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: process.env.POSTGRES_DB!, + host: process.env.POSTGRES_HOST!, + port: parseInt(process.env.POSTGRES_PORT!), + ...getCredentials(), + max: 10, + }) +}) + +export const db = new Kysely({ + dialect, +}) diff --git a/src/data/db.d.ts b/src/data/db.d.ts new file mode 100644 index 0000000..96df047 --- /dev/null +++ b/src/data/db.d.ts @@ -0,0 +1,70 @@ +import type {ColumnType, JSONColumnType} from "kysely"; + +export type Generated = T extends ColumnType + ? ColumnType + : ColumnType; + +export type Int8 = ColumnType; + +export type Json = ColumnType; + +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; + +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; + name: string; + raw_json: JSONColumnType<{ + color: string; + id: number; + name: string; + }>; + toggl_id: Int8; + workspace_id: Int8; +} + +export interface TimeEntry { + description: string; + id: Generated; + 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; +} diff --git a/src/app/fetchData.ts b/src/data/fetchData.ts similarity index 75% rename from src/app/fetchData.ts rename to src/data/fetchData.ts index ef11160..0765cb5 100644 --- a/src/app/fetchData.ts +++ b/src/data/fetchData.ts @@ -1,7 +1,5 @@ "use server"; - - -import {OverviewConfig} from "@/app/OverviewPage"; +import {OverviewConfig} from "@/components/OverviewPage"; export interface Data { projects: { @@ -19,9 +17,9 @@ export interface Data { }[], } -export async function getData(config: OverviewConfig) { +export async function getData(config: OverviewConfig): Promise { 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 projectResponse = await fetch(`https://cosmos.tail307fc.ts.net/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, @@ -29,7 +27,7 @@ export async function getData(config: OverviewConfig) { 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 timeEntriesResponse = await fetch(`https://cosmos.tail307fc.ts.net/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, diff --git a/src/data/fetchWithSQL.ts b/src/data/fetchWithSQL.ts new file mode 100644 index 0000000..d61ee03 --- /dev/null +++ b/src/data/fetchWithSQL.ts @@ -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 { + 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, + })), + } +}