├── packages ├── app │ ├── src │ │ ├── main.ts │ │ ├── extensions │ │ │ ├── SqlSandbox │ │ │ │ ├── index.tsx │ │ │ │ ├── sandbox.api.ts │ │ │ │ └── SqlSandbox.tsx │ │ │ └── HourlyCategoryReport │ │ │ │ ├── index.tsx │ │ │ │ └── hourlyCategoryReport.api.ts │ │ ├── assets │ │ │ ├── agi_2048.png │ │ │ ├── icons │ │ │ │ ├── boss_angry.png │ │ │ │ ├── boss_happy.png │ │ │ │ ├── boss_neutral.png │ │ │ │ ├── bar_chart.svg │ │ │ │ ├── notification.svg │ │ │ │ └── block.svg │ │ │ └── source-logos │ │ │ │ ├── canva.webp │ │ │ │ ├── claude.png │ │ │ │ ├── figma.png │ │ │ │ ├── github.png │ │ │ │ ├── gmail.png │ │ │ │ ├── jira.png │ │ │ │ ├── slack.webp │ │ │ │ ├── vscode.png │ │ │ │ ├── chatgpt.png │ │ │ │ ├── chrome.webp │ │ │ │ ├── firefox.webp │ │ │ │ ├── linkedIn.png │ │ │ │ ├── outlook.png │ │ │ │ ├── youtube.png │ │ │ │ ├── stackoverflow.png │ │ │ │ └── linear.svg │ │ ├── public │ │ │ └── images │ │ │ │ ├── logo-min.png │ │ │ │ └── logo-min-white.png │ │ ├── vite-env.d.ts │ │ ├── api │ │ │ ├── localServer │ │ │ │ ├── keys.ts │ │ │ │ └── report.localapi.ts │ │ │ ├── platformServer │ │ │ │ ├── keys.ts │ │ │ │ ├── errorReport.platformApi.ts │ │ │ │ ├── weeklyReport.platformApi.ts │ │ │ │ └── gameSettings.platformApi.ts │ │ │ ├── browser │ │ │ │ ├── services │ │ │ │ │ ├── query.service.ts │ │ │ │ │ └── report.service.ts │ │ │ │ ├── repos │ │ │ │ │ ├── user.repo.ts │ │ │ │ │ ├── queries │ │ │ │ │ │ └── deepWork.query.ts │ │ │ │ │ └── pulse.repo.ts │ │ │ │ ├── keys.ts │ │ │ │ ├── pulse.api.ts │ │ │ │ └── user.api.ts │ │ │ ├── index.ts │ │ │ └── utils │ │ │ │ └── db.util.ts │ │ ├── components │ │ │ ├── Home │ │ │ │ ├── Time │ │ │ │ │ ├── utils.ts │ │ │ │ │ └── TimeDataPoint.tsx │ │ │ │ ├── Source │ │ │ │ │ ├── Sources.error.tsx │ │ │ │ │ ├── SourceTimeChart.tsx │ │ │ │ │ ├── SiteRow.tsx │ │ │ │ │ └── Sources.loading.tsx │ │ │ │ ├── DeepWork.tsx │ │ │ │ └── HomePage.tsx │ │ │ ├── GameMakers │ │ │ │ ├── GameMakersPage.tsx │ │ │ │ └── AIWeeklyReportList.tsx │ │ │ ├── common │ │ │ │ ├── Logo │ │ │ │ │ └── Logo.tsx │ │ │ │ ├── Icons │ │ │ │ │ ├── BarChartIcon.tsx │ │ │ │ │ ├── RingIcon.tsx │ │ │ │ │ ├── DoubleRingIcon.tsx │ │ │ │ │ ├── BossImage.tsx │ │ │ │ │ ├── NotificationIcon.tsx │ │ │ │ │ ├── DiscordIcon.tsx │ │ │ │ │ └── BlockIcon.tsx │ │ │ │ ├── CodeClimbersButton.tsx │ │ │ │ ├── HighlightLabel.tsx │ │ │ │ ├── CodeClimbersLink.tsx │ │ │ │ ├── CodeClimbersIconButton.tsx │ │ │ │ ├── CodeClimbersLoadingButton.tsx │ │ │ │ ├── LocalApiKeyErrorBanner.tsx │ │ │ │ ├── PlainHeader.tsx │ │ │ │ ├── GithubProfileImage.tsx │ │ │ │ └── UpdateBanner │ │ │ │ │ └── UpdateBanner.tsx │ │ │ ├── WeeklyReports │ │ │ │ ├── ScoreHeader.tsx │ │ │ │ ├── EmptyState.tsx │ │ │ │ ├── ActiveHoursScore.tsx │ │ │ │ ├── GrowthScore.tsx │ │ │ │ ├── DeepWorkScore.tsx │ │ │ │ ├── ProjectScore.tsx │ │ │ │ └── WeeklyLineGraph.tsx │ │ │ ├── LoadingScreen.tsx │ │ │ ├── Extensions │ │ │ │ ├── ExtensionsPage.tsx │ │ │ │ └── AuthorInfo.tsx │ │ │ ├── PerformanceReviewFax.tsx │ │ │ └── ContributorsPage.tsx │ │ ├── layouts │ │ │ ├── InstallLayout.tsx │ │ │ ├── ImportLayout.tsx │ │ │ ├── DashboardLayout.tsx │ │ │ └── ExtensionsLayout.tsx │ │ ├── index.css │ │ ├── utils │ │ │ ├── posthog.util.ts │ │ │ ├── flag.util.ts │ │ │ ├── csv.util.ts │ │ │ ├── auth.util.ts │ │ │ ├── time.ts │ │ │ ├── categories.ts │ │ │ ├── environment.util.ts │ │ │ ├── style │ │ │ │ └── rgbAnimation.ts │ │ │ └── db.util.ts │ │ ├── routes │ │ │ ├── index.tsx │ │ │ └── AppRoutes.tsx │ │ ├── hooks │ │ │ ├── useSetFeaturePreference.ts │ │ │ ├── useBrowserPreferences.ts │ │ │ ├── useUpdateHook.ts │ │ │ ├── useSelectedDate.ts │ │ │ └── useVersionConsole.ts │ │ ├── providers │ │ │ └── localStorageAuthProvider.tsx │ │ ├── repos │ │ │ ├── pulse.repo.ts │ │ │ ├── user.repo.ts │ │ │ └── queries │ │ │ │ └── deepWork.query.ts │ │ ├── index.html │ │ ├── services │ │ │ ├── index.ts │ │ │ ├── feature.service.ts │ │ │ ├── keys.ts │ │ │ ├── localAuth.service.ts │ │ │ ├── health.service.ts │ │ │ └── version.service.ts │ │ └── App.tsx │ ├── tsconfig.json │ ├── vite.config.js │ └── package.json └── server │ ├── utils │ ├── constants.ts │ ├── sql.util.ts │ ├── packageJson.util.ts │ ├── __tests__ │ │ ├── activites.util.test.ts │ │ └── localAuth.util.test.ts │ ├── codeClimberErrors.ts │ └── sqlReader.util.ts │ ├── tsconfig.build.json │ ├── src │ ├── v1 │ │ ├── dtos │ │ │ ├── getWeekOverview.dto.ts │ │ │ ├── getCategoryTimeOverview.dto.ts │ │ │ ├── createWakatimePulse.dto.ts │ │ │ └── pagination.dto.ts │ │ ├── database │ │ │ ├── queries │ │ │ │ ├── getCategoryTimeOverview.sql │ │ │ │ ├── getLongestDayInRangeMinutes.sql │ │ │ │ ├── getStatusBarDetails.sql │ │ │ │ └── getDeepWork.sql │ │ │ ├── migrations.ts │ │ │ ├── __tests__ │ │ │ │ └── knex.test.ts │ │ │ ├── models │ │ │ │ ├── user.d.ts │ │ │ │ └── user_setting.d.ts │ │ │ └── user.repo.ts │ │ ├── localdb │ │ │ ├── localDb.repo.ts │ │ │ ├── localAuth.service.ts │ │ │ ├── localDb.controller.ts │ │ │ ├── localAuth.controller.ts │ │ │ └── localAuth.guard.ts │ │ ├── users │ │ │ └── user.service.ts │ │ ├── startup │ │ │ ├── startup.util.ts │ │ │ ├── startup.controller.ts │ │ │ ├── unsupportedStartup.service.ts │ │ │ └── startupService.factory.ts │ │ ├── activities │ │ │ ├── report.controller.ts │ │ │ └── wakatimeProxy.controller.ts │ │ └── v1.module.ts │ ├── types │ │ ├── utils.d.ts │ │ ├── local.api.d.ts │ │ ├── report.api.d.ts │ │ ├── activities.api.d.ts │ │ ├── wakatimeProxy.api.d.ts │ │ └── time.api.d.ts │ ├── common │ │ ├── infrastructure │ │ │ └── http │ │ │ │ ├── controllers │ │ │ │ └── health.controller.ts │ │ │ │ └── middleware │ │ │ │ └── requestlogger.middleware.ts │ │ └── scheduler.module.ts │ ├── filters │ │ ├── codeClimbersException.filter.ts │ │ └── allExceptions.filter.ts │ ├── assets │ │ └── startup.plist.ts │ ├── app.module.ts │ └── main.ts │ ├── test │ ├── jest-e2e.json │ ├── jest.globalTeardown.js │ ├── jest.globalSetup.js │ └── app.e2e-spec.ts │ ├── jest.config.js │ ├── knexfile.js │ ├── nest-cli.json │ ├── commands │ ├── config │ │ └── apikey.ts │ ├── startup │ │ ├── disable.ts │ │ └── enable.ts │ ├── log │ │ ├── out.ts │ │ └── error.ts │ └── stop │ │ └── index.ts │ ├── tsconfig.json │ └── .gitignore ├── bin ├── dev.cmd ├── run.cmd ├── migrations │ ├── example │ │ └── stub.js │ ├── 20240822235249_turn_on_wal_mode.js │ ├── 20241004000814_create_user.js │ ├── 20240620003646_add_pulses.js │ └── 20241003221416_add_user.js ├── run.js ├── dev.js └── startup.js ├── .firebaserc ├── docs ├── information_diagram.png ├── Troubleshooting.md └── Architecture.md ├── .editorconfig ├── eslint-plugin-codeclimbers ├── package.json └── index.js ├── jest.test.js ├── .github ├── prace.yml ├── workflows │ ├── prace.yml │ ├── eslint.yml │ ├── greetings.yml │ ├── firebase-hosting-merge-main.yml │ ├── firebase-hosting-merge-release.yml │ ├── firebase-hosting-pull-request.yml │ ├── notify_release.yml │ ├── stale.yml │ ├── test.yml │ ├── bump_minor_version.yml │ ├── codeql.yml │ ├── bump_patch_version.yml │ └── pull-request-size.yml └── pull_request_template.md ├── knexfile.js ├── vite.config.js ├── firebase.json ├── .gitignore ├── LICENSE ├── Readme.md ├── .eslintrc.js └── scripts ├── mock_install.sh └── checkDependencies.js /packages/app/src/main.ts: -------------------------------------------------------------------------------- 1 | import './App' 2 | -------------------------------------------------------------------------------- /bin/dev.cmd: -------------------------------------------------------------------------------- 1 | @echo off 2 | 3 | node "%~dp0\dev" %* 4 | -------------------------------------------------------------------------------- /bin/run.cmd: -------------------------------------------------------------------------------- 1 | @echo off 2 | 3 | node "%~dp0\run" %* 4 | -------------------------------------------------------------------------------- /.firebaserc: -------------------------------------------------------------------------------- 1 | { 2 | "projects": { 3 | "default": "codeclimbersio" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /packages/server/utils/constants.ts: -------------------------------------------------------------------------------- 1 | export const PROCESS_NAME = 'codeclimbers-server' 2 | -------------------------------------------------------------------------------- /packages/app/src/extensions/SqlSandbox/index.tsx: -------------------------------------------------------------------------------- 1 | export { SqlSandbox } from './SqlSandbox' 2 | -------------------------------------------------------------------------------- /docs/information_diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeClimbersIO/cli/HEAD/docs/information_diagram.png -------------------------------------------------------------------------------- /packages/app/src/assets/agi_2048.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeClimbersIO/cli/HEAD/packages/app/src/assets/agi_2048.png -------------------------------------------------------------------------------- /packages/app/src/assets/icons/boss_angry.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeClimbersIO/cli/HEAD/packages/app/src/assets/icons/boss_angry.png -------------------------------------------------------------------------------- /packages/app/src/assets/icons/boss_happy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeClimbersIO/cli/HEAD/packages/app/src/assets/icons/boss_happy.png -------------------------------------------------------------------------------- /packages/app/src/public/images/logo-min.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeClimbersIO/cli/HEAD/packages/app/src/public/images/logo-min.png -------------------------------------------------------------------------------- /packages/app/src/assets/icons/boss_neutral.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeClimbersIO/cli/HEAD/packages/app/src/assets/icons/boss_neutral.png -------------------------------------------------------------------------------- /packages/app/src/assets/source-logos/canva.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeClimbersIO/cli/HEAD/packages/app/src/assets/source-logos/canva.webp -------------------------------------------------------------------------------- /packages/app/src/assets/source-logos/claude.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeClimbersIO/cli/HEAD/packages/app/src/assets/source-logos/claude.png -------------------------------------------------------------------------------- /packages/app/src/assets/source-logos/figma.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeClimbersIO/cli/HEAD/packages/app/src/assets/source-logos/figma.png -------------------------------------------------------------------------------- /packages/app/src/assets/source-logos/github.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeClimbersIO/cli/HEAD/packages/app/src/assets/source-logos/github.png -------------------------------------------------------------------------------- /packages/app/src/assets/source-logos/gmail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeClimbersIO/cli/HEAD/packages/app/src/assets/source-logos/gmail.png -------------------------------------------------------------------------------- /packages/app/src/assets/source-logos/jira.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeClimbersIO/cli/HEAD/packages/app/src/assets/source-logos/jira.png -------------------------------------------------------------------------------- /packages/app/src/assets/source-logos/slack.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeClimbersIO/cli/HEAD/packages/app/src/assets/source-logos/slack.webp -------------------------------------------------------------------------------- /packages/app/src/assets/source-logos/vscode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeClimbersIO/cli/HEAD/packages/app/src/assets/source-logos/vscode.png -------------------------------------------------------------------------------- /packages/server/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /packages/app/src/assets/source-logos/chatgpt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeClimbersIO/cli/HEAD/packages/app/src/assets/source-logos/chatgpt.png -------------------------------------------------------------------------------- /packages/app/src/assets/source-logos/chrome.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeClimbersIO/cli/HEAD/packages/app/src/assets/source-logos/chrome.webp -------------------------------------------------------------------------------- /packages/app/src/assets/source-logos/firefox.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeClimbersIO/cli/HEAD/packages/app/src/assets/source-logos/firefox.webp -------------------------------------------------------------------------------- /packages/app/src/assets/source-logos/linkedIn.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeClimbersIO/cli/HEAD/packages/app/src/assets/source-logos/linkedIn.png -------------------------------------------------------------------------------- /packages/app/src/assets/source-logos/outlook.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeClimbersIO/cli/HEAD/packages/app/src/assets/source-logos/outlook.png -------------------------------------------------------------------------------- /packages/app/src/assets/source-logos/youtube.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeClimbersIO/cli/HEAD/packages/app/src/assets/source-logos/youtube.png -------------------------------------------------------------------------------- /packages/app/src/public/images/logo-min-white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeClimbersIO/cli/HEAD/packages/app/src/public/images/logo-min-white.png -------------------------------------------------------------------------------- /packages/app/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | declare const __APP_VERSION__: string 4 | declare const __IS_DEV__: boolean 5 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | charset = utf-8 7 | indent_style = space 8 | indent_size = 2 9 | -------------------------------------------------------------------------------- /packages/app/src/api/localServer/keys.ts: -------------------------------------------------------------------------------- 1 | export const reportKeys = { 2 | weeklyScores: (startDate: string) => ['weeklyScores', startDate] as const, 3 | } 4 | -------------------------------------------------------------------------------- /packages/app/src/assets/source-logos/stackoverflow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeClimbersIO/cli/HEAD/packages/app/src/assets/source-logos/stackoverflow.png -------------------------------------------------------------------------------- /bin/migrations/example/stub.js: -------------------------------------------------------------------------------- 1 | const SQL = `--sql 2 | 3 | ` 4 | exports.up = function (knex) { 5 | return knex.raw(SQL) 6 | } 7 | 8 | exports.down = function () {} 9 | -------------------------------------------------------------------------------- /eslint-plugin-codeclimbers/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "eslint-plugin-codeclimbers", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "MIT" 6 | } 7 | -------------------------------------------------------------------------------- /packages/server/src/v1/dtos/getWeekOverview.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsDateString } from 'class-validator' 2 | 3 | export class GetWeekOverviewDto { 4 | @IsDateString() 5 | date: string 6 | } 7 | -------------------------------------------------------------------------------- /jest.test.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 2 | module.exports = { 3 | preset: 'ts-jest', 4 | testEnvironment: 'node', 5 | testMatch: ['**/__tests__/**/*.test.ts'], 6 | } 7 | -------------------------------------------------------------------------------- /bin/run.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | process.env.NODE_ENV = process.env.NODE_ENV || 'production' 3 | ;(async () => { 4 | const oclif = await import('@oclif/core') 5 | await oclif.execute({ dir: __dirname }) 6 | })() 7 | -------------------------------------------------------------------------------- /packages/app/src/components/Home/Time/utils.ts: -------------------------------------------------------------------------------- 1 | export const minutesToHours = (minutes: number) => { 2 | const hours = Math.floor(minutes / 60) 3 | const minutesRemaining = minutes % 60 4 | 5 | return `${hours}h ${minutesRemaining}m` 6 | } 7 | -------------------------------------------------------------------------------- /packages/server/test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/server/test/jest.globalTeardown.js: -------------------------------------------------------------------------------- 1 | const { knex } = require('../src/v1/database/knex') 2 | 3 | module.exports = async () => { 4 | // Don't disconnect the DB if in watch mode 5 | if (process.env.JEST_WATCH) return 6 | 7 | await knex.destroy() 8 | } 9 | -------------------------------------------------------------------------------- /.github/prace.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | body: 3 | patterns: 4 | - '#\d+' 5 | error: The body has to include an issue reference like `#0` 6 | branch: 7 | patterns: 8 | - 'issue-\d{1,}-[a-z0-9-]+' 9 | error: Branch must be called `issue-{id}-{short description}` 10 | -------------------------------------------------------------------------------- /knexfile.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | client: 'sqlite3', 3 | useNullAsDefault: true, 4 | connection: { 5 | filename: './codeclimber.sqlite', 6 | }, 7 | migrations: { 8 | directory: './bin/migrations', 9 | stub: 'migrations/example/stub.js', 10 | }, 11 | } 12 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import react from '@vitejs/plugin-react' 2 | import { defineConfig } from 'vite' 3 | 4 | export default defineConfig({ 5 | plugins: [react()], 6 | root: 'src/app', 7 | build: { 8 | outDir: '../../dist/app', 9 | emptyOutDir: true, 10 | }, 11 | }) 12 | -------------------------------------------------------------------------------- /packages/server/src/types/utils.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace CodeClimbers { 2 | export interface StartupService { 3 | enableStartup: () => Promise 4 | disableStartup: () => Promise 5 | launchAndEnableStartup: () => Promise 6 | closeAndDisableStartup: () => Promise 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /packages/app/src/layouts/InstallLayout.tsx: -------------------------------------------------------------------------------- 1 | import { Outlet } from 'react-router-dom' 2 | 3 | interface BaseLayoutProps { 4 | children?: React.ReactNode 5 | } 6 | 7 | const BaseLayout = ({ children }: BaseLayoutProps) => { 8 | return <>{children || } 9 | } 10 | 11 | export { BaseLayout } 12 | -------------------------------------------------------------------------------- /packages/server/src/v1/database/queries/getCategoryTimeOverview.sql: -------------------------------------------------------------------------------- 1 | SELECT category, count() AS minutes 2 | FROM (SELECT category, count() 3 | FROM activities_pulse 4 | WHERE date(activities_pulse.time) BETWEEN :startDate AND :endDate 5 | GROUP BY strftime('%s', time) / 60) 6 | GROUP BY category 7 | -------------------------------------------------------------------------------- /packages/app/src/api/platformServer/keys.ts: -------------------------------------------------------------------------------- 1 | export const gamemakerKeys = { 2 | gameSettings: (id: string) => ['gameSettings', id] as const, 3 | aiWeeklyReports: ['aiWeeklyReports'] as const, 4 | } 5 | 6 | export const weeklyReportKeys = { 7 | aiWeeklyReports: (date: string) => ['aiWeeklyReports', date] as const, 8 | } 9 | -------------------------------------------------------------------------------- /packages/app/src/assets/icons/bar_chart.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /packages/app/src/extensions/SqlSandbox/sandbox.api.ts: -------------------------------------------------------------------------------- 1 | import { useMutation } from '@tanstack/react-query' 2 | import { sqlQueryFn } from '../../api/browser/services/query.service' 3 | 4 | export const useRunSql = () => { 5 | return useMutation({ 6 | mutationFn: (query: string) => sqlQueryFn(query, 'runSql'), 7 | }) 8 | } 9 | -------------------------------------------------------------------------------- /bin/migrations/20240822235249_turn_on_wal_mode.js: -------------------------------------------------------------------------------- 1 | const SQL = `--sql 2 | PRAGMA journal_mode = wal; 3 | ` 4 | exports.up = function (knex) { 5 | return knex.raw(SQL) 6 | } 7 | 8 | const DOWN_SQL = `--sql 9 | PRAGMA journal_mode = delete; 10 | ` 11 | exports.down = function (knex) { 12 | return knex.raw(DOWN_SQL) 13 | } 14 | -------------------------------------------------------------------------------- /packages/app/src/index.css: -------------------------------------------------------------------------------- 1 | html { 2 | width: 100%; 3 | height: 100%; 4 | } 5 | body { 6 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, 7 | Arial, sans-serif; 8 | margin: auto; 9 | width: 100%; 10 | height: 100%; 11 | } 12 | #root { 13 | width: 100%; 14 | height: 100%; 15 | } 16 | -------------------------------------------------------------------------------- /packages/server/jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 2 | module.exports = { 3 | preset: 'ts-jest', 4 | testEnvironment: 'node', 5 | testMatch: ['**/__tests__/**/*.test.ts'], 6 | globalSetup: '/test/jest.globalSetup.js', 7 | globalTeardown: '/test/jest.globalTeardown.js', 8 | } 9 | -------------------------------------------------------------------------------- /packages/server/src/v1/localdb/localDb.repo.ts: -------------------------------------------------------------------------------- 1 | import { InjectKnex, Knex } from 'nestjs-knex' 2 | 3 | export class LocalDbRepo { 4 | constructor(@InjectKnex() private readonly knex: Knex) {} 5 | 6 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 7 | async query(query: string): Promise { 8 | return this.knex.raw(query) 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /packages/server/knexfile.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | client: 'sqlite3', 3 | useNullAsDefault: true, 4 | connection: { 5 | filename: './codeclimber.sqlite', 6 | }, 7 | migrations: { 8 | directory: '../../bin/migrations', 9 | stub: '../../bin/migrations/example/stub.js', 10 | }, 11 | seeds: { 12 | directory: '../../bin/seeds', 13 | }, 14 | } 15 | -------------------------------------------------------------------------------- /packages/server/src/types/local.api.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace CodeClimbers { 2 | export interface LocalDbQueryDao { 3 | message: string 4 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 5 | data: any[] 6 | } 7 | 8 | export interface LocalAuthDao { 9 | message: string 10 | data: { 11 | apiKey: string 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /packages/app/src/layouts/ImportLayout.tsx: -------------------------------------------------------------------------------- 1 | import { Box } from '@mui/material' 2 | import { Outlet } from 'react-router-dom' 3 | 4 | interface ImportLayoutProps { 5 | children?: React.ReactNode 6 | } 7 | 8 | const ImportLayout = ({ children }: ImportLayoutProps) => { 9 | return {children || } 10 | } 11 | 12 | export { ImportLayout } 13 | -------------------------------------------------------------------------------- /packages/server/nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nest-cli", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src", 5 | "compilerOptions": { 6 | "deleteOutDir": true, 7 | "assets": [ 8 | { 9 | "include": "**/*.sql", 10 | "watchAssets": true, 11 | "outDir": "./dist/src" 12 | } 13 | ] 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /packages/server/src/v1/users/user.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common' 2 | import { UserRepo } from '../database/user.repo' 3 | 4 | @Injectable() 5 | export class UserService { 6 | constructor(private readonly userRepo: UserRepo) { 7 | this.userRepo = userRepo 8 | } 9 | 10 | getCurrentUser = () => { 11 | return this.userRepo.getCurrentUser() 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /packages/server/utils/sql.util.ts: -------------------------------------------------------------------------------- 1 | export const toSQL = (query) => { 2 | const { sql, bindings } = query.toSQL() 3 | const fullQuery = bindings.reduce( 4 | (acc, binding) => 5 | acc.replace( 6 | '?', 7 | binding instanceof Date ? `'${binding.toISOString()}'` : `'${binding}'`, 8 | ), 9 | sql, 10 | ) 11 | console.log(fullQuery) 12 | return fullQuery 13 | } 14 | -------------------------------------------------------------------------------- /packages/app/src/api/browser/services/query.service.ts: -------------------------------------------------------------------------------- 1 | import { BASE_API_URL } from '../..' 2 | import { apiRequest } from '../../request' 3 | 4 | // do not use this directly in a component 5 | const sqlQueryFn = (query: string, name: string) => 6 | apiRequest({ 7 | url: `${BASE_API_URL}/localdb/query`, 8 | method: 'POST', 9 | body: { query, name }, 10 | }) 11 | 12 | export { sqlQueryFn } 13 | -------------------------------------------------------------------------------- /packages/server/src/v1/database/migrations.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from '@nestjs/common' 2 | import { knex } from './knex' 3 | 4 | export const startMigrations = async () => { 5 | Logger.log('Running Migrations') 6 | 7 | try { 8 | // Locations of migrations is relative to project root 9 | await knex.migrate.latest() 10 | } finally { 11 | Logger.log('Migrations Complete') 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /packages/server/src/v1/localdb/localAuth.service.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getLocalApiKey, 3 | isValidLocalApiKey, 4 | } from '../../../utils/localAuth.util' 5 | 6 | export class LocalAuthService { 7 | async getLocalApiKey(): Promise { 8 | return getLocalApiKey() 9 | } 10 | async isValidLocalApiKey(apiKey: string): Promise { 11 | return isValidLocalApiKey(apiKey) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /packages/app/src/utils/posthog.util.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/no-named-as-default 2 | import posthog from 'posthog-js' 3 | 4 | export const initPosthog = () => { 5 | posthog.init('phc_MPX1sQylN646gExgBzp69irMAuUVUQ28fpqeCrwrWDU', { 6 | api_host: 'https://us.i.posthog.com', 7 | person_profiles: 'always', 8 | }) 9 | 10 | posthog.register({ 11 | isBrowserApp: true, 12 | }) 13 | } 14 | -------------------------------------------------------------------------------- /packages/server/src/v1/dtos/getCategoryTimeOverview.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsArray, IsDateString, ValidateNested } from 'class-validator' 2 | 3 | export class TimePeriodDto { 4 | @IsDateString() 5 | startDate: string 6 | 7 | @IsDateString() 8 | endDate: string 9 | } 10 | 11 | export class GetCategoryTimeOverviewDto { 12 | @IsArray() 13 | @ValidateNested({ each: true }) 14 | periods: TimePeriodDto[] 15 | } 16 | -------------------------------------------------------------------------------- /packages/app/src/utils/flag.util.ts: -------------------------------------------------------------------------------- 1 | // save feature flag to local storage 2 | export const saveFeatureFlag = (featureFlag: string, value: boolean) => { 3 | localStorage.setItem(`feature-flag-${featureFlag}`, value.toString()) 4 | } 5 | 6 | // get feature flag from local storage 7 | export const getFeatureFlag = (featureFlag: string) => { 8 | return localStorage.getItem(`feature-flag-${featureFlag}`) === 'true' 9 | } 10 | -------------------------------------------------------------------------------- /firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "hosting": { 3 | "public": "packages/app/dist", 4 | "ignore": ["firebase.json", "**/.*", "**/node_modules/**"], 5 | "rewrites": [ 6 | { 7 | "source": "**", 8 | "destination": "/index.html" 9 | }, 10 | { 11 | "source": "/api/v1/wakatime/users/current/heartbeats", 12 | "function": "blockWakatimeRequests" 13 | } 14 | ] 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /packages/app/src/components/GameMakers/GameMakersPage.tsx: -------------------------------------------------------------------------------- 1 | import { Box } from '@mui/material' 2 | import { PromptEditor } from './PromptEditor' 3 | import { AiWeeklyReportList } from './AIWeeklyReportList' 4 | 5 | export const GameMakersPage = () => { 6 | return ( 7 | 8 | 9 | 10 | 11 | ) 12 | } 13 | -------------------------------------------------------------------------------- /packages/app/src/routes/index.tsx: -------------------------------------------------------------------------------- 1 | import { BrowserRouter } from 'react-router-dom' 2 | import { AppRoutes } from './AppRoutes' 3 | import { useVersionConsoleBanner } from '../hooks/useVersionConsole' 4 | 5 | const AppRouter = () => { 6 | useVersionConsoleBanner() 7 | return ( 8 | <> 9 | 10 | 11 | 12 | 13 | ) 14 | } 15 | 16 | export { AppRouter } 17 | -------------------------------------------------------------------------------- /.github/workflows/prace.yml: -------------------------------------------------------------------------------- 1 | name: Prace 2 | on: 3 | pull_request: 4 | branches: 5 | - main 6 | types: 7 | - opened 8 | - edited 9 | - reopened 10 | 11 | jobs: 12 | linting: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: innerspacetrainings/Prace.js@master 16 | with: 17 | configuration-path: .github/prace.yml 18 | env: 19 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 20 | -------------------------------------------------------------------------------- /packages/app/src/extensions/HourlyCategoryReport/index.tsx: -------------------------------------------------------------------------------- 1 | import { Typography } from '@mui/material' 2 | import { HourlyCategoryReportChart } from './HourlyCategoryReport' 3 | 4 | export const HourlyCategoryReport = () => { 5 | return ( 6 |
7 | Hourly Category Report 8 |
9 | 10 |
11 |
12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /.github/workflows/eslint.yml: -------------------------------------------------------------------------------- 1 | name: eslint 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | - name: Use Node.js 17 | uses: actions/setup-node@v3 18 | with: 19 | node-version: latest 20 | cache: 'npm' 21 | - run: npm ci 22 | - run: npm run lint 23 | -------------------------------------------------------------------------------- /packages/app/src/components/common/Logo/Logo.tsx: -------------------------------------------------------------------------------- 1 | import { useTheme } from '@mui/material' 2 | 3 | const THEME_LOGO = { 4 | dark: 'logo-white.svg', 5 | light: 'logo.svg', 6 | } 7 | 8 | interface Props { 9 | width?: number | string 10 | height?: number | string 11 | } 12 | 13 | export const Logo = ({ width = 100, height = '100%' }: Props) => { 14 | const logo = `/images/${THEME_LOGO[useTheme().palette.mode]}` 15 | 16 | return 17 | } 18 | -------------------------------------------------------------------------------- /packages/server/src/v1/database/__tests__/knex.test.ts: -------------------------------------------------------------------------------- 1 | import { knex, SQL_LITE_TEST_FILE } from '../knex' 2 | 3 | describe('knex', () => { 4 | it('Should connect successfully', async () => { 5 | await knex.raw('SELECT 1').catch((e) => { 6 | expect(e).toBeUndefined() 7 | }) 8 | }) 9 | 10 | it('Should be using test path', () => { 11 | expect(knex.client.config.connection.filename).toEqual(SQL_LITE_TEST_FILE) 12 | }) 13 | }) 14 | 15 | afterAll((done) => { 16 | knex.destroy() 17 | done() 18 | }) 19 | -------------------------------------------------------------------------------- /packages/server/src/v1/database/models/user.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace CodeClimbers { 2 | export interface User { 3 | id?: number 4 | email: string 5 | firstName?: string 6 | lastName?: string 7 | avatarUrl?: string 8 | createdAt: string 9 | updatedAt: string 10 | } 11 | // same as User but snake case 12 | export interface UserDB { 13 | id?: number 14 | email: string 15 | first_name?: string 16 | last_name?: string 17 | avatar_url?: string 18 | created_at: string 19 | updated_at: string 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /packages/server/src/v1/database/queries/getLongestDayInRangeMinutes.sql: -------------------------------------------------------------------------------- 1 | WITH get_total_minutes (time, total_minutes) AS ( 2 | SELECT time, count() * 2 as total_minutes 3 | FROM activities_pulse 4 | WHERE date(activities_pulse.time) BETWEEN :startDate AND :endDate 5 | GROUP BY category, strftime('%s', time) / 120), 6 | get_day_minutes (time, day_minutes) AS ( 7 | SELECT time, count() * 2 as day_minutes 8 | FROM get_total_minutes 9 | GROUP BY strftime('%Y-%m-%d', time)) 10 | SELECT max(day_minutes) as minutes 11 | FROM get_day_minutes; 12 | -------------------------------------------------------------------------------- /bin/dev.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node --watch --watch-path=./packages --watch-path=./bin --watch-preserve-output --loader=ts-node/esm --no-warnings 2 | ;(async () => { 3 | const oclif = await import('@oclif/core') 4 | const { exec } = await import('child_process') 5 | 6 | console.log('=== BUILDING CLI (build:command) ===') 7 | exec('npm run build:command', (err) => { 8 | if (err) { 9 | console.error(err) 10 | process.exit(1) 11 | } 12 | 13 | oclif.execute({ 14 | development: true, 15 | dir: __dirname, 16 | }) 17 | }) 18 | })() 19 | -------------------------------------------------------------------------------- /packages/app/src/api/platformServer/errorReport.platformApi.ts: -------------------------------------------------------------------------------- 1 | import { platformApiRequest } from '../request' 2 | import { PLATFORM_API_URL } from '..' 3 | import { useMutation } from '@tanstack/react-query' 4 | 5 | const useSendPlatformErrorReport = () => { 6 | const mutationFn = (report: Record) => 7 | platformApiRequest({ 8 | url: `${PLATFORM_API_URL}/error-report/discord`, 9 | method: 'POST', 10 | body: report, 11 | }) 12 | return useMutation({ 13 | mutationFn, 14 | }) 15 | } 16 | 17 | export { useSendPlatformErrorReport } 18 | -------------------------------------------------------------------------------- /packages/server/test/jest.globalSetup.js: -------------------------------------------------------------------------------- 1 | const { knex, SQL_LITE_TEST_FILE } = require('../src/v1/database/knex') 2 | 3 | module.exports = async () => { 4 | if (knex.client.config.connection.filename !== SQL_LITE_TEST_FILE) { 5 | throw new Error('You are not using the test file') 6 | } 7 | 8 | console.log('\n======== SETUP - DROP ========') 9 | await knex.migrate.rollback({}, true) 10 | 11 | console.log('======== SETUP - MIGRATION ========') 12 | await knex.migrate.latest() 13 | console.log('======== SETUP - SEED ========') 14 | await knex.seed.run() 15 | } 16 | -------------------------------------------------------------------------------- /packages/server/src/v1/database/models/user_setting.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace CodeClimbers { 2 | export type WeeklyReportType = 'ai' | 'standard' | 'none' | '' 3 | export interface UserSettings { 4 | id?: number 5 | email?: string 6 | userId: number 7 | weeklyReportType: WeeklyReportType 8 | createdAt: string 9 | updatedAt: string 10 | } 11 | // same as User but snake case 12 | export interface UserSettingsDB { 13 | id?: number 14 | user_id: number 15 | weekly_report_type: WeeklyReportType 16 | created_at: string 17 | updated_at: string 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /packages/app/src/components/Home/Time/TimeDataPoint.tsx: -------------------------------------------------------------------------------- 1 | import { Typography } from '@mui/material' 2 | import Grid2 from '@mui/material/Unstable_Grid2/Grid2' 3 | 4 | interface TimeDataPointProps { 5 | title: string 6 | time: string 7 | } 8 | 9 | export const TimeDataPoint = ({ title, time }: TimeDataPointProps) => ( 10 | 11 | 12 | {title} 13 | 14 | 15 | {time} 16 | 17 | 18 | ) 19 | -------------------------------------------------------------------------------- /packages/server/src/v1/startup/startup.util.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * these modules are not available on all platforms, so we have to import them conditionally 3 | * or we'll get build and runtime errors 4 | */ 5 | const getServiceLib = () => { 6 | const os = process.platform 7 | switch (os) { 8 | case 'darwin': 9 | return require('node-mac') 10 | case 'linux': 11 | return require('@codeclimbers/node-linux') 12 | case 'win32': 13 | return require('@codeclimbers/node-windows') 14 | default: 15 | throw new Error('Unsupported platform') 16 | } 17 | } 18 | 19 | export { getServiceLib } 20 | -------------------------------------------------------------------------------- /packages/server/src/v1/database/user.repo.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common' 2 | import { InjectKnex, Knex } from 'nestjs-knex' 3 | 4 | @Injectable() 5 | export class UserRepo { 6 | constructor(@InjectKnex() private readonly knex: Knex) {} 7 | 8 | getCurrentUser = async () => { 9 | // Example query 10 | const query = ` 11 | SELECT * 12 | FROM accounts_user 13 | JOIN accounts_user_settings ON accounts_user.id = accounts_user_settings.user_id 14 | LIMIT 1 15 | ` 16 | 17 | const [result] = await this.knex.raw(query) 18 | return result 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /packages/server/utils/packageJson.util.ts: -------------------------------------------------------------------------------- 1 | // src/utils/package-json.util.ts 2 | 3 | import { readFileSync } from 'fs' 4 | import { join } from 'path' 5 | import { PROJECT_ROOT } from './node.util' 6 | 7 | interface PackageJson { 8 | version: string 9 | [key: string]: string | number | boolean | null | undefined 10 | } 11 | 12 | export const getPackageJsonVersion = (): string => { 13 | const packageJsonPath = join(PROJECT_ROOT, 'package.json') 14 | const packageJsonContent = readFileSync(packageJsonPath, 'utf-8') 15 | const packageJson: PackageJson = JSON.parse(packageJsonContent) 16 | return packageJson.version 17 | } 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # build artifacts 2 | dist 3 | node_modules 4 | 5 | # storage 6 | codeclimber.sqlite 7 | codeclimber.sqlite* 8 | codeclimber.test.sqlite* 9 | codeclimbers.identity 10 | 11 | .DS_Store 12 | 13 | .vscode 14 | .idea 15 | 16 | # oclif directory for caching build resources 17 | tmp 18 | releases 19 | 20 | # npm pack and tarball files 21 | package 22 | 23 | # intellij 24 | .idea 25 | 26 | # symlink to logs 27 | *.log 28 | 29 | # windows generated service files 30 | bin/daemon 31 | 32 | # firebase cache 33 | .firebase/hosting.* 34 | 35 | # npm pack and tarball files 36 | *.tgz 37 | 38 | # mock install 39 | codeclimbers_install_* -------------------------------------------------------------------------------- /.github/workflows/greetings.yml: -------------------------------------------------------------------------------- 1 | name: Greetings 2 | 3 | on: [pull_request_target, issues] 4 | 5 | jobs: 6 | greeting: 7 | runs-on: ubuntu-latest 8 | permissions: 9 | issues: write 10 | pull-requests: write 11 | steps: 12 | - uses: actions/first-interaction@v1 13 | with: 14 | repo-token: ${{ secrets.GITHUB_TOKEN }} 15 | issue-message: "Welcome and thanks for raising this issue!\nWe strive to reply within 48h and will reply as fast as possible." 16 | pr-message: "Welcome and thank you for contributing! Please make sure you keep Work In Progress as a draft until it is read to be reviewed." 17 | -------------------------------------------------------------------------------- /packages/app/src/hooks/useSetFeaturePreference.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * hook checks if the user settings has a particular field, and updates the local storage for if the feature is enabled 3 | * 4 | */ 5 | 6 | import { useEffect } from 'react' 7 | import { useGetCurrentUser } from '../api/browser/user.api' 8 | import { setFeatureEnabled } from '../services/feature.service' 9 | 10 | // TODO: Remove this hook after a week or two 11 | export const useSetFeaturePreference = () => { 12 | const { data: user } = useGetCurrentUser() 13 | 14 | useEffect(() => { 15 | if (!user) return 16 | setFeatureEnabled('weekly-report', user.weeklyReportType) 17 | }, [user]) 18 | } 19 | -------------------------------------------------------------------------------- /packages/server/commands/config/apikey.ts: -------------------------------------------------------------------------------- 1 | process.env.CODECLIMBERS_SERVER_APP_CONTEXT = 'cli' 2 | 3 | import { Command } from '@oclif/core' 4 | import { getLocalApiKey } from '../../utils/localAuth.util' 5 | import pc from 'picocolors' 6 | 7 | export default class ApiKey extends Command { 8 | static description = 'Get your local api key' 9 | 10 | static examples = [ 11 | `<%= config.bin %> <%= command.id %> 12 | 13 | apikey: 14 | `, 15 | ] 16 | 17 | static flags = {} 18 | 19 | async run(): Promise { 20 | const apiKey = await getLocalApiKey(true) 21 | this.log(` 22 | apikey: ${pc.green(apiKey)} 23 | `) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /packages/server/src/v1/localdb/localDb.controller.ts: -------------------------------------------------------------------------------- 1 | import { Body, Controller, Post, UseGuards } from '@nestjs/common' 2 | import { LocalAuthGuard } from './localAuth.guard' 3 | import { LocalDbRepo } from './localDb.repo' 4 | 5 | @Controller('localdb') 6 | @UseGuards(LocalAuthGuard) 7 | export class LocalDbController { 8 | constructor(private readonly localDbRepo: LocalDbRepo) {} 9 | @Post('query') 10 | async query( 11 | @Body() body: { query: string }, 12 | ): Promise { 13 | const query = body.query 14 | const result = await this.localDbRepo.query(query) 15 | return { message: 'Query', data: result } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /packages/app/src/components/common/Icons/BarChartIcon.tsx: -------------------------------------------------------------------------------- 1 | import SvgIcon, { SvgIconProps } from '@mui/material/SvgIcon' 2 | 3 | // Thank you! https://github.com/mui/material-ui/issues/35218#issuecomment-1977984142 4 | export const BarChartIcon = (props: SvgIconProps) => { 5 | return ( 6 | 11 | 15 | 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /packages/app/src/providers/localStorageAuthProvider.tsx: -------------------------------------------------------------------------------- 1 | import { useGetLocalApiKey } from '../services/localAuth.service' 2 | import { LoadingScreen } from '../components/LoadingScreen' 3 | import { getLocalApiKey, setLocalApiKey } from '../utils/auth.util' 4 | 5 | interface Props { 6 | children: React.ReactNode 7 | } 8 | 9 | export const LocalStorageAuthProvider = ({ children }: Props) => { 10 | const localApiKey = getLocalApiKey() 11 | const { data, isFetching } = useGetLocalApiKey(!localApiKey) 12 | 13 | if (isFetching) { 14 | return 15 | } 16 | if (data?.apiKey) { 17 | setLocalApiKey(data.apiKey) 18 | } 19 | return <>{children} 20 | } 21 | -------------------------------------------------------------------------------- /packages/app/src/repos/pulse.repo.ts: -------------------------------------------------------------------------------- 1 | import { deepWorkSql } from './queries/deepWork.query' 2 | 3 | const getLatestPulses = () => { 4 | // Example query 5 | const query = ` 6 | SELECT * 7 | FROM activities_pulse 8 | ORDER BY id DESC 9 | LIMIT 10 10 | ` 11 | return query 12 | } 13 | 14 | const getAllPulses = () => { 15 | const query = ` 16 | SELECT * 17 | FROM activities_pulse 18 | ORDER BY created_at DESC 19 | ` 20 | return query 21 | } 22 | 23 | const getDeepWork = (startDate: string, endDate: string) => { 24 | const query = deepWorkSql(startDate, endDate) 25 | return query 26 | } 27 | 28 | export { getAllPulses, getLatestPulses, getDeepWork } 29 | -------------------------------------------------------------------------------- /packages/server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "CommonJS", 5 | "declaration": true, 6 | "removeComments": true, 7 | "emitDecoratorMetadata": true, 8 | "experimentalDecorators": true, 9 | "esModuleInterop": true, 10 | "allowSyntheticDefaultImports": true, 11 | "target": "ES2021", 12 | "sourceMap": true, 13 | "outDir": "./dist", 14 | "incremental": true, 15 | "skipLibCheck": true, 16 | "strictNullChecks": false, 17 | "noImplicitAny": false, 18 | "strictBindCallApply": false, 19 | "forceConsistentCasingInFileNames": false, 20 | "noFallthroughCasesInSwitch": false, 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /packages/app/src/components/WeeklyReports/ScoreHeader.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Typography } from '@mui/material' 2 | import { getColorForRating } from '../../api/browser/services/report.service' 3 | 4 | interface Props { 5 | title: string 6 | score: number 7 | rating: CodeClimbers.WeeklyScoreRating 8 | } 9 | export const ScoreHeader = ({ title, score, rating }: Props) => { 10 | const color = getColorForRating(rating) 11 | return ( 12 | 13 | {title} 14 | 15 | +{score} 16 | 17 | 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /packages/server/src/common/infrastructure/http/controllers/health.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from '@nestjs/common' 2 | import { getPackageJsonVersion } from '../../../../../utils/packageJson.util' 3 | 4 | @Controller('/health') 5 | export class HealthController { 6 | private readonly version: string 7 | 8 | constructor() { 9 | this.version = getPackageJsonVersion() 10 | } 11 | 12 | @Get() 13 | health(): { 14 | OK: boolean 15 | message: string 16 | data: CodeClimbers.Health 17 | } { 18 | return { 19 | OK: true, 20 | message: 'Health check successful', 21 | data: { OK: true, app: 'codeclimbers', version: this.version }, 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /.github/workflows/firebase-hosting-merge-main.yml: -------------------------------------------------------------------------------- 1 | # This file was auto-generated by the Firebase CLI 2 | # https://github.com/firebase/firebase-tools 3 | 4 | name: Deploy to Firebase Hosting on Main 5 | on: 6 | push: 7 | branches: 8 | - main 9 | jobs: 10 | build_and_preview: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - run: npm ci && npm run build:app 15 | - uses: FirebaseExtended/action-hosting-deploy@v0 16 | with: 17 | repoToken: ${{ secrets.GITHUB_TOKEN }} 18 | firebaseServiceAccount: ${{ secrets.FIREBASE_SERVICE_ACCOUNT_CODECLIMBERSIO }} 19 | channelId: main 20 | projectId: codeclimbersio 21 | -------------------------------------------------------------------------------- /packages/server/src/v1/activities/report.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Query } from '@nestjs/common' 2 | import { ReportService } from './report.service' 3 | import dayjs from 'dayjs' 4 | 5 | @Controller('reports') 6 | export class ReportController { 7 | constructor(private readonly reportService: ReportService) { 8 | this.reportService = reportService 9 | } 10 | @Get('weekly-report') 11 | async latestPulses(@Query('startDate') startDate: string): Promise<{ 12 | message: string 13 | data: CodeClimbers.WeeklyScores | undefined 14 | }> { 15 | const pulse = await this.reportService.getWeeklyScores(dayjs(startDate)) 16 | return { message: 'success', data: pulse } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /.github/workflows/firebase-hosting-merge-release.yml: -------------------------------------------------------------------------------- 1 | # This file was auto-generated by the Firebase CLI 2 | # https://github.com/firebase/firebase-tools 3 | 4 | name: Deploy to Firebase Hosting on merge 5 | on: 6 | push: 7 | branches: 8 | - release 9 | jobs: 10 | build_and_deploy: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - run: npm ci && npm run build:app 15 | - uses: FirebaseExtended/action-hosting-deploy@v0 16 | with: 17 | repoToken: ${{ secrets.GITHUB_TOKEN }} 18 | firebaseServiceAccount: ${{ secrets.FIREBASE_SERVICE_ACCOUNT_CODECLIMBERSIO }} 19 | channelId: live 20 | projectId: codeclimbersio 21 | -------------------------------------------------------------------------------- /.github/workflows/firebase-hosting-pull-request.yml: -------------------------------------------------------------------------------- 1 | # This file was auto-generated by the Firebase CLI 2 | # https://github.com/firebase/firebase-tools 3 | 4 | name: Deploy to Firebase Hosting on PR 5 | on: pull_request 6 | permissions: 7 | checks: write 8 | contents: read 9 | pull-requests: write 10 | jobs: 11 | build_and_preview: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - run: npm ci && npm run build:app 16 | - uses: FirebaseExtended/action-hosting-deploy@v0 17 | with: 18 | repoToken: ${{ secrets.GITHUB_TOKEN }} 19 | firebaseServiceAccount: ${{ secrets.FIREBASE_SERVICE_ACCOUNT_CODECLIMBERSIO }} 20 | projectId: codeclimbersio 21 | -------------------------------------------------------------------------------- /packages/app/src/api/localServer/report.localapi.ts: -------------------------------------------------------------------------------- 1 | import { Dayjs } from 'dayjs' 2 | import { BASE_API_URL, useBetterQuery } from '..' 3 | import { reportKeys } from './keys' 4 | import { apiRequest } from '../request' 5 | 6 | const useGetLocalServerWeeklyReport = (selectedStartDate: Dayjs) => { 7 | const queryFn = () => 8 | apiRequest({ 9 | url: `${BASE_API_URL}/reports/weekly-report?startDate=${selectedStartDate.toISOString()}`, 10 | method: 'GET', 11 | }) 12 | return useBetterQuery({ 13 | queryKey: reportKeys.weeklyScores(selectedStartDate.toISOString()), 14 | queryFn, 15 | enabled: !!selectedStartDate, 16 | }) 17 | } 18 | 19 | export { useGetLocalServerWeeklyReport } 20 | -------------------------------------------------------------------------------- /packages/app/src/components/common/Icons/RingIcon.tsx: -------------------------------------------------------------------------------- 1 | import { Icon, SvgIconProps } from '@mui/material' 2 | 3 | export type RingIconProps = SvgIconProps 4 | 5 | export const RingIcon = (props: RingIconProps) => ( 6 | ( 9 | 15 | 16 | 17 | )} 18 | /> 19 | ) 20 | -------------------------------------------------------------------------------- /packages/server/test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing' 2 | import { INestApplication } from '@nestjs/common' 3 | import * as request from 'supertest' 4 | import { AppModule } from './../src/app.module' 5 | 6 | describe('AppController (e2e)', () => { 7 | let app: INestApplication 8 | 9 | beforeEach(async () => { 10 | const moduleFixture: TestingModule = await Test.createTestingModule({ 11 | imports: [AppModule], 12 | }).compile() 13 | 14 | app = moduleFixture.createNestApplication() 15 | await app.init() 16 | }) 17 | 18 | it('/ (GET)', () => { 19 | return request(app.getHttpServer()) 20 | .get('/') 21 | .expect(200) 22 | .expect('Hello World!') 23 | }) 24 | }) 25 | -------------------------------------------------------------------------------- /packages/app/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CodeClimbers - Open Source Productivity Tool 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /packages/app/src/services/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | useQuery, 3 | UseQueryOptions, 4 | UseQueryResult, 5 | } from '@tanstack/react-query' 6 | 7 | export const BASE_API_URL = '/api/v1' 8 | 9 | export const useBetterQuery = ( 10 | options: UseQueryOptions, 11 | ): UseQueryResult & { isEmpty: boolean } => { 12 | const queryResult = useQuery(options) 13 | 14 | // Determine if the data is "empty" 15 | const isEmpty = queryResult.data 16 | ? Array.isArray(queryResult.data) 17 | ? queryResult.data.length === 0 18 | : Object.keys(queryResult.data).length === 0 19 | : true 20 | 21 | // Return the original query result with the isEmpty property 22 | return { ...queryResult, isEmpty } 23 | } 24 | -------------------------------------------------------------------------------- /docs/Troubleshooting.md: -------------------------------------------------------------------------------- 1 | # Troubleshooting 2 | 3 | Some things to try if you are having trouble using Codeclimbers 4 | 5 | ## Extensions (VSCode, Chrome, etc...) 6 | 7 | ### The extension is asking for an api key 8 | 9 | Ordinarily, the CLI should make the adjustments to the `.wakatime.cfg` file for you so that you don't have to do anything. Occasionally, this may not work and the api_key that the extensions are looking for will be missing. 10 | 11 | Sometimes, this can also happen if you install the extension before you've run the CLI on your machine for the first time. 12 | 13 | To fix. Replace `~/.wakatime.cfg` with 14 | 15 | ``` 16 | [settings] 17 | api_key = eacb3beb-dad8-4fa1-b6ba-f89de8bf8f4a 18 | api_url = http://localhost:14400/api/v1/wakatime 19 | ``` 20 | -------------------------------------------------------------------------------- /packages/app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "module": "commonjs", 5 | "outDir": "./dist", 6 | "rootDirs": ["./packages", "../server/src"], 7 | "strict": true, 8 | "target": "es2022", 9 | "moduleResolution": "node", 10 | "paths": { 11 | "server/*": ["../server/src/*"], 12 | "@app/*": ["./src/*"] 13 | }, 14 | "esModuleInterop": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "skipLibCheck": true, 17 | "experimentalDecorators": true, 18 | "jsx": "react-jsx" 19 | }, 20 | 21 | "include": [ 22 | "packages/**/*", 23 | "../server/src/**/*", 24 | "./src/**/*.ts", 25 | "./src/**/*.tsx" ], 26 | "exclude": ["node_modules", "dist"] 27 | } 28 | -------------------------------------------------------------------------------- /packages/app/src/components/LoadingScreen.tsx: -------------------------------------------------------------------------------- 1 | import { Box } from '@mui/material' 2 | import { AnimatedLogo } from './common/Logo/AnimatedLogo' 3 | import { useEffect, useState } from 'react' 4 | 5 | export const LoadingScreen = () => { 6 | const [isWaiting, setIsWaiting] = useState(true) 7 | 8 | useEffect(() => { 9 | setTimeout(() => { 10 | setIsWaiting(false) 11 | }, 1000) 12 | }, []) 13 | 14 | return ( 15 | 22 | 23 | {!isWaiting && } 24 | 25 | 26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /packages/app/src/components/common/Icons/DoubleRingIcon.tsx: -------------------------------------------------------------------------------- 1 | import { Icon, SvgIconProps } from '@mui/material' 2 | 3 | export type DoubleRingIconProps = SvgIconProps 4 | 5 | export const DoubleRingIcon = (props: DoubleRingIconProps) => ( 6 | ( 9 | 15 | 16 | 17 | 18 | )} 19 | /> 20 | ) 21 | -------------------------------------------------------------------------------- /packages/app/src/components/common/CodeClimbersButton.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable codeclimbers/use-code-climbers-button */ 2 | // eslint-disable-next-line import/no-named-as-default 3 | import posthog from 'posthog-js' 4 | import { Button, ButtonProps } from '@mui/material' 5 | 6 | type Props = ButtonProps & { 7 | eventName: string 8 | target?: string 9 | } 10 | 11 | const CodeClimbersButton = ({ 12 | eventName, 13 | children, 14 | onClick, 15 | ...props 16 | }: Props) => { 17 | const handleClick = (e: React.MouseEvent) => { 18 | posthog.capture(eventName) 19 | onClick?.(e) 20 | } 21 | return ( 22 | 25 | ) 26 | } 27 | 28 | export { CodeClimbersButton } 29 | -------------------------------------------------------------------------------- /packages/app/src/components/common/HighlightLabel.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Typography } from '@mui/material' 2 | 3 | export const HighlightLabel = ({ label }: { label: string }) => { 4 | return ( 5 | theme.palette.background.border, 10 | paddingX: 1, 11 | borderRadius: '4px', 12 | background: (theme) => theme.palette.background.medium, 13 | }} 14 | > 15 | theme.palette.text.actionDown, 19 | fontSize: '12px', 20 | }} 21 | > 22 | {label} 23 | 24 | 25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /packages/app/src/components/common/Icons/BossImage.tsx: -------------------------------------------------------------------------------- 1 | import happyBoss from '@app/assets/icons/boss_happy.png' 2 | import angryBoss from '@app/assets/icons/boss_angry.png' 3 | import neutralBoss from '@app/assets/icons/boss_neutral.png' 4 | 5 | type Props = { 6 | width?: number 7 | height?: number 8 | variant?: 'happy' | 'angry' | 'neutral' 9 | } & React.ImgHTMLAttributes 10 | 11 | export const BossImage = ({ 12 | width = 32, 13 | height = 32, 14 | variant = 'happy', 15 | ...props 16 | }: Props) => { 17 | let bossImage = neutralBoss 18 | if (variant === 'happy') bossImage = happyBoss 19 | if (variant === 'angry') bossImage = angryBoss 20 | return ( 21 | Boss 22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /packages/server/src/v1/startup/startup.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Post } from '@nestjs/common' 2 | import { StartupServiceFactory } from './startupService.factory' 3 | 4 | @Controller('/startup') 5 | export class StartupController { 6 | private startupService: CodeClimbers.StartupService 7 | 8 | constructor(private readonly startupServiceFactory: StartupServiceFactory) { 9 | this.startupService = this.startupServiceFactory.getStartupService() 10 | } 11 | 12 | @Post('/enable') 13 | async enableStartup(): Promise { 14 | await this.startupService.enableStartup() 15 | return 'OK' 16 | } 17 | 18 | @Post('/disable') 19 | async disableStartup(): Promise { 20 | await this.startupService.disableStartup() 21 | return 'OK' 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /packages/app/src/api/browser/services/report.service.ts: -------------------------------------------------------------------------------- 1 | import { useTheme } from '@mui/material' 2 | 3 | export const getColorForRating = ( 4 | rating: CodeClimbers.WeeklyScoreRating, 5 | ): { main: string; accent: string } => { 6 | const theme = useTheme() 7 | switch (rating) { 8 | case 'Positive': 9 | return { 10 | main: theme.palette.graphColors.green, 11 | accent: theme.palette.graphColors.greenAccent, 12 | } 13 | case 'Alert': 14 | return { 15 | main: theme.palette.graphColors.orange, 16 | accent: theme.palette.graphColors.orangeAccent, 17 | } 18 | case 'Neutral': 19 | return { 20 | main: theme.palette.graphColors.grey, 21 | accent: theme.palette.graphColors.greyAccent, 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /packages/app/src/api/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | useQuery, 3 | UseQueryOptions, 4 | UseQueryResult, 5 | } from '@tanstack/react-query' 6 | 7 | export const BASE_API_URL = '/api/v1' 8 | export const PLATFORM_API_URL = '/api' 9 | 10 | export const useBetterQuery = ( 11 | options: UseQueryOptions, 12 | ): UseQueryResult & { isEmpty: boolean } => { 13 | const queryResult = useQuery(options) 14 | 15 | // Determine if the data is "empty" 16 | const isEmpty = queryResult.data 17 | ? Array.isArray(queryResult.data) 18 | ? queryResult.data.length === 0 19 | : Object.keys(queryResult.data).length === 0 20 | : true 21 | 22 | // Return the original query result with the isEmpty property 23 | return { ...queryResult, isEmpty } 24 | } 25 | -------------------------------------------------------------------------------- /packages/app/src/assets/icons/notification.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /packages/app/src/components/common/CodeClimbersLink.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable codeclimbers/use-code-climbers-button */ 2 | import { Link, LinkProps } from '@mui/material' 3 | // eslint-disable-next-line import/no-named-as-default 4 | import posthog from 'posthog-js' 5 | 6 | type Props = LinkProps & { 7 | eventName: string 8 | } 9 | const CodeClimbersLink = ({ 10 | eventName, 11 | children, 12 | onClick, 13 | ...props 14 | }: Props) => { 15 | const handleClick = (e: React.MouseEvent) => { 16 | posthog.capture(eventName) 17 | onClick?.(e) 18 | } 19 | return ( 20 | 25 | {children} 26 | 27 | ) 28 | } 29 | 30 | export { CodeClimbersLink } 31 | -------------------------------------------------------------------------------- /packages/server/src/v1/localdb/localAuth.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Req } from '@nestjs/common' 2 | import { LocalAuthService } from './localAuth.service' 3 | 4 | @Controller('auth/local') 5 | export class LocalAuthController { 6 | constructor(private readonly localAuthService: LocalAuthService) {} 7 | 8 | @Get() 9 | async getLocalApiKey(): Promise { 10 | const apiKey = await this.localAuthService.getLocalApiKey() 11 | return { message: 'API key set', data: { apiKey } } 12 | } 13 | 14 | @Get('validate') 15 | async validate(@Req() request: Request) { 16 | const apiKey = request.headers['x-api-key'] 17 | const isValid = await this.localAuthService.isValidLocalApiKey(apiKey) 18 | return { message: 'API key set', data: { isValid } } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /packages/app/src/utils/csv.util.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 2 | const convertRecordsToCSV = (records: Record[]): string => { 3 | console.log('records', records) 4 | const header = Object.keys(records[0]).join(',') 5 | const rows = records.map((row) => 6 | Object.values(row).map((value) => 7 | value === null ? '' : value?.toString(), 8 | ), 9 | ) 10 | 11 | return [header, ...rows].join('\n') 12 | } 13 | 14 | const downloadBlob = (blob: Blob, filename = 'data.csv') => { 15 | const encodedUri = window.URL.createObjectURL(blob) 16 | const a = document.createElement('a') 17 | a.href = encodedUri 18 | a.download = filename 19 | a.click() 20 | window.URL.revokeObjectURL(encodedUri) 21 | } 22 | 23 | export { convertRecordsToCSV, downloadBlob } 24 | -------------------------------------------------------------------------------- /packages/app/src/components/common/CodeClimbersIconButton.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable codeclimbers/use-code-climbers-button */ 2 | // eslint-disable-next-line import/no-named-as-default 3 | import posthog from 'posthog-js' 4 | import { IconButton, IconButtonProps } from '@mui/material' 5 | 6 | type Props = IconButtonProps & { 7 | eventName: string 8 | href?: string 9 | target?: string 10 | } 11 | 12 | const CodeClimbersIconButton = ({ 13 | eventName, 14 | children, 15 | onClick, 16 | ...props 17 | }: Props) => { 18 | const handleClick = (e: React.MouseEvent) => { 19 | posthog.capture(eventName) 20 | onClick?.(e) 21 | } 22 | return ( 23 | 24 | {children} 25 | 26 | ) 27 | } 28 | 29 | export { CodeClimbersIconButton } 30 | -------------------------------------------------------------------------------- /packages/server/src/common/scheduler.module.ts: -------------------------------------------------------------------------------- 1 | // scheduled-task.module.ts 2 | import { Module } from '@nestjs/common' 3 | import { ScheduleModule } from '@nestjs/schedule' 4 | import { ScheduledTaskService } from './scheduleTask.service' 5 | import { ReportService } from '../v1/activities/report.service' 6 | import { PulseRepo } from '../v1/database/pulse.repo' 7 | import { ActivitiesService } from '../v1/activities/activities.service' 8 | import { UserService } from '../v1/users/user.service' 9 | import { UserRepo } from '../v1/database/user.repo' 10 | 11 | @Module({ 12 | imports: [ScheduleModule.forRoot()], 13 | providers: [ 14 | ScheduledTaskService, 15 | ReportService, 16 | PulseRepo, 17 | ActivitiesService, 18 | UserService, 19 | UserRepo, 20 | ], 21 | }) 22 | export class ScheduledTaskModule {} 23 | -------------------------------------------------------------------------------- /bin/startup.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-var-requires 2 | const { spawn } = require('child_process') 3 | 4 | // node is often not in path for the service process 5 | process.env.PATH = `${process.env.PATH}:${process.env.NODE_PATH}` 6 | 7 | const isProduction = process.env.NODE_ENV === 'production' 8 | 9 | if (!isProduction) { 10 | console.log('DEVELOPMENT: Starting the server') 11 | console.log(`process.env: ${JSON.stringify(process.env)}`) 12 | } 13 | 14 | isProduction 15 | ? spawn('npx', ['codeclimbers@latest', 'start', 'server'], { 16 | shell: true, 17 | stdio: 'inherit', 18 | }) 19 | : spawn( 20 | 'node', 21 | [`${process.env.CODE_CLIMBER_BIN_PATH}/run.js`, 'start', 'server'], 22 | { 23 | shell: true, 24 | stdio: 'inherit', 25 | }, 26 | ) 27 | -------------------------------------------------------------------------------- /packages/app/src/components/common/CodeClimbersLoadingButton.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable codeclimbers/use-code-climbers-button */ 2 | // eslint-disable-next-line import/no-named-as-default 3 | import posthog from 'posthog-js' 4 | import { LoadingButton, LoadingButtonProps } from '@mui/lab' 5 | 6 | type Props = LoadingButtonProps & { 7 | eventName: string 8 | target?: string 9 | } 10 | 11 | const CodeClimbersLoadingButton = ({ 12 | eventName, 13 | children, 14 | onClick, 15 | ...props 16 | }: Props) => { 17 | const handleClick = (e: React.MouseEvent) => { 18 | posthog.capture(eventName) 19 | onClick?.(e) 20 | } 21 | 22 | return ( 23 | 24 | {children} 25 | 26 | ) 27 | } 28 | 29 | export { CodeClimbersLoadingButton } 30 | -------------------------------------------------------------------------------- /packages/server/src/v1/startup/unsupportedStartup.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Logger } from '@nestjs/common' 2 | 3 | @Injectable() 4 | export class UnsupportedStartupService implements CodeClimbers.StartupService { 5 | async enableStartup() { 6 | Logger.error(`Unsupported operating system: ${process.platform}`) 7 | } 8 | 9 | async disableStartup() { 10 | Logger.error(`Unsupported operating system: ${process.platform}`) 11 | } 12 | 13 | // cleanly separate implementation code for each environment. If I'm working on windows, I see all the implementation around startup 14 | async launchAndEnableStartup() { 15 | Logger.error(`Unsupported operating system: ${process.platform}`) 16 | } 17 | 18 | async closeAndDisableStartup() { 19 | Logger.error(`Unsupported operating system: ${process.platform}`) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /packages/server/src/filters/codeClimbersException.filter.ts: -------------------------------------------------------------------------------- 1 | import { ExceptionFilter, Catch, ArgumentsHost } from '@nestjs/common' 2 | import { Response } from 'express' 3 | import { CodeClimberError } from '../../utils/codeClimberErrors' 4 | 5 | @Catch(CodeClimberError) 6 | export class CodeClimberExceptionFilter implements ExceptionFilter { 7 | catch(exception: CodeClimberError, host: ArgumentsHost) { 8 | const ctx = host.switchToHttp() 9 | const response = ctx.getResponse() 10 | 11 | response.status(exception.status).json({ 12 | statusCode: exception.status, 13 | message: exception.message, 14 | error: exception.name, 15 | validationErrors: 16 | exception instanceof CodeClimberError.InvalidBody 17 | ? exception.validationErrors 18 | : undefined, 19 | }) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /packages/server/commands/startup/disable.ts: -------------------------------------------------------------------------------- 1 | process.env.CODECLIMBERS_SERVER_APP_CONTEXT = 'cli' 2 | 3 | import { Command } from '@oclif/core' 4 | import { StartupServiceFactory } from '../../src/v1/startup/startupService.factory' 5 | 6 | export default class Disable extends Command { 7 | static description = 'Disable starting codeclimbers on computer startup' 8 | 9 | static examples = [ 10 | `<%= config.bin %> <%= command.id %> 11 | codeclimbers startup disabled 12 | `, 13 | ] 14 | 15 | static flags = {} 16 | 17 | async run(): Promise { 18 | const startupService = StartupServiceFactory.buildStartupService() 19 | try { 20 | await startupService.disableStartup() 21 | this.log('codeclimbers startup disabled') 22 | } catch (error) { 23 | this.log('codeclimbers already disabled') 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /packages/server/src/common/infrastructure/http/middleware/requestlogger.middleware.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Logger, NestMiddleware } from '@nestjs/common' 2 | 3 | import { NextFunction, Request, Response } from 'express' 4 | 5 | @Injectable() 6 | export class RequestLoggerMiddleware implements NestMiddleware { 7 | use(request: Request, response: Response, next: NextFunction): void { 8 | const { ip, method, originalUrl: url } = request 9 | const userAgent = request.get('user-agent') || '' 10 | 11 | response.on('close', () => { 12 | const { statusCode } = response 13 | const contentLength = response.get('content-length') 14 | Logger.log( 15 | `${method} ${url} ${statusCode} ${contentLength} - ${userAgent} ${ip}`, 16 | 'requestlogger.middleware', 17 | ) 18 | }) 19 | 20 | next() 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /packages/server/src/v1/localdb/localAuth.guard.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Injectable, 3 | CanActivate, 4 | ExecutionContext, 5 | UnauthorizedException, 6 | } from '@nestjs/common' 7 | import { LocalAuthService } from './localAuth.service' 8 | 9 | @Injectable() 10 | export class LocalAuthGuard implements CanActivate { 11 | constructor(private readonly localAuthService: LocalAuthService) {} 12 | 13 | async canActivate(context: ExecutionContext): Promise { 14 | const request = context.switchToHttp().getRequest() 15 | 16 | const apiKey = request.headers['x-api-key'] 17 | if (!apiKey) { 18 | throw new UnauthorizedException() 19 | } 20 | 21 | const isValid = await this.localAuthService.isValidLocalApiKey(apiKey) 22 | if (!isValid) { 23 | throw new UnauthorizedException() 24 | } 25 | 26 | return true 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /packages/server/commands/startup/enable.ts: -------------------------------------------------------------------------------- 1 | process.env.CODECLIMBERS_SERVER_APP_CONTEXT = 'cli' 2 | 3 | import { Command } from '@oclif/core' 4 | import { StartupServiceFactory } from '../../src/v1/startup/startupService.factory' 5 | 6 | export default class Disable extends Command { 7 | static description = 'Disable starting codeclimbers on computer startup' 8 | 9 | static examples = [ 10 | `<%= config.bin %> <%= command.id %> 11 | codeclimbers startup enabled 12 | `, 13 | ] 14 | 15 | static flags = {} 16 | 17 | async run(): Promise { 18 | const startupService = StartupServiceFactory.buildStartupService() 19 | try { 20 | await startupService.enableStartup() 21 | this.log('codeclimbers startup enabled!') 22 | } catch (error) { 23 | console.log(error) 24 | this.log('codeclimbers already enabled') 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## The Pull Request is ready 2 | 3 | - [ ] all github actions are passing 4 | - [ ] are changes backwards compatible? 5 | - [ ] fixes # 6 | - [ ] the branch follows the naming schema `issue-123-enable-x-does-not-disable-y` 7 | - [ ] the pull request has a sensible title 8 | 9 | ## Intention 10 | 11 | With this change I intend to... 12 | 13 | 14 | 15 | ## Review Points 16 | 17 | Please take extra care reviewing... 18 | 19 | 20 | 21 | ## The code follows best practices 22 | 23 | - [ ] the code is readable 24 | - [ ] issues for follow-up tasks have been created 25 | - [ ] there is no `any` type used 26 | 27 | ## Notes 28 | 29 | 30 | -------------------------------------------------------------------------------- /.github/workflows/notify_release.yml: -------------------------------------------------------------------------------- 1 | name: Discord Release Notification 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | notify_discord: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Send Discord Notification 12 | env: 13 | DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} 14 | run: | 15 | RELEASE_BODY=$(echo '${{ github.event.release.body }}' | jq -Rs .) 16 | curl -H "Content-Type: application/json" -X POST -d '{ 17 | "content": "New release published: ${{ github.event.release.tag_name }}", 18 | "embeds": [{ 19 | "title": "${{ github.event.release.name }}", 20 | "description": '"${RELEASE_BODY}"', 21 | "url": "${{ github.event.release.html_url }}", 22 | "color": 3066993 23 | }] 24 | }' $DISCORD_WEBHOOK 25 | -------------------------------------------------------------------------------- /packages/app/src/components/Home/Source/Sources.error.tsx: -------------------------------------------------------------------------------- 1 | import { Card, CardContent, Stack, Typography } from '@mui/material' 2 | 3 | const SourcesError = () => { 4 | return ( 5 | 6 | 7 | 8 | 9 | Sources 10 | 11 | 18 | Error getting connected sources. 19 | 20 | 21 | 22 | 23 | ) 24 | } 25 | 26 | export { SourcesError } 27 | -------------------------------------------------------------------------------- /packages/server/utils/__tests__/activites.util.test.ts: -------------------------------------------------------------------------------- 1 | import { getSourceFromUserAgent } from '../activities.util' 2 | 3 | describe('getSourceFromUserAgent', () => { 4 | it(`should return 'vscode' as source of userAgents`, () => { 5 | const userAgents = [ 6 | 'wakatime/v1.98.1 (windows-10.0.22631.3880-unknown) go1.22.5 vscode/1.91.1 vscode-climbers/0.0.0', 7 | 'wakatime/v1.98.1 (windows-10.0.22631.3880-unknown) go1.22.5 vscode/1.91.1 vscode-wakatime/24.6.0', 8 | 'wakatime/v1.98.3 (windows-10.0.22631.3880-unknown) go1.22.5 vscode/1.91.1 vscode-climbers/0.0.0', 9 | 'wakatime/v1.98.3 (windows-10.0.22631.3880-unknown) go1.22.5 vscode/1.91.1 vscode-wakatime/24.6.0', 10 | ] 11 | 12 | const result = userAgents.map((userAgent) => { 13 | return getSourceFromUserAgent(userAgent) 14 | }) 15 | 16 | expect(result).toEqual(['vscode', 'vscode', 'vscode', 'vscode']) 17 | }) 18 | }) 19 | -------------------------------------------------------------------------------- /packages/app/src/components/Extensions/ExtensionsPage.tsx: -------------------------------------------------------------------------------- 1 | import { Box } from '@mui/material' 2 | import { ExtensionDetail } from './ExtensionDetail' 3 | import { PlainHeader } from '../common/PlainHeader' 4 | import { getExtensions } from '../../services/extensions.service' 5 | 6 | export const ExtensionsPage = () => { 7 | const extensions = getExtensions() 8 | return ( 9 | 10 | 18 | 19 | 20 | 21 | {extensions.map((extension) => ( 22 | 23 | ))} 24 | 25 | 26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /bin/migrations/20241004000814_create_user.js: -------------------------------------------------------------------------------- 1 | exports.up = function(knex) { 2 | return knex.transaction(async (trx) => { 3 | // Insert into accounts_user 4 | await trx('accounts_user').insert({}); 5 | const [row] = await trx.raw('SELECT last_insert_rowid() as id'); 6 | const userId = row.id; 7 | // Insert into accounts_user_settings 8 | await trx('accounts_user_settings').insert({ 9 | user_id: userId 10 | }); 11 | }); 12 | }; 13 | 14 | exports.down = function(knex) { 15 | // return knex.transaction(async (trx) => { 16 | // // Remove the last inserted user_settings 17 | // await trx('accounts_user_settings') 18 | // .where('user_id', knex.raw('(SELECT MAX(id) FROM accounts_user)')) 19 | // .del(); 20 | 21 | // // Remove the last inserted user 22 | // await trx('accounts_user') 23 | // .where('id', knex.raw('(SELECT MAX(id) FROM accounts_user)')) 24 | // .del(); 25 | // }); 26 | }; -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: Stale 2 | on: 3 | schedule: 4 | - cron: '37 * * * *' 5 | 6 | permissions: 7 | issues: write 8 | pull-requests: write 9 | 10 | jobs: 11 | stale: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/stale@v9 15 | with: 16 | days-before-pr-stale: 7 17 | days-before-issue-stale: 7 18 | close-pr-message: "Due to lack of activity this has been closed for now. Feel free to reopen once you are back." 19 | include-only-assigned: true 20 | days-before-issue-close: 21 21 | stale-issue-message: "Due to inactivity you will be unassigned soon and the issue be freed." 22 | remove-assignee: 23 | runs-on: ubuntu-latest 24 | steps: 25 | - name: Unassign contributor after days of inactivity 26 | uses: BoundfoxStudios/action-unassign-contributor-after-days-of-inactivity@v1.0.3 27 | with: 28 | last-activity: 14 29 | -------------------------------------------------------------------------------- /packages/app/src/services/feature.service.ts: -------------------------------------------------------------------------------- 1 | import { posthog } from 'posthog-js' 2 | 3 | export type FeatureState = boolean | CodeClimbers.WeeklyReportType 4 | export type FeatureKey = 'weekly-report' 5 | 6 | export const isFeatureEnabled = ( 7 | feature: FeatureKey, 8 | state: FeatureState, 9 | ): boolean => { 10 | const enabledFeatures = JSON.parse( 11 | localStorage.getItem(`enabled-features-${feature}`) || '', 12 | ) 13 | return enabledFeatures === state 14 | } 15 | 16 | export const setFeatureEnabled = (feature: FeatureKey, state: FeatureState) => { 17 | let stringState = '' 18 | try { 19 | stringState = JSON.stringify(state) 20 | localStorage.setItem(`enabled-features-${feature}`, stringState) 21 | posthog.capture('$set', { 22 | $set: { [`enabled-features-${feature}`]: state }, 23 | }) 24 | } catch (error) { 25 | console.error(`Error setting feature enabled-features-${feature}`, error) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /packages/app/src/services/keys.ts: -------------------------------------------------------------------------------- 1 | export const pulseKeys = { 2 | pulse: ['pulse'] as const, 3 | latestPulses: ['pulse', 'latest-pulses'] as const, 4 | sources: ['sources'] as const, 5 | weekOverview: (date: string) => ['weekOverview', date] as const, 6 | deepWork: (startDate: string, endDate: string) => 7 | ['deepWork', startDate, endDate] as const, 8 | categoryTimeOverview: (startDate: string, endDate: string) => 9 | ['categoryTimeOverview', startDate, endDate] as const, 10 | sourcesMinutes: (startDate: string, endDate: string) => 11 | ['sourcesMinutes', startDate, endDate] as const, 12 | sitesMinutes: (startDate: string, endDate: string) => 13 | ['sitesMinutes', startDate, endDate] as const, 14 | perProjectOverviewTopThree: (startDate: string, endDate: string) => 15 | ['perProjectTimeOverview', 'topThree', startDate, endDate] as const, 16 | } 17 | 18 | export const userKeys = { 19 | user: ['user'] as const, 20 | } 21 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | matrix: 16 | node-version: [20.x, 21.x, 22.x] 17 | steps: 18 | - uses: actions/checkout@v4 19 | - name: Use Node.js ${{ matrix.node-version }} 20 | uses: actions/setup-node@v3 21 | with: 22 | node-version: ${{ matrix.node-version }} 23 | cache: "npm" 24 | - run: npm ci 25 | - run: npm test 26 | - name: Get package version 27 | id: package-version 28 | run: echo "::set-output name=version::$(node -p "require('./package.json').version")" 29 | - name: Run mock install script 30 | run: | 31 | chmod +x ./scripts/mock_install.sh 32 | ./scripts/mock_install.sh ${{ steps.package-version.outputs.version }} 33 | -------------------------------------------------------------------------------- /packages/app/src/hooks/useBrowserPreferences.ts: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | 3 | // Local augmentation of the Window interface 4 | declare global { 5 | interface Window { 6 | doNotTrack: string | null 7 | } 8 | interface Navigator { 9 | msDoNotTrack: string | null 10 | } 11 | } 12 | 13 | /** 14 | * Custom hook to get the browser preferences 15 | */ 16 | export const useBrowserPreferences = () => { 17 | const [prefersDark] = useState(() => { 18 | if (!window) return true 19 | 20 | return window.matchMedia('(prefers-color-scheme: dark)').matches 21 | }) 22 | 23 | const [doNotTrack] = useState(() => { 24 | if (!window) return false 25 | 26 | // const dnt = 27 | // navigator.doNotTrack || window.doNotTrack || navigator.msDoNotTrack 28 | // disabled doNotTrack feature until we release publicly 29 | return false 30 | }) 31 | 32 | return { 33 | prefersDark, 34 | doNotTrack, 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /packages/server/.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | /build 5 | 6 | # Logs 7 | logs 8 | *.log 9 | npm-debug.log* 10 | pnpm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | lerna-debug.log* 14 | 15 | # OS 16 | .DS_Store 17 | 18 | # Tests 19 | /coverage 20 | /.nyc_output 21 | 22 | # IDEs and editors 23 | /.idea 24 | .project 25 | .classpath 26 | .c9/ 27 | *.launch 28 | .settings/ 29 | *.sublime-workspace 30 | 31 | # IDE - VSCode 32 | .vscode/* 33 | !.vscode/settings.json 34 | !.vscode/tasks.json 35 | !.vscode/launch.json 36 | !.vscode/extensions.json 37 | 38 | # dotenv environment variable files 39 | .env 40 | .env.development.local 41 | .env.test.local 42 | .env.production.local 43 | .env.local 44 | 45 | # temp directory 46 | .temp 47 | .tmp 48 | 49 | # Runtime data 50 | pids 51 | *.pid 52 | *.seed 53 | *.pid.lock 54 | 55 | # Diagnostic reports (https://nodejs.org/api/report.html) 56 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 57 | -------------------------------------------------------------------------------- /.github/workflows/bump_minor_version.yml: -------------------------------------------------------------------------------- 1 | name: Bump Minor Version 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | bump_minor_version: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | with: 12 | token: ${{ secrets.GH_PERSONAL_ACCESS_TOKEN }} 13 | fetch-depth: 0 14 | 15 | - name: Use Node.js 16 | uses: actions/setup-node@v3 17 | with: 18 | node-version: latest 19 | 20 | - name: Configure Git 21 | run: | 22 | git config --global user.name "github-actions[bot]" 23 | git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" 24 | 25 | - name: Bump version 26 | run: | 27 | git checkout main 28 | npm version minor 29 | 30 | - name: Push changes 31 | run: | 32 | git branch --all 33 | git push origin main 34 | git push --tags 35 | -------------------------------------------------------------------------------- /packages/app/src/services/localAuth.service.ts: -------------------------------------------------------------------------------- 1 | import { BASE_API_URL, useBetterQuery } from '.' 2 | import { apiRequest } from '../api/request' 3 | 4 | export const useGetLocalApiKey = (enabled = true) => { 5 | const queryFn = () => 6 | apiRequest({ 7 | url: `${BASE_API_URL}/auth/local`, 8 | method: 'GET', 9 | }) 10 | return useBetterQuery<{ apiKey: string }, Error>({ 11 | queryKey: ['local-auth'], 12 | queryFn, 13 | enabled, 14 | retry: false, 15 | }) 16 | } 17 | 18 | export const useValidateLocalApiKey = ( 19 | page: 'import' | 'home' | 'banner' = 'home', 20 | ) => { 21 | const queryFn = () => 22 | apiRequest({ 23 | url: `${BASE_API_URL}/auth/local/validate`, 24 | method: 'GET', 25 | }) 26 | return useBetterQuery<{ isValid: boolean }, Error>({ 27 | queryKey: ['local-auth/has-valid-cookie', page], 28 | queryFn, 29 | refetchOnWindowFocus: 'always', 30 | retry: false, 31 | }) 32 | } 33 | -------------------------------------------------------------------------------- /packages/server/commands/log/out.ts: -------------------------------------------------------------------------------- 1 | // oclif command to get the latest 50 lines of error logs from the log file 2 | import { Command, Flags } from '@oclif/core' 3 | import path from 'node:path' 4 | import fs from 'node:fs' 5 | import { CODE_CLIMBER_META_DIR, LOG_NAME } from '../../utils/node.util' 6 | 7 | export default class LogOut extends Command { 8 | static description = 'Get the latest 50 lines of error logs' 9 | 10 | static flags = { 11 | lines: Flags.string({ 12 | char: 'l', 13 | description: 'Number of lines to get', 14 | required: false, 15 | }), 16 | } 17 | 18 | async run() { 19 | const { flags } = await this.parse(LogOut) 20 | const lines = flags.lines || 50 21 | this.log(`Getting latest ${lines} lines of logs...`) 22 | const logPath = path.join(CODE_CLIMBER_META_DIR, LOG_NAME) 23 | const logContent = fs.readFileSync(logPath, 'utf8') 24 | this.log(logContent.split('\n').slice(-lines).join('\n')) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /packages/app/vite.config.js: -------------------------------------------------------------------------------- 1 | import react from '@vitejs/plugin-react' 2 | import { defineConfig } from 'vite' 3 | import { version } from '../../package.json' 4 | import path from 'path' 5 | 6 | const define = (mode) => ({ 7 | // Need to clone the version string otherwise it breaks. 8 | __APP_VERSION__: JSON.stringify(version), 9 | __IS_DEV__: mode === 'development', 10 | }) 11 | 12 | export default defineConfig(({ mode }) => { 13 | return { 14 | plugins: [react()], 15 | root: 'src', 16 | define: { 17 | ...define(mode), 18 | }, 19 | build: { 20 | outDir: '../dist', 21 | emptyOutDir: true, 22 | }, 23 | resolve: { 24 | alias: { 25 | '@app': path.resolve(__dirname, './src'), 26 | }, 27 | }, 28 | server: { 29 | fs: { 30 | allow: [ 31 | // search up for workspace root 32 | '../dist', 33 | './', 34 | '../../../node_modules', 35 | ], 36 | }, 37 | }, 38 | } 39 | }) 40 | -------------------------------------------------------------------------------- /packages/server/commands/log/error.ts: -------------------------------------------------------------------------------- 1 | // oclif command to get the latest 50 lines of error logs from the log file 2 | import { Command, Flags } from '@oclif/core' 3 | import path from 'node:path' 4 | import fs from 'node:fs' 5 | import { CODE_CLIMBER_META_DIR, ERROR_LOG_NAME } from '../../utils/node.util' 6 | 7 | export default class Log extends Command { 8 | static description = 'Get the latest 50 lines of error logs' 9 | 10 | static flags = { 11 | lines: Flags.string({ 12 | char: 'l', 13 | description: 'Number of lines to get', 14 | required: false, 15 | }), 16 | } 17 | 18 | async run() { 19 | const { flags } = await this.parse(Log) 20 | const lines = flags.lines || 50 21 | this.log(`Getting latest ${lines} lines of error logs...`) 22 | const errorLogPath = path.join(CODE_CLIMBER_META_DIR, ERROR_LOG_NAME) 23 | const errorLogContent = fs.readFileSync(errorLogPath, 'utf8') 24 | this.log(errorLogContent.split('\n').slice(-lines).join('\n')) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | schedule: 11 | - cron: '34 21 * * 0' 12 | 13 | jobs: 14 | analyze: 15 | name: Analyze 16 | runs-on: ubuntu-latest 17 | timeout-minutes: 350 18 | permissions: 19 | security-events: write 20 | packages: read 21 | actions: read 22 | contents: read 23 | steps: 24 | - name: Checkout repository 25 | uses: actions/checkout@v4 26 | - name: Initialize CodeQL 27 | uses: github/codeql-action/init@v3 28 | with: 29 | languages: javascript-typescript 30 | build-mode: none 31 | - name: Setup Node.js environment 32 | uses: actions/setup-node@v4.0.3 33 | with: 34 | node-version: 20 35 | - run: npm ci 36 | - name: Perform CodeQL Analysis 37 | uses: github/codeql-action/analyze@v3 38 | with: 39 | category: "/language:javascript-typescript" 40 | -------------------------------------------------------------------------------- /packages/app/src/utils/auth.util.ts: -------------------------------------------------------------------------------- 1 | // function to get api key from local storage 2 | const LOCAL_API_KEY = 'local_api_key' 3 | const GAMEMAKER_API_KEY = 'gamemaker_api_key' 4 | const getLocalApiKey = (): string | null => { 5 | const apiKey = localStorage.getItem(LOCAL_API_KEY) 6 | if (apiKey === 'undefined') { 7 | return null 8 | } 9 | return apiKey 10 | } 11 | 12 | const setLocalApiKey = (apiKey: string) => { 13 | if (apiKey === 'undefined') return 14 | localStorage.setItem(LOCAL_API_KEY, apiKey) 15 | } 16 | 17 | const getGameMakerApiKey = (): string | null => { 18 | const apiKey = localStorage.getItem(GAMEMAKER_API_KEY) 19 | if (apiKey === 'undefined') { 20 | return null 21 | } 22 | return apiKey 23 | } 24 | 25 | const setGameMakerApiKey = (apiKey: string) => { 26 | if (apiKey === 'undefined') return 27 | localStorage.setItem(GAMEMAKER_API_KEY, apiKey) 28 | } 29 | 30 | export { 31 | getLocalApiKey, 32 | setLocalApiKey, 33 | getGameMakerApiKey, 34 | setGameMakerApiKey, 35 | } 36 | -------------------------------------------------------------------------------- /packages/app/src/utils/time.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-named-as-default-member */ 2 | import dayjs from 'dayjs' 3 | import relativeTime from 'dayjs/plugin/relativeTime' 4 | import updateLocale from 'dayjs/plugin/updateLocale' 5 | 6 | dayjs.extend(updateLocale) 7 | dayjs.extend(relativeTime) 8 | 9 | dayjs.updateLocale('en', { 10 | relativeTime: { 11 | future: 'in %s', 12 | past: '%s ago', 13 | s: '1s', 14 | m: '1m', 15 | mm: '%dm', 16 | h: '1h', 17 | hh: '%dh', 18 | d: '1d', 19 | dd: '%dd', 20 | M: '1M', 21 | MM: '%dM', 22 | y: '1Y', 23 | yy: '%dY', 24 | }, 25 | }) 26 | 27 | export const getTimeSince = (utcDateString: string): string => { 28 | return dayjs(utcDateString).fromNow(true) 29 | } 30 | 31 | export const formatMinutes = (minutes: number | string) => { 32 | if (typeof minutes !== 'number') return 33 | const hours = Math.floor(minutes / 60) 34 | const remainingMinutes = Math.floor(minutes % 60) 35 | return `${hours}h ${remainingMinutes}m` 36 | } 37 | -------------------------------------------------------------------------------- /packages/app/src/layouts/DashboardLayout.tsx: -------------------------------------------------------------------------------- 1 | import { Outlet } from 'react-router-dom' 2 | import { UpdateBanner } from '../components/common/UpdateBanner/UpdateBanner' 3 | import { LocalApiKeyErrorBanner } from '../components/common/LocalApiKeyErrorBanner' 4 | import { LocalStorageAuthProvider } from '../providers/localStorageAuthProvider' 5 | import { useUpdateVersionHook } from '../hooks/useUpdateHook' 6 | import { UpdatePage } from '../components/UpdatePage' 7 | 8 | interface DashboardLayoutProps { 9 | children?: React.ReactNode 10 | } 11 | 12 | const DashboardLayout = ({ children }: DashboardLayoutProps) => { 13 | const { isMajorUpdate, isMinorUpdate } = useUpdateVersionHook() 14 | if (isMajorUpdate || isMinorUpdate) { 15 | return 16 | } 17 | return ( 18 | <> 19 | 20 | 21 | 22 | {children || } 23 | 24 | 25 | ) 26 | } 27 | 28 | export { DashboardLayout } 29 | -------------------------------------------------------------------------------- /packages/server/src/v1/database/queries/getStatusBarDetails.sql: -------------------------------------------------------------------------------- 1 | WITH heartbeats_with_diff AS ( 2 | SELECT 3 | project, 4 | language, 5 | editor, 6 | operating_system, 7 | machine, 8 | branch, 9 | time, 10 | MIN( 11 | (JULIANDAY(time) - JULIANDAY(LAG(time) OVER w)) * 86400, 12 | 120 13 | ) AS diff 14 | FROM 15 | activities_pulse 16 | WHERE 17 | time >= :startOfDay 18 | AND time < :endOfDay 19 | WINDOW 20 | w AS (ORDER BY time) 21 | ) 22 | SELECT 23 | project, 24 | language, 25 | editor, 26 | operating_system, 27 | machine, 28 | branch, 29 | ROUND(SUM(MAX(1, diff))) AS seconds, 30 | MIN(time) AS min_heartbeat_time, 31 | MAX(time) AS max_heartbeat_time 32 | FROM 33 | heartbeats_with_diff 34 | WHERE 35 | diff IS NOT NULL 36 | GROUP BY 37 | project, 38 | language, 39 | editor, 40 | operating_system, 41 | machine, 42 | branch 43 | -------------------------------------------------------------------------------- /.github/workflows/bump_patch_version.yml: -------------------------------------------------------------------------------- 1 | name: Bump Patch Version 2 | 3 | on: 4 | workflow_dispatch: 5 | pull_request: 6 | types: [closed] 7 | branches: 8 | - main 9 | 10 | jobs: 11 | bump_patch_version: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | with: 16 | token: ${{ secrets.GH_PERSONAL_ACCESS_TOKEN }} 17 | fetch-depth: 0 18 | 19 | - name: Use Node.js 20 | uses: actions/setup-node@v3 21 | with: 22 | node-version: latest 23 | 24 | - name: Configure Git 25 | run: | 26 | git config --global user.name "github-actions[bot]" 27 | git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" 28 | 29 | - name: Bump version 30 | run: | 31 | git checkout main 32 | npm version patch 33 | 34 | - name: Push changes 35 | run: | 36 | git branch --all 37 | git push origin main 38 | git push --tags 39 | -------------------------------------------------------------------------------- /packages/app/src/components/Home/DeepWork.tsx: -------------------------------------------------------------------------------- 1 | import { CircularProgress, useTheme } from '@mui/material' 2 | import { TimeDataChart } from './Time/TimeDataChart' 3 | import { Dayjs } from 'dayjs' 4 | import { minutesToHours } from './Time/utils' 5 | import { useDeepWorkV2 } from '../../api/browser/pulse.api' 6 | 7 | interface Props { 8 | selectedDate: Dayjs 9 | } 10 | const DeepWork = ({ selectedDate }: Props) => { 11 | const theme = useTheme() 12 | const { 13 | isLoading, 14 | isError, 15 | data: deepWork, 16 | } = useDeepWorkV2(selectedDate, selectedDate.endOf('day')) 17 | 18 | if (isLoading) return 19 | if (isError || !deepWork) return
Error
20 | 21 | const totalTime = deepWork[0]?.time ?? 0 22 | return ( 23 | <> 24 | 30 | 31 | ) 32 | } 33 | 34 | export { DeepWork } 35 | -------------------------------------------------------------------------------- /bin/migrations/20240620003646_add_pulses.js: -------------------------------------------------------------------------------- 1 | const SQL = `--sql 2 | CREATE TABLE activities_pulse ( 3 | id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, 4 | user_id text not null, 5 | entity text not null, 6 | type varchar(255), 7 | category varchar(255), 8 | project text, 9 | branch text, 10 | language text, 11 | is_write boolean, 12 | editor text, 13 | operating_system text, 14 | machine text, 15 | user_agent varchar(255), 16 | time timestamp(3), 17 | hash varchar(17) UNIQUE, 18 | origin varchar(255), 19 | origin_id varchar(255), 20 | created_at timestamp(3), 21 | description text 22 | 23 | ); 24 | ` 25 | exports.up = function (knex) { 26 | return knex.raw(SQL) 27 | } 28 | 29 | const DOWN_SQL = `--sql 30 | DROP TABLE activities_pulse; 31 | ` 32 | 33 | exports.down = function (knex) { 34 | return knex.raw(DOWN_SQL) 35 | } 36 | -------------------------------------------------------------------------------- /packages/app/src/hooks/useUpdateHook.ts: -------------------------------------------------------------------------------- 1 | import { useGetLocalVersion } from '../services/health.service' 2 | import { useLatestVersion } from '../services/version.service' 3 | import { extractVersions } from '../utils/environment.util' 4 | 5 | export const useUpdateVersionHook = () => { 6 | const { data: localVersionResponse } = useGetLocalVersion() 7 | const remoteVersion = useLatestVersion() 8 | const localVersion = localVersionResponse?.version 9 | 10 | const { 11 | major: remoteMajor, 12 | minor: remoteMinor, 13 | patch: remotePatch, 14 | } = extractVersions(remoteVersion.data ?? '') 15 | const { 16 | major: localMajor, 17 | minor: localMinor, 18 | patch: localPatch, 19 | } = extractVersions(localVersion ?? '') 20 | 21 | const isMajorUpdate = remoteMajor > localMajor 22 | const isMinorUpdate = remoteMinor > localMinor 23 | const isPatchUpdate = remotePatch > localPatch 24 | 25 | return { 26 | isMajorUpdate, 27 | isMinorUpdate, 28 | isPatchUpdate, 29 | remoteVersion, 30 | localVersion, 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /packages/app/src/components/Extensions/AuthorInfo.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Typography } from '@mui/material' 2 | import { GitHubProfileImage } from '../common/GithubProfileImage' 3 | 4 | interface Props { 5 | authorUrl: string 6 | authorName: string 7 | } 8 | 9 | export const AuthorInfo = ({ authorUrl, authorName }: Props) => { 10 | return ( 11 | 32 | 33 | 34 | {authorName} 35 | 36 | 37 | ) 38 | } 39 | -------------------------------------------------------------------------------- /packages/app/src/repos/user.repo.ts: -------------------------------------------------------------------------------- 1 | import { db, sqlWithBindings } from '../utils/db.util' 2 | 3 | const getCurrentUser = () => { 4 | // Example query 5 | const query = ` 6 | SELECT * 7 | FROM accounts_user 8 | JOIN accounts_user_settings ON accounts_user.id = accounts_user_settings.user_id 9 | LIMIT 1 10 | ` 11 | return query 12 | } 13 | 14 | const updateUser = (userId: number, user: Partial) => { 15 | const query = db 16 | .updateTable('accounts_user') 17 | .set(user) 18 | .where('id', '=', userId) 19 | .returningAll() 20 | 21 | const sql = sqlWithBindings(query.compile()) 22 | return sql 23 | } 24 | 25 | const updateUserSettings = ( 26 | userId: number, 27 | settings: Partial, 28 | ) => { 29 | const query = db 30 | .updateTable('accounts_user_settings') 31 | .set(settings) 32 | .where('user_id', '=', userId) 33 | .returningAll() 34 | 35 | const sql = sqlWithBindings(query.compile()) 36 | return sql 37 | } 38 | 39 | export { getCurrentUser, updateUser, updateUserSettings } 40 | -------------------------------------------------------------------------------- /packages/server/src/types/report.api.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace CodeClimbers { 2 | export type WeeklyScoreRating = 'Positive' | 'Neutral' | 'Alert' 3 | export interface WeeklyScore { 4 | score: number 5 | actual: number // represents the number used to give the rating 6 | rating: WeeklyScoreRating 7 | explanation?: string 8 | breakdown?: unknown 9 | recommendation?: string 10 | } 11 | 12 | export interface WeeklyScores { 13 | totalTimeScore: WeeklyScore 14 | projectTimeScore: WeeklyScore 15 | socialMediaTimeScore: WeeklyScore 16 | deepWorkTimeScore: WeeklyScore 17 | growthScore: WeeklyScore 18 | totalScore: WeeklyScore 19 | } 20 | 21 | export interface UserWeeklySummary { 22 | totalTime: number // total time over the period 23 | greatestProjectTime: number // total time on a specific project 24 | totalSocialMediaTime: number // total time on social media 25 | totalGrowthTime: number // total time spent on sites related to learning 26 | averageDeepWorkTime: number // average time on deep work each day of the week 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /packages/app/src/api/browser/repos/user.repo.ts: -------------------------------------------------------------------------------- 1 | import { db, sqlWithBindings } from '../../utils/db.util' 2 | 3 | const getCurrentUser = () => { 4 | // Example query 5 | const query = ` 6 | SELECT * 7 | FROM accounts_user 8 | JOIN accounts_user_settings ON accounts_user.id = accounts_user_settings.user_id 9 | LIMIT 1 10 | ` 11 | return query 12 | } 13 | 14 | const updateUser = (userId: number, user: Partial) => { 15 | const query = db 16 | .updateTable('accounts_user') 17 | .set(user) 18 | .where('id', '=', userId) 19 | .returningAll() 20 | 21 | const sql = sqlWithBindings(query.compile()) 22 | return sql 23 | } 24 | 25 | const updateUserSettings = ( 26 | userId: number, 27 | settings: Partial, 28 | ) => { 29 | const query = db 30 | .updateTable('accounts_user_settings') 31 | .set(settings) 32 | .where('user_id', '=', userId) 33 | .returningAll() 34 | 35 | const sql = sqlWithBindings(query.compile()) 36 | return sql 37 | } 38 | 39 | export { getCurrentUser, updateUser, updateUserSettings } 40 | -------------------------------------------------------------------------------- /packages/app/src/components/common/LocalApiKeyErrorBanner.tsx: -------------------------------------------------------------------------------- 1 | import { Alert, Box } from '@mui/material' 2 | import { useValidateLocalApiKey } from '../../services/localAuth.service' 3 | import { useNavigate } from 'react-router-dom' 4 | import { CodeClimbersButton } from './CodeClimbersButton' 5 | 6 | export const LocalApiKeyErrorBanner = () => { 7 | const { data, isPending } = useValidateLocalApiKey('banner') 8 | const navigate = useNavigate() 9 | if (isPending || data?.isValid) { 10 | return null 11 | } 12 | return ( 13 | 14 | 15 | Looks like you need to update your local API key. 16 | { 21 | navigate('/import') 22 | }} 23 | sx={{ marginLeft: 1 }} 24 | > 25 | Update API Key 26 | 27 | 28 | 29 | ) 30 | } 31 | -------------------------------------------------------------------------------- /packages/app/src/utils/categories.ts: -------------------------------------------------------------------------------- 1 | import { Theme } from '@mui/material' 2 | 3 | export const categories = { 4 | coding: 'coding', 5 | browsing: 'browsing', 6 | debugging: 'debugging', 7 | communicating: 'communicating', 8 | designing: 'designing', 9 | } 10 | 11 | export const simplifiedCategories = { 12 | coding: 'coding', 13 | browsing: 'browsing', 14 | communicating: 'communicating', 15 | designing: 'designing', 16 | } 17 | 18 | export const typeColors = (theme: Theme) => [ 19 | { 20 | type: 'code', 21 | category: 'coding', 22 | color: theme.palette.graphColors.blue, 23 | }, 24 | { 25 | type: 'design', 26 | category: 'designing', 27 | color: theme.palette.graphColors.orange, 28 | }, 29 | { 30 | type: 'communication', 31 | category: 'communicating', 32 | color: theme.palette.graphColors.purple, 33 | }, 34 | { 35 | type: 'web', 36 | category: 'browsing', 37 | color: theme.palette.graphColors.green, 38 | }, 39 | { 40 | type: 'misc', 41 | category: 'browsing', 42 | color: theme.palette.graphColors.green, 43 | }, 44 | ] 45 | -------------------------------------------------------------------------------- /.github/workflows/pull-request-size.yml: -------------------------------------------------------------------------------- 1 | name: pull request size 2 | on: 3 | pull_request: 4 | branches: 5 | - main 6 | jobs: 7 | lint: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | - name: Use Node.js 12 | uses: actions/setup-node@v4 13 | with: 14 | node-version: latest 15 | cache: 'npm' 16 | cache-dependency-path: | 17 | package-lock.json 18 | - run: npm install -g check-pr-length 19 | - run: git remote add base https://github.com/CodeClimbersIO/cli.git 20 | - run: git config --global user.email "prs@example.com" 21 | - run: git config --global user.name "PRS" 22 | - run: git fetch base main 23 | - run: git switch main 24 | - run: git remote add pullrequest "https://github.com/${{github.event.pull_request.head.repo.full_name}}" 25 | - run: git fetch pullrequest "${GITHUB_HEAD_REF}" 26 | - run: git switch $GITHUB_HEAD_REF 27 | - run: check-pr-length --max=700 --total=1000 --base=main --silent=false --exclude="package-lock.json;*/package-lock.json" 28 | -------------------------------------------------------------------------------- /packages/app/src/utils/environment.util.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 2 | const isBrowserCli = (import.meta as any).env === undefined 3 | 4 | const extractVersions = (version: string) => { 5 | const [major, minor, patch] = version.split('.').map(Number) 6 | return { major, minor, patch } 7 | } 8 | type FEEnvironment = 'release' | 'preview' | 'localhost' | 'unknown' 9 | 10 | const isReleaseSite = 11 | window.location.hostname === 'codeclimbers.io' || 12 | window.location.hostname.endsWith('.codeclimbers.io') || 13 | window.location.hostname === 'localhost:14400' 14 | 15 | const isPreviewSite = window.location.hostname.endsWith('.web.app') 16 | const isLocalhost = window.location.hostname === 'localhost' 17 | 18 | const getFEEnvironment = (): FEEnvironment => { 19 | if (isReleaseSite) return 'release' 20 | if (isPreviewSite) return 'preview' 21 | if (isLocalhost) return 'localhost' 22 | return 'unknown' 23 | } 24 | 25 | export { 26 | isBrowserCli, 27 | extractVersions, 28 | isReleaseSite, 29 | isPreviewSite, 30 | isLocalhost, 31 | getFEEnvironment, 32 | } 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 The Dev Craft LLC 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /packages/app/src/services/health.service.ts: -------------------------------------------------------------------------------- 1 | import { BASE_API_URL, useBetterQuery } from '.' 2 | import { apiRequest } from '../api/request' 3 | 4 | export const useGetHealth = ( 5 | { 6 | refetchInterval = 1000, 7 | retry = false, 8 | }: { 9 | refetchInterval?: number | false 10 | retry?: boolean 11 | } = {}, 12 | page: 'home' | 'install' = 'home', 13 | ) => { 14 | const queryFn = () => 15 | apiRequest({ 16 | url: `${BASE_API_URL}/health`, 17 | method: 'GET', 18 | }) 19 | return useBetterQuery({ 20 | queryKey: ['health', page], 21 | queryFn, 22 | refetchInterval, 23 | retry, 24 | select: (data) => { 25 | if (data.OK && data.app === 'codeclimbers') { 26 | return true 27 | } 28 | return false 29 | }, 30 | }) 31 | } 32 | 33 | export const useGetLocalVersion = () => { 34 | const queryFn = () => 35 | apiRequest({ 36 | url: `${BASE_API_URL}/health`, 37 | method: 'GET', 38 | }) 39 | return useBetterQuery({ 40 | queryKey: ['health'], 41 | queryFn, 42 | refetchOnWindowFocus: true, 43 | }) 44 | } 45 | -------------------------------------------------------------------------------- /bin/migrations/20241003221416_add_user.js: -------------------------------------------------------------------------------- 1 | const SQL = `--sql 2 | CREATE TABLE accounts_user ( 3 | id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, 4 | email varchar(255) UNIQUE, 5 | first_name varchar(255), 6 | last_name varchar(255), 7 | avatar_url varchar(255), 8 | created_at DATETIME DEFAULT CURRENT_TIMESTAMP, 9 | updated_at DATETIME DEFAULT CURRENT_TIMESTAMP 10 | ); 11 | 12 | ` 13 | 14 | const SQL_SETTINGS = `--sql 15 | CREATE TABLE IF NOT EXISTS accounts_user_settings ( 16 | id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, 17 | user_id INTEGER NOT NULL, 18 | weekly_report_type VARCHAR(255) NOT NULL DEFAULT '', 19 | created_at DATETIME DEFAULT CURRENT_TIMESTAMP, 20 | updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, 21 | FOREIGN KEY (user_id) REFERENCES accounts_user (id) ON DELETE CASCADE 22 | ); 23 | ` 24 | 25 | exports.up = async function (knex) { 26 | await knex.raw(SQL) 27 | await knex.raw(SQL_SETTINGS) 28 | return 29 | } 30 | 31 | exports.down = function (knex) { 32 | return 33 | // return knex.raw(`--sql 34 | // DROP TABLE accounts_user; 35 | // `) 36 | } 37 | -------------------------------------------------------------------------------- /packages/app/src/components/common/Icons/NotificationIcon.tsx: -------------------------------------------------------------------------------- 1 | import SvgIcon, { SvgIconProps } from '@mui/material/SvgIcon' 2 | 3 | // Thank you! https://github.com/mui/material-ui/issues/35218#issuecomment-1977984142 4 | export const NotificationIcon = (props: SvgIconProps) => ( 5 | 10 | 14 | 18 | 19 | ) 20 | -------------------------------------------------------------------------------- /packages/server/src/assets/startup.plist.ts: -------------------------------------------------------------------------------- 1 | import { BIN_PATH, HOME_DIR } from '../../utils/node.util' 2 | import * as path from 'node:path' 3 | 4 | export const plist = () => { 5 | try { 6 | const bashScriptPath = path.join(BIN_PATH, 'run.sh') 7 | return ` 8 | 9 | 10 | 11 | 12 | 13 | Label 14 | io.codeclimbers.plist 15 | 16 | ProgramArguments 17 | 18 | /bin/bash 19 | ${bashScriptPath} 20 | 21 | 22 | KeepAlive 23 | 24 | 25 | StandardOutPath 26 | ${HOME_DIR}/.codeclimbers/log.out 27 | StandardErrorPath 28 | ${HOME_DIR}/.codeclimbers/log.err 29 | 30 | 31 | 32 | ` 33 | } catch (error) { 34 | console.error(`Error creating plist declaration: ${error.message}`) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /packages/server/src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { MiddlewareConsumer, Module } from '@nestjs/common' 2 | import { V1Module } from './v1/v1.module' 3 | import { APP_FILTER, RouterModule } from '@nestjs/core' 4 | import { AllExceptionsFilter } from './filters/allExceptions.filter' 5 | import { RequestLoggerMiddleware } from './common/infrastructure/http/middleware/requestlogger.middleware' 6 | import { ServeStaticModule } from '@nestjs/serve-static' 7 | import { DbModule } from './v1/database/knex' 8 | import { APP_DIST_PATH } from '../utils/node.util' 9 | import { ScheduledTaskModule } from './common/scheduler.module' 10 | 11 | @Module({ 12 | imports: [ 13 | DbModule, 14 | V1Module, 15 | ScheduledTaskModule, 16 | RouterModule.register([ 17 | { 18 | path: '/api/v1', 19 | module: V1Module, 20 | }, 21 | ]), 22 | ServeStaticModule.forRoot({ 23 | rootPath: APP_DIST_PATH, 24 | }), 25 | ], 26 | providers: [ 27 | { 28 | provide: APP_FILTER, 29 | useClass: AllExceptionsFilter, 30 | }, 31 | ], 32 | }) 33 | export class AppModule { 34 | configure(consumer: MiddlewareConsumer): void { 35 | consumer.apply(RequestLoggerMiddleware).forRoutes('*') 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /packages/app/src/services/version.service.ts: -------------------------------------------------------------------------------- 1 | import { useBetterQuery } from '.' 2 | import { getFEEnvironment } from '../utils/environment.util' 3 | 4 | const THREE_MINUTES = 3 * 60 * 1_000 5 | 6 | export const useLatestVersion = (enabled = true) => { 7 | const environment = getFEEnvironment() 8 | 9 | const packageJsonUrl = 10 | environment === 'release' 11 | ? 'https://raw.githubusercontent.com/CodeClimbersIO/cli/release/package.json' 12 | : `https://raw.githubusercontent.com/CodeClimbersIO/cli/main/package.json` 13 | 14 | const queryFn = async () => { 15 | const res = await fetch(packageJsonUrl) 16 | 17 | if (!res.ok) { 18 | throw new Error('Failed to fetch latest version') 19 | } 20 | 21 | if (localStorage.getItem('latestVersion')) { 22 | // allows override for testing 23 | return localStorage.getItem('latestVersion') as string 24 | } 25 | 26 | const data = await res.json() 27 | 28 | return String(data.version) 29 | } 30 | return useBetterQuery({ 31 | queryKey: ['latestVersion'], 32 | queryFn, 33 | refetchInterval: THREE_MINUTES, 34 | staleTime: THREE_MINUTES, 35 | throwOnError: false, 36 | enabled, 37 | }) 38 | } 39 | -------------------------------------------------------------------------------- /packages/app/src/components/GameMakers/AIWeeklyReportList.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import { useGetAiWeeklyReports } from '../../api/platformServer/gameSettings.platformApi' 3 | import { Box, Chip } from '@mui/material' 4 | import { PerformanceReviewFax } from '../PerformanceReviewFax' 5 | 6 | export const AiWeeklyReportList = () => { 7 | const { data } = useGetAiWeeklyReports() 8 | const [selectedReport, setSelectedReport] = useState(0) 9 | return ( 10 | 18 | 19 | {data?.map((report, index) => ( 20 | setSelectedReport(index)} 25 | /> 26 | ))} 27 | 28 | 29 | 32 | 33 | 34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /packages/server/commands/stop/index.ts: -------------------------------------------------------------------------------- 1 | process.env.CODECLIMBERS_SERVER_APP_CONTEXT = 'cli' 2 | 3 | import { Command } from '@oclif/core' 4 | import { exec as _exec } from 'node:child_process' 5 | import util from 'node:util' 6 | import os from 'node:os' 7 | import find from 'find-process' 8 | import { PROCESS_NAME } from '../../utils/constants' 9 | // eslint-disable-next-line import/no-unresolved 10 | 11 | const exec = util.promisify(_exec) 12 | 13 | const IS_WINDOWS = os.platform() === 'win32' 14 | 15 | export default class Stop extends Command { 16 | static description = 'Stops the codeclimbers server on your machine' 17 | 18 | static examples = [`<%= config.bin %> <%= command.id %>`] 19 | 20 | async run(): Promise { 21 | const [instance] = await find('name', PROCESS_NAME) 22 | 23 | if (!instance) { 24 | this.error(`Could not find a running instance of ${PROCESS_NAME}`) 25 | } 26 | 27 | const { stdout, stderr } = await exec( 28 | IS_WINDOWS 29 | ? `taskkill /F /PID ${instance.pid}` 30 | : `kill -9 ${instance.pid}`, 31 | ) 32 | 33 | if (stderr) { 34 | this.error(stderr) 35 | } 36 | 37 | this.log(`Stopped ${PROCESS_NAME} - ${stdout || 'Success!'}`) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /packages/server/src/v1/dtos/createWakatimePulse.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsBoolean, IsNumber, IsOptional, IsString } from 'class-validator' 2 | 3 | export class CreateWakatimePulseDto { 4 | @IsOptional() 5 | @IsString() 6 | userId?: string 7 | 8 | @IsString() 9 | entity: string 10 | 11 | @IsString() 12 | type: string 13 | 14 | @IsOptional() 15 | @IsString() 16 | category?: string 17 | 18 | @IsString() 19 | project: string 20 | 21 | @IsString() 22 | branch: string 23 | 24 | @IsOptional() 25 | @IsString() 26 | language?: string 27 | 28 | @IsOptional() 29 | @IsBoolean() 30 | is_write?: boolean 31 | 32 | @IsOptional() 33 | @IsString() 34 | editor?: string 35 | 36 | @IsOptional() 37 | @IsString() 38 | operating_system?: string 39 | 40 | @IsOptional() 41 | @IsString() 42 | machine?: string 43 | 44 | @IsOptional() 45 | @IsString() 46 | user_agent?: string 47 | 48 | @IsNumber() 49 | time: number | string 50 | 51 | @IsOptional() 52 | @IsString() 53 | hash?: string 54 | 55 | @IsOptional() 56 | @IsString() 57 | origin?: string 58 | 59 | @IsOptional() 60 | @IsString() 61 | origin_id?: string 62 | 63 | @IsOptional() 64 | @IsString() 65 | description?: string 66 | } 67 | -------------------------------------------------------------------------------- /packages/app/src/layouts/ExtensionsLayout.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Typography } from '@mui/material' 2 | import { Outlet, useLocation, useNavigate } from 'react-router-dom' 3 | 4 | import { Logo } from '../components/common/Logo/Logo' 5 | import { getExtensionByRoute } from '../services/extensions.service' 6 | import { CodeClimbersButton } from '../components/common/CodeClimbersButton' 7 | 8 | interface Props { 9 | children?: React.ReactNode 10 | } 11 | 12 | export const ExtensionsLayout = ({ children }: Props) => { 13 | const navigate = useNavigate() 14 | const location = useLocation() 15 | const currentExtension = getExtensionByRoute(location.pathname) 16 | const title = currentExtension?.name 17 | const handleClick = () => { 18 | navigate('/') 19 | } 20 | 21 | return ( 22 | 23 | 24 | 29 | 30 | 31 | {title} 32 | 33 | {children || } 34 | 35 | ) 36 | } 37 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | # Code Climbers CLI 2 | 3 | A command-line interface tool for climbers to track their daily coding stats. Made up of 3 pieces. A Node server 4 | with a restful api that delivers a Single Page application and a CLI that allows the user to turn the server on and off 5 | and control some of their preferences. 6 | 7 | ## Features 8 | 9 | - Measure daily work 10 | - Measure deep work 11 | - View progress over time 12 | - Community mods like gamification or interruptions manager 13 | 14 | ## Quickstart 15 | 16 | ``` 17 | npx codeclimbers start 18 | ``` 19 | 20 | ## From Source 21 | 22 | ``` 23 | git clone https://github.com/CodeClimbersIO/cli.git && cd cli 24 | npm i 25 | npm run build 26 | npx codeclimbers start server 27 | ``` 28 | 29 | ## Prerequisites 30 | 31 | - Node.js 32 | 33 | ## Contributing 🚀 34 | 35 | Come help contribute to making it easier for coders to focus on doing what they love to do: Code! 36 | 37 | - [Contributing Guide](./docs/Contributing.md) 38 | 39 | ## Licensing 40 | 41 | This project is licensed under the MIT License. 42 | 43 | ## Thank you 44 | Big thanks to all of our contributors and to the open source community. Our extensions currently make use of [wakatime](https://github.com/wakatime) for time tracking functionality. Thank you! 45 | -------------------------------------------------------------------------------- /packages/server/src/types/activities.api.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace CodeClimbers { 2 | export interface WakatimePulseStatusDao { 3 | project: string 4 | language: string 5 | editor: string 6 | operatingSystem: string 7 | machine: string 8 | branch: string 9 | seconds: number | string 10 | maxHeartbeatTime: string 11 | minHeartbeatTime: string 12 | } 13 | 14 | export interface Source { 15 | name: string 16 | lastActive: string 17 | } 18 | 19 | export interface SourceWithMinutes { 20 | name: string 21 | lastActive: string 22 | minutes: number 23 | } 24 | 25 | export interface SiteWithMinutes { 26 | name: string 27 | minutes: number 28 | } 29 | 30 | export interface PerProjectTimeOverviewDB { 31 | minutes: number 32 | name: string 33 | } 34 | 35 | export interface PerProjectTimeAndCategoryOverviewDB { 36 | category: string 37 | minutes: number 38 | name: string 39 | } 40 | 41 | export interface EntityTimeOverviewDB { 42 | entity: string 43 | minutes: number 44 | } 45 | 46 | export interface CategoryTimeOverviewDB { 47 | category: string 48 | minutes: number 49 | } 50 | 51 | export interface DeepWorkPeriod { 52 | startDate: string 53 | endDate: string 54 | time: number 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /packages/server/src/v1/database/queries/getDeepWork.sql: -------------------------------------------------------------------------------- 1 | WITH get_periods AS ( 2 | select MIN(time) AS interval_start, 3 | COUNT(*) AS activity_count, 4 | (strftime('%s', time) / 120) AS interval_id 5 | from activities_pulse 6 | where time BETWEEN :startDate AND :endDate 7 | group by (strftime('%s', time) / 120) 8 | order by interval_id asc 9 | ), 10 | 11 | flagged AS ( 12 | SELECT *, 13 | (strftime('%s', interval_start) - strftime('%s', LAG(interval_start) OVER (ORDER BY interval_start)) <= 240) AS within_4_minutes 14 | FROM get_periods 15 | ), 16 | groups AS ( 17 | SELECT *, 18 | SUM(CASE WHEN within_4_minutes = 0 THEN 1 ELSE 0 END) OVER (ORDER BY interval_start) AS reset_group 19 | FROM flagged 20 | ), 21 | flow_states AS ( 22 | SELECT *, 23 | SUM(within_4_minutes) OVER (PARTITION BY reset_group ORDER BY interval_start) AS cumulative_within_4_minutes 24 | FROM groups 25 | ), 26 | flow_final AS ( 27 | 28 | select reset_group as flow_group, min(interval_start) as flow_start, (max(cumulative_within_4_minutes) + 1) * 2 as flow_time 29 | from flow_states 30 | group by flow_group 31 | ) 32 | 33 | select * from flow_final 34 | where flow_time > 14; -------------------------------------------------------------------------------- /packages/server/src/filters/allExceptions.filter.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ExceptionFilter, 3 | Catch, 4 | ArgumentsHost, 5 | HttpException, 6 | HttpStatus, 7 | Logger, 8 | } from '@nestjs/common' 9 | 10 | export type BEErrorReport = { 11 | customMessage?: string 12 | } & Error 13 | 14 | @Catch() 15 | export class AllExceptionsFilter implements ExceptionFilter { 16 | async catch(exception: unknown, host: ArgumentsHost) { 17 | const ctx = host.switchToHttp() 18 | const response = ctx.getResponse() 19 | 20 | const status = 21 | exception instanceof HttpException 22 | ? exception.getStatus() 23 | : HttpStatus.INTERNAL_SERVER_ERROR 24 | const message = 25 | exception instanceof HttpException 26 | ? exception.getResponse() 27 | : 'Internal server error' 28 | 29 | const errorReport: BEErrorReport = { 30 | message: typeof message === 'object' ? JSON.stringify(message) : message, 31 | name: exception instanceof Error ? exception.name : 'Error', 32 | stack: exception instanceof Error ? exception.stack : undefined, 33 | } 34 | if (status >= 500) { 35 | Logger.error(errorReport) 36 | // Sentry.captureException(exception) 37 | } else { 38 | Logger.debug(errorReport) 39 | } 40 | 41 | response.status(status).json(message) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /packages/server/src/v1/activities/wakatimeProxy.controller.ts: -------------------------------------------------------------------------------- 1 | import { Body, Controller, Get, Logger, Post } from '@nestjs/common' 2 | import { ActivitiesService } from './activities.service' 3 | import { CreateWakatimePulseDto } from '../dtos/createWakatimePulse.dto' 4 | 5 | @Controller('wakatime') 6 | export class WakatimeController { 7 | constructor(private readonly activitiesService: ActivitiesService) { 8 | this.activitiesService = activitiesService 9 | } 10 | @Get('users/current/statusbar/today') 11 | async getStatusBar(): Promise { 12 | const result = await this.activitiesService.getActivityStatusBar() 13 | return result 14 | } 15 | 16 | @Post('users/current/heartbeats') 17 | async createPulse(@Body() body: CreateWakatimePulseDto): Promise<{ 18 | Responses: number[][] 19 | }> { 20 | Logger.log(JSON.stringify(body), 'wakatimeProxy.controller') 21 | const result = await this.activitiesService.createPulse(body) 22 | return result 23 | } 24 | 25 | @Post('users/current/heartbeats.bulk') 26 | async createPulses(@Body() body: CreateWakatimePulseDto[]): Promise<{ 27 | Responses: number[][] 28 | }> { 29 | Logger.log(JSON.stringify(body), 'wakatimeProxy.controller') 30 | const result = await this.activitiesService.createPulses(body) 31 | return result 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /packages/app/src/api/platformServer/weeklyReport.platformApi.ts: -------------------------------------------------------------------------------- 1 | import { platformApiRequest } from '../request' 2 | 3 | import { PLATFORM_API_URL, useBetterQuery } from '..' 4 | import { weeklyReportKeys } from './keys' 5 | import { useMutation, useQueryClient } from '@tanstack/react-query' 6 | 7 | export const useGetAiWeeklyReport = (startOfWeek: string, email?: string) => { 8 | const queryFn = () => 9 | platformApiRequest({ 10 | url: `${PLATFORM_API_URL}/ai-weekly-report?email=${email}&startOfWeek=${startOfWeek}`, 11 | method: 'GET', 12 | }) 13 | return useBetterQuery({ 14 | queryKey: weeklyReportKeys.aiWeeklyReports(startOfWeek), 15 | queryFn, 16 | enabled: !!email, 17 | }) 18 | } 19 | 20 | export const useGenerateAiWeeklyReport = () => { 21 | const queryClient = useQueryClient() 22 | const mutationFn = (body: { 23 | email: string 24 | startOfWeek: string 25 | weeklyReport: CodeClimbers.WeeklyScores 26 | }) => { 27 | return platformApiRequest({ 28 | url: `${PLATFORM_API_URL}/ai-weekly-report`, 29 | method: 'POST', 30 | body, 31 | }) 32 | } 33 | return useMutation({ 34 | mutationFn, 35 | onSuccess: (_, variables) => { 36 | queryClient.invalidateQueries({ 37 | queryKey: weeklyReportKeys.aiWeeklyReports(variables.startOfWeek), 38 | }) 39 | }, 40 | }) 41 | } 42 | -------------------------------------------------------------------------------- /packages/app/src/utils/style/rgbAnimation.ts: -------------------------------------------------------------------------------- 1 | export const rgbAnimatedBorder = { 2 | position: 'relative', 3 | '&::before': { 4 | content: '""', 5 | position: 'absolute', 6 | inset: 0, 7 | borderRadius: 'inherit', 8 | padding: '2px', 9 | background: 10 | 'linear-gradient(90deg, #ff0000, #ff7300, #fffb00, #48ff00, #00ffd5, #002bff, #7a00ff, #ff00c8, #ff0000)', 11 | backgroundSize: '200% 100%', 12 | animation: 'moveGradient 3s linear infinite', 13 | WebkitMask: 14 | 'linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)', 15 | WebkitMaskComposite: 'xor', 16 | maskComposite: 'exclude', 17 | pointerEvents: 'none', 18 | }, 19 | '@keyframes moveGradient': { 20 | '0%': { backgroundPosition: '0% 50%' }, 21 | '100%': { backgroundPosition: '200% 50%' }, 22 | }, 23 | } 24 | 25 | export const rgbAnimatedText = { 26 | background: 27 | 'linear-gradient(90deg, #ff0000, #ff7300, #fffb00, #48ff00, #00ffd5, #002bff, #7a00ff, #ff00c8, #ff0000)', 28 | backgroundSize: '200% 100%', 29 | animation: 'moveGradient 10s linear infinite', 30 | WebkitBackgroundClip: 'text', 31 | WebkitTextFillColor: 'transparent', 32 | backgroundClip: 'text', 33 | color: 'transparent', 34 | '@keyframes moveGradient': { 35 | '0%': { backgroundPosition: '0% 50%' }, 36 | '100%': { backgroundPosition: '200% 50%' }, 37 | }, 38 | } 39 | -------------------------------------------------------------------------------- /packages/app/src/components/Home/Source/SourceTimeChart.tsx: -------------------------------------------------------------------------------- 1 | import { LinearProgress, Typography, useTheme } from '@mui/material' 2 | import Grid2 from '@mui/material/Unstable_Grid2/Grid2' 3 | 4 | interface SourceTimeChartProps { 5 | color: string 6 | time: string 7 | progress: number 8 | } 9 | 10 | export const SourceTimeChart = ({ 11 | color, 12 | progress, 13 | time, 14 | }: SourceTimeChartProps) => { 15 | const theme = useTheme() 16 | 17 | return ( 18 | 25 | 26 | 100 ? 100 : progress} 29 | sx={{ 30 | alignSelf: 'center', 31 | width: '100%', 32 | backgroundColor: 'transparent', 33 | height: 12, 34 | '& .MuiLinearProgress-bar': { 35 | bottom: 0, 36 | top: 'auto', 37 | backgroundColor: color, 38 | height: 12, 39 | }, 40 | }} 41 | /> 42 | 43 | 44 | 45 | {time} 46 | 47 | 48 | 49 | ) 50 | } 51 | -------------------------------------------------------------------------------- /packages/app/src/repos/queries/deepWork.query.ts: -------------------------------------------------------------------------------- 1 | export const deepWorkSql = (startDate: string, endDate: string) => ` 2 | WITH get_periods AS ( 3 | select MIN(time) AS interval_start, 4 | COUNT(*) AS activity_count, 5 | (strftime('%s', time) / 120) AS interval_id 6 | from activities_pulse 7 | where time BETWEEN '${startDate}' AND '${endDate}' 8 | group by (strftime('%s', time) / 120) 9 | order by interval_id asc 10 | ), 11 | 12 | flagged AS ( 13 | SELECT *, 14 | (strftime('%s', interval_start) - strftime('%s', LAG(interval_start) OVER (ORDER BY interval_start)) <= 240) AS within_4_minutes 15 | FROM get_periods 16 | ), 17 | groups AS ( 18 | SELECT *, 19 | SUM(CASE WHEN within_4_minutes = 0 THEN 1 ELSE 0 END) OVER (ORDER BY interval_start) AS reset_group 20 | FROM flagged 21 | ), 22 | flow_states AS ( 23 | SELECT *, 24 | SUM(within_4_minutes) OVER (PARTITION BY reset_group ORDER BY interval_start) AS cumulative_within_4_minutes 25 | FROM groups 26 | ), 27 | flow_final AS ( 28 | 29 | select reset_group as flow_group, min(interval_start) as flow_start, (max(cumulative_within_4_minutes) + 1) * 2 as flow_time 30 | from flow_states 31 | group by flow_group 32 | ) 33 | 34 | select * from flow_final 35 | where flow_time > 14; 36 | ` 37 | -------------------------------------------------------------------------------- /packages/app/src/hooks/useSelectedDate.ts: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs' 2 | import { create } from 'zustand' 3 | 4 | const appStore = create<{ 5 | selectedDate: dayjs.Dayjs 6 | setSelectedDate: (date: dayjs.Dayjs) => void 7 | }>((set) => ({ 8 | selectedDate: dayjs().startOf('day'), 9 | setSelectedDate: (date) => set({ selectedDate: date }), 10 | })) 11 | 12 | const useSelectedDate = () => { 13 | const { selectedDate, setSelectedDate } = appStore() 14 | return { selectedDate, setSelectedDate } 15 | } 16 | 17 | const weekAppStore = create<{ 18 | selectedDate: dayjs.Dayjs 19 | setSelectedDate: (date: dayjs.Dayjs) => void 20 | isCurrentWeek: () => boolean 21 | isMonthAgo: () => boolean 22 | }>((set, get) => ({ 23 | selectedDate: dayjs().startOf('week').subtract(1, 'week').add(1, 'day'), 24 | setSelectedDate: (date) => set({ selectedDate: date }), 25 | isCurrentWeek: () => { 26 | const state = get() 27 | return state.selectedDate.isSame(dayjs().startOf('isoWeek'), 'week') 28 | }, 29 | isMonthAgo: () => { 30 | const state = get() 31 | return state.selectedDate.isBefore(dayjs().subtract(1, 'month'), 'day') 32 | }, 33 | })) 34 | 35 | const useSelectedWeekDate = () => { 36 | const { selectedDate, setSelectedDate, isCurrentWeek, isMonthAgo } = 37 | weekAppStore() 38 | return { selectedDate, setSelectedDate, isCurrentWeek, isMonthAgo } 39 | } 40 | 41 | export { useSelectedDate, useSelectedWeekDate } 42 | -------------------------------------------------------------------------------- /packages/app/src/api/browser/repos/queries/deepWork.query.ts: -------------------------------------------------------------------------------- 1 | export const deepWorkSql = (startDate: string, endDate: string) => ` 2 | WITH get_periods AS ( 3 | select MIN(time) AS interval_start, 4 | COUNT(*) AS activity_count, 5 | (strftime('%s', time) / 120) AS interval_id 6 | from activities_pulse 7 | where time BETWEEN '${startDate}' AND '${endDate}' 8 | group by (strftime('%s', time) / 120) 9 | order by interval_id asc 10 | ), 11 | 12 | flagged AS ( 13 | SELECT *, 14 | (strftime('%s', interval_start) - strftime('%s', LAG(interval_start) OVER (ORDER BY interval_start)) <= 240) AS within_4_minutes 15 | FROM get_periods 16 | ), 17 | groups AS ( 18 | SELECT *, 19 | SUM(CASE WHEN within_4_minutes = 0 THEN 1 ELSE 0 END) OVER (ORDER BY interval_start) AS reset_group 20 | FROM flagged 21 | ), 22 | flow_states AS ( 23 | SELECT *, 24 | SUM(within_4_minutes) OVER (PARTITION BY reset_group ORDER BY interval_start) AS cumulative_within_4_minutes 25 | FROM groups 26 | ), 27 | flow_final AS ( 28 | 29 | select reset_group as flow_group, min(interval_start) as flow_start, (max(cumulative_within_4_minutes) + 1) * 2 as flow_time 30 | from flow_states 31 | group by flow_group 32 | ) 33 | 34 | select * from flow_final 35 | where flow_time > 14; 36 | ` 37 | -------------------------------------------------------------------------------- /packages/app/src/assets/icons/block.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /packages/app/src/components/PerformanceReviewFax.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Divider, Stack, Typography } from '@mui/material' 2 | import { BossImage } from './common/Icons/BossImage' 3 | 4 | interface Props { 5 | performanceReview: string 6 | } 7 | 8 | export const PerformanceReviewFax = ({ performanceReview }: Props) => { 9 | return ( 10 | theme.palette.common.white, 13 | color: (theme) => theme.palette.common.black, 14 | p: 6, 15 | }} 16 | > 17 | 18 | 19 | 20 | 21 | 25 | From: Timothy Brother 26 | 27 | 28 | Subject: Comments on Your Week 29 | 30 | 31 | 32 | 35 | 36 | 40 | {performanceReview} 41 | 42 | 43 | ) 44 | } 45 | -------------------------------------------------------------------------------- /packages/server/src/v1/dtos/pagination.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsEnum, IsNumber, IsOptional, Max, Min } from 'class-validator' 2 | 3 | export interface PageMetaDtoParameters { 4 | pageOptionsDto: PageOptionsDto 5 | count: number 6 | } 7 | export enum Sort { 8 | ASC = 'asc', 9 | DESC = 'desc', 10 | } 11 | 12 | export class PageOptionsDto { 13 | @IsEnum(Sort) 14 | @IsOptional() 15 | readonly sort?: Sort = Sort.DESC 16 | 17 | @IsNumber() 18 | @Min(1) 19 | @IsOptional() 20 | readonly page?: number = 1 21 | 22 | @IsNumber() 23 | @Min(1) 24 | @Max(50) 25 | @IsOptional() 26 | readonly limit?: number = 10 27 | } 28 | 29 | export class PageMetaDto { 30 | readonly page: number 31 | 32 | readonly limit: number 33 | 34 | readonly count: number 35 | 36 | readonly pageCount: number 37 | 38 | readonly hasPreviousPage: boolean 39 | 40 | readonly hasNextPage: boolean 41 | 42 | constructor({ pageOptionsDto, count }: PageMetaDtoParameters) { 43 | this.page = pageOptionsDto.page 44 | this.limit = pageOptionsDto.limit 45 | this.count = count 46 | this.pageCount = Math.ceil(this.count / this.limit) 47 | this.hasPreviousPage = this.page > 1 48 | this.hasNextPage = this.page < this.pageCount 49 | } 50 | } 51 | 52 | export class PageDto { 53 | readonly data: T[] 54 | 55 | readonly meta: PageMetaDto 56 | 57 | constructor(data: T[], meta: PageMetaDto) { 58 | this.data = data 59 | this.meta = meta 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /packages/server/src/v1/startup/startupService.factory.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common' 2 | import { DarwinStartupService } from './darwinStartup.service' 3 | import { UnsupportedStartupService } from './unsupportedStartup.service' 4 | import { WindowsStartupService } from './windowsStartup.service' 5 | import { LinuxStartupService } from './linuxStartup.service' 6 | 7 | @Injectable() 8 | export class StartupServiceFactory { 9 | constructor( 10 | private readonly darwinStartupService: DarwinStartupService, 11 | private readonly windowsStartupService: WindowsStartupService, 12 | private readonly linuxStartupService: LinuxStartupService, 13 | private readonly unsupportedStartupService: UnsupportedStartupService, 14 | ) {} 15 | 16 | getStartupService(): CodeClimbers.StartupService { 17 | const os = process.platform 18 | switch (os) { 19 | case 'darwin': 20 | return this.darwinStartupService 21 | case 'win32': 22 | return this.windowsStartupService 23 | case 'linux': 24 | return this.linuxStartupService 25 | default: 26 | return this.unsupportedStartupService 27 | } 28 | } 29 | 30 | static buildStartupService(): CodeClimbers.StartupService { 31 | return new StartupServiceFactory( 32 | new DarwinStartupService(), 33 | new WindowsStartupService(), 34 | new LinuxStartupService(), 35 | new UnsupportedStartupService(), 36 | ).getStartupService() 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /packages/app/src/api/browser/keys.ts: -------------------------------------------------------------------------------- 1 | export const pulseKeys = { 2 | pulse: ['pulse'] as const, 3 | latestPulses: ['pulse', 'latest-pulses'] as const, 4 | sources: ['sources'] as const, 5 | weekOverview: (date: string) => ['weekOverview', date] as const, 6 | deepWork: (startDate: string, endDate: string) => 7 | ['deepWork', startDate, endDate] as const, 8 | projectsTimeByRangeAndCategory: (startDate: string, endDate: string) => 9 | ['projectsTimeByRangeAndCategory', startDate, endDate] as const, 10 | socialMediaTimeByRange: (startDate: string, endDate: string) => 11 | ['socialMediaTimeByRange', startDate, endDate] as const, 12 | categoryTimeOverviewV2: (startDate: string, endDate: string) => 13 | ['categoryTimeOverviewV2', startDate, endDate] as const, 14 | totalTimeByRange: (startDate: string, endDate: string) => 15 | ['totalTimeByRange', startDate, endDate] as const, 16 | sourcesMinutes: (startDate: string, endDate: string) => 17 | ['sourcesMinutes', startDate, endDate] as const, 18 | sitesMinutes: (startDate: string, endDate: string) => 19 | ['sitesMinutes', startDate, endDate] as const, 20 | perProjectOverviewTopThree: (startDate: string, endDate: string) => 21 | ['perProjectTimeOverview', 'topThree', startDate, endDate] as const, 22 | } 23 | 24 | export const userKeys = { 25 | user: ['user'] as const, 26 | } 27 | 28 | export const reportKeys = { 29 | weeklyScores: (startDate: string) => ['weeklyScores', startDate] as const, 30 | } 31 | -------------------------------------------------------------------------------- /eslint-plugin-codeclimbers/index.js: -------------------------------------------------------------------------------- 1 | // custom-eslint-plugin.js 2 | module.exports = { 3 | rules: { 4 | 'use-code-climbers-button': { 5 | meta: { 6 | type: 'suggestion', 7 | docs: { 8 | description: 9 | 'Enforce using CodeClimbersButton for proper event tracking', 10 | category: 'Best Practices', 11 | recommended: true, 12 | }, 13 | fixable: null, 14 | }, 15 | create(context) { 16 | return { 17 | Identifier(node) { 18 | if ( 19 | node.name === 'button' || 20 | node.name === 'Button' || 21 | node.name === 'IconButton' 22 | ) { 23 | context.report({ 24 | node, 25 | message: 26 | 'Please use the CodeClimbersButton or CodeClimbersIconButton so that events are properly tracked', 27 | }) 28 | } 29 | }, 30 | JSXIdentifier(node) { 31 | if ( 32 | node.name === 'button' || 33 | node.name === 'Button' || 34 | node.name === 'IconButton' 35 | ) { 36 | context.report({ 37 | node, 38 | message: 39 | 'Please use the CodeClimbersButton or CodeClimbersIconButton so that events are properly tracked', 40 | }) 41 | } 42 | }, 43 | } 44 | }, 45 | }, 46 | }, 47 | } 48 | -------------------------------------------------------------------------------- /docs/Architecture.md: -------------------------------------------------------------------------------- 1 | # Architecture 2 | 3 | The information flow generally speaking looks like this: 4 | 5 | ``` 6 | extension -> wakatime cli -> codeclimbers cli -> sqlite db 7 | ``` 8 | 9 | We are making use of the wakatime cli as it is a great open source project that helps us to prototype what we are looking to build. 10 | 11 | ## Architecture Video 12 | 13 | Took a couple minutes to talk through some of the architecture of codeclimbers 14 | [Loom Video](https://www.loom.com/share/36a7fc061fa3415aac550c4f63a1618e?sid=fee45422-4c38-4179-ac3f-7805cb5f81d5) 15 | 16 | ## Information Flow 17 | 18 | ![Information Diagram](./information_diagram.png) 19 | 20 | ## Activity | Pulse | Heartbeat 21 | 22 | The definition of the fields for an activity can be found in [pulse.d.ts](../packages/server/src/v1/pulse/infrastructure/database/models/pulse.d.ts) 23 | 24 | ## Vocab 25 | 26 | - `Heartbeat, pulse or activity`: An individual record that represents a user using a particular application. They contain information like the file or url, the category of the action, and the git project or branch being worked on. The 3 terms are used interchangeably in the project. Wakatime refers to them as heartbeats. 27 | - `extension or plugin`: installed to applicaitons like VSCode, Chrome, etc... that capture user activity and send it to the cli. 28 | - `wakatime`: open source platform for tracking time. We leverage it to help us build our version more quickly as we can focus on what is unique to us rather than time tracking 29 | -------------------------------------------------------------------------------- /packages/app/src/components/common/PlainHeader.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Typography } from '@mui/material' 2 | 3 | import { Logo } from '../common/Logo/Logo' 4 | import { useNavigate } from 'react-router-dom' 5 | import { Close } from '@mui/icons-material' 6 | import { CodeClimbersButton } from './CodeClimbersButton' 7 | 8 | type Props = { 9 | title: string 10 | } 11 | 12 | const PlainHeader = ({ title }: Props) => { 13 | const navigate = useNavigate() 14 | 15 | const handleClick = () => { 16 | navigate('/') 17 | } 18 | 19 | return ( 20 | <> 21 | 25 | 26 | 31 | 32 | 33 | 34 | 35 | 42 | {title} 43 | 44 | 45 | 46 | 47 | 48 | ) 49 | } 50 | 51 | export { PlainHeader } 52 | -------------------------------------------------------------------------------- /packages/app/src/components/ContributorsPage.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Typography } from '@mui/material' 2 | import { SimpleInfoCard, SimpleInfoCardProps } from './common/SimpleInfoCard' 3 | import Grid2 from '@mui/material/Unstable_Grid2' 4 | import { getContributors } from '../services/contributors.service' 5 | import { PlainHeader } from './common/PlainHeader' 6 | import { useTheme } from '@mui/material/styles' 7 | 8 | export const ContributorsPage = () => { 9 | const theme = useTheme() 10 | const contributors = getContributors() 11 | const contributorCardData: SimpleInfoCardProps[] = contributors.map( 12 | (contributor) => ({ 13 | title: contributor.name, 14 | subTitle: contributor.subTitle, 15 | subjectUrl: contributor.profileUrl, 16 | callout: '', 17 | href: contributor.githubUrl, 18 | }), 19 | ) 20 | return ( 21 | 22 | 23 | 24 | 25 | Thank you to all our amazing contributors! 26 | 27 | 28 | {contributorCardData.map((contributor) => ( 29 | 30 | 34 | 35 | ))} 36 | 37 | 38 | 39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /packages/server/src/types/wakatimeProxy.api.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace CodeClimbers { 2 | interface ActivitiesDetail { 3 | digital: string 4 | hours: number 5 | minutes: number 6 | name?: string 7 | percent?: number 8 | seconds?: number 9 | text: string 10 | total_seconds: number 11 | } 12 | export interface ActivitiesStatusBarData { 13 | categories?: ActivitiesDetail[] 14 | dependencies?: ActivitiesDetail[] 15 | editors?: ActivitiesDetail[] 16 | languages?: ActivitiesDetail[] 17 | machines?: ActivitiesDetail[] 18 | operating_systems?: ActivitiesDetail[] 19 | projects?: ActivitiesDetail[] 20 | branches?: ActivitiesDetail[] | null 21 | entities?: ActivitiesDetail[] | null 22 | grand_total?: ActivitiesDetail 23 | range?: { 24 | date: string 25 | end: string 26 | start: string 27 | text: string 28 | timezone: string 29 | } 30 | } 31 | export interface ActivitiesStatusBar { 32 | cached_at: string 33 | data: ActivitiesStatusBarData 34 | } 35 | 36 | export interface CreateWakatimePulseDto { 37 | userId?: string 38 | entity: string 39 | type: string 40 | category?: string 41 | project: string 42 | branch: string 43 | language?: string 44 | is_write?: boolean 45 | editor?: string 46 | operating_system?: string 47 | machine?: string 48 | user_agent?: string 49 | time: number | string 50 | hash?: string 51 | origin?: string 52 | origin_id?: string 53 | description?: string 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /packages/app/src/components/common/Icons/DiscordIcon.tsx: -------------------------------------------------------------------------------- 1 | import SvgIcon, { SvgIconProps } from '@mui/material/SvgIcon' 2 | 3 | // Thank you! https://github.com/mui/material-ui/issues/35218#issuecomment-1977984142 4 | export const DiscordIcon = (props: SvgIconProps) => ( 5 | 6 | 7 | 8 | ) 9 | -------------------------------------------------------------------------------- /packages/app/src/components/common/GithubProfileImage.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react' 2 | import { Avatar, Skeleton, Typography } from '@mui/material' 3 | 4 | interface Props { 5 | url: string 6 | size?: number 7 | } 8 | 9 | interface Profile { 10 | avatar_url: string 11 | login: string 12 | } 13 | 14 | export const GitHubProfileImage = ({ url, size = 60 }: Props) => { 15 | const [profile, setProfile] = useState(null) 16 | const [error, setError] = useState(null) 17 | 18 | useEffect(() => { 19 | const fetchProfile = async () => { 20 | try { 21 | // Extract username from URL 22 | const username = url.split('/').pop() 23 | const response = await fetch(`https://api.github.com/users/${username}`) 24 | if (!response.ok) { 25 | throw new Error() 26 | } 27 | const data = await response.json() 28 | setProfile(data) 29 | } catch (err) { 30 | if (err instanceof Error) { 31 | setError(err.message) 32 | } else { 33 | setError('An unknown error occurred') 34 | } 35 | } 36 | } 37 | 38 | fetchProfile() 39 | }, [url]) 40 | 41 | if (error) { 42 | return Error: {error} 43 | } 44 | 45 | if (!profile) { 46 | return 47 | } 48 | 49 | return ( 50 | 55 | ) 56 | } 57 | -------------------------------------------------------------------------------- /packages/app/src/components/WeeklyReports/EmptyState.tsx: -------------------------------------------------------------------------------- 1 | import { Card, CardContent, Stack, Typography, useTheme } from '@mui/material' 2 | import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined' 3 | import { CodeClimbersLink } from '../common/CodeClimbersLink' 4 | export const EmptyState = () => { 5 | const theme = useTheme() 6 | return ( 7 | 16 | 23 | 24 | 25 | 26 | No Data Available 27 | 28 | 29 | We don't see any data for this category this week. But you still get 30 | some points for showing up :) 31 | 32 | 40 | Learn more about points 41 | 42 | 43 | 44 | 45 | ) 46 | } 47 | -------------------------------------------------------------------------------- /packages/app/src/api/browser/pulse.api.ts: -------------------------------------------------------------------------------- 1 | import { Dayjs } from 'dayjs' 2 | import { useBetterQuery } from '..' 3 | import { pulseKeys } from './keys' 4 | import { 5 | getDeepWorkBetweenDates, 6 | getProjectsTimeByRangeAndCategory, 7 | } from './services/pulse.service' 8 | 9 | interface DeepWorkPeriod { 10 | startDate: string 11 | endDate: string 12 | time: number 13 | } 14 | 15 | const useDeepWorkV2 = (selectedStartDate: Dayjs, selectedEndDate: Dayjs) => { 16 | const startDate = selectedStartDate?.startOf('day').toISOString() 17 | const endDate = selectedEndDate?.endOf('day').toISOString() 18 | 19 | const queryFn = () => 20 | getDeepWorkBetweenDates(selectedStartDate, selectedEndDate) 21 | 22 | return useBetterQuery({ 23 | queryKey: pulseKeys.deepWork(startDate, endDate), 24 | queryFn, 25 | enabled: !!selectedStartDate && !!selectedEndDate, 26 | }) 27 | } 28 | 29 | const useProjectsTimeByRangeAndCategory = ( 30 | selectedStartDate: Dayjs, 31 | selectedEndDate: Dayjs, 32 | ) => { 33 | const startDate = selectedStartDate?.startOf('day').toISOString() 34 | const endDate = selectedEndDate?.endOf('day').toISOString() 35 | 36 | const queryFn = () => 37 | getProjectsTimeByRangeAndCategory(selectedStartDate, selectedEndDate) 38 | 39 | return useBetterQuery({ 40 | queryKey: pulseKeys.projectsTimeByRangeAndCategory(startDate, endDate), 41 | queryFn, 42 | enabled: !!selectedStartDate && !!selectedEndDate, 43 | }) 44 | } 45 | 46 | export { useDeepWorkV2, useProjectsTimeByRangeAndCategory } 47 | -------------------------------------------------------------------------------- /packages/app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@codeclimbers/app", 3 | "productName": "CLI App", 4 | "version": "0.0.1", 5 | "description": "CLI App", 6 | "licenses": [ 7 | { 8 | "type": "MIT", 9 | "url": "https://opensource.org/licenses/MIT" 10 | } 11 | ], 12 | "scripts": { 13 | "copy": "copyfiles -u 1 \"src/**/*.sql\" dist", 14 | "build": "vite build", 15 | "start": "vite", 16 | "lint": "eslint --ext .ts,.tsx .", 17 | "test": "jest", 18 | "test:watch": "jest --watch", 19 | "db:migrate": "knex migrate:latest", 20 | "db:migrate:make": "knex migrate:make" 21 | }, 22 | "dependencies": { 23 | "@emotion/react": "^11.11.4", 24 | "@emotion/styled": "^11.11.5", 25 | "@fontsource/roboto": "^5.0.13", 26 | "@mui/icons-material": "^5.16.4", 27 | "@mui/lab": "^5.0.0-alpha.170", 28 | "@mui/material": "^5.16.4", 29 | "@tanstack/react-query": "^5.48.0", 30 | "dayjs": "^1.11.12", 31 | "kysely": "^0.27.4", 32 | "posthog-js": "^1.165.1", 33 | "react": "^18.3.1", 34 | "react-router-dom": "^6.24.0", 35 | "uuid": "^10.0.0", 36 | "zustand": "^5.0.0-rc.2" 37 | }, 38 | "devDependencies": { 39 | "@types/react": "^18.3.3", 40 | "@types/react-dom": "^18.3.0", 41 | "@typescript-eslint/eslint-plugin": "^6.0.0", 42 | "@vitejs/plugin-react": "^4.3.1", 43 | "copyfiles": "^2.4.1", 44 | "typescript": "^5.5.3", 45 | "typescript-eslint": "^7.13.0", 46 | "vite": "^5.3.1" 47 | }, 48 | "keywords": [], 49 | "author": { 50 | "name": "Paul Hovley", 51 | "email": "rphovley@gmail.com" 52 | }, 53 | "license": "MIT" 54 | } 55 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es6: true, 5 | node: true, 6 | }, 7 | parser: "@typescript-eslint/parser", 8 | plugins: ["@typescript-eslint", "codeclimbers"], 9 | settings: { 10 | "import/resolver": { 11 | typescript: {}, 12 | node: { 13 | extensions: [".js", ".ts"], 14 | paths: ["public"], 15 | }, 16 | alias: { 17 | map: [["@app", "./packages/app/src"]], 18 | }, 19 | }, 20 | }, 21 | extends: [ 22 | "eslint:recommended", 23 | "plugin:@typescript-eslint/eslint-recommended", 24 | "plugin:@typescript-eslint/recommended", 25 | "plugin:import/recommended", 26 | "plugin:import/typescript", 27 | "plugin:prettier/recommended", 28 | ], 29 | ignorePatterns: [".eslintrc.js", "migrations", "dist"], 30 | rules: { 31 | "prettier/prettier": [ 32 | "error", 33 | { semi: false, singleQuote: true, endOfLine: "auto" }, 34 | ], 35 | "@typescript-eslint/interface-name-prefix": "off", 36 | "@typescript-eslint/explicit-function-return-type": "off", 37 | "@typescript-eslint/explicit-module-boundary-types": "off", 38 | "@typescript-eslint/no-namespace": "off", 39 | "@typescript-eslint/no-explicit-any": "error", 40 | "codeclimbers/use-code-climbers-button": "error", 41 | "prefer-arrow-callback": "warn", 42 | "func-style": ["warn", "expression", { "allowArrowFunctions": true }], 43 | "import/no-default-export": "error", 44 | }, 45 | overrides: [ 46 | { 47 | files: ["packages/server/commands/**/*.ts"], 48 | rules: { 49 | "import/no-default-export": "off" 50 | } 51 | } 52 | ] 53 | }; 54 | -------------------------------------------------------------------------------- /packages/server/utils/codeClimberErrors.ts: -------------------------------------------------------------------------------- 1 | import { ValidationError } from 'class-validator' 2 | 3 | /** 4 | * Custom Errors are used when a developer wants the error to be displayed to the end user 5 | * All other errors are reported internally 6 | */ 7 | export class CodeClimberError extends Error { 8 | public message!: string 9 | public status: number 10 | 11 | public constructor(message: string, status = 500) { 12 | super(message) 13 | this.name = this.constructor.name 14 | Error.captureStackTrace(this, this.constructor) 15 | this.status = status 16 | } 17 | } 18 | 19 | export namespace CodeClimberError { 20 | class ValidationErr extends CodeClimberError { 21 | validationErrors?: ValidationError[] 22 | public constructor(message: string, validationErrors?: ValidationError[]) { 23 | super(message, 422) 24 | this.validationErrors = validationErrors 25 | } 26 | } 27 | export class InvalidBody extends ValidationErr { 28 | constructor(validationErrors: ValidationError[], message?: string) { 29 | super(message || 'Expected request body was invalid', validationErrors) 30 | } 31 | } 32 | export class LocalApiKeyNotSet extends CodeClimberError { 33 | constructor(message?: string) { 34 | super(message || 'Local API key not set', 404) 35 | } 36 | } 37 | 38 | export class LocalApiKeyUnavailable extends CodeClimberError { 39 | constructor(message?: string) { 40 | super(message || 'Local API key not available', 403) 41 | } 42 | } 43 | 44 | export class ApiKeyInvalid extends CodeClimberError { 45 | constructor(message?: string) { 46 | super(message || 'API key is invalid', 401) 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /packages/app/src/assets/source-logos/linear.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /packages/app/src/components/Home/Source/SiteRow.tsx: -------------------------------------------------------------------------------- 1 | import { Stack, Typography, useTheme } from '@mui/material' 2 | 3 | import { minutesToHours } from '../Time/utils' 4 | import { SourceTimeChart } from './SourceTimeChart' 5 | import { SiteDetails } from '../../../utils/supportedSites' 6 | import { typeColors } from '../../../utils/categories' 7 | 8 | interface SiteRowProps { 9 | site: SiteDetails 10 | minutes: number 11 | } 12 | 13 | export const SiteRow = ({ site, minutes }: SiteRowProps) => { 14 | const theme = useTheme() 15 | const compareTime = 90 16 | 17 | const sourceCategoryColor = 18 | typeColors(theme).find((typeColor) => typeColor.type === site.type) 19 | ?.color ?? theme.palette.graphColors.blue 20 | 21 | return ( 22 | 28 | 29 | {site.logo && ( 30 | {site.displayName 35 | )} 36 | {site.icon && site.icon} 37 | 38 | 39 | {site.displayName} 40 | 41 | {minutes > 0 && ( 42 | 47 | )} 48 | 49 | 50 | 51 | ) 52 | } 53 | -------------------------------------------------------------------------------- /packages/app/src/components/common/Icons/BlockIcon.tsx: -------------------------------------------------------------------------------- 1 | import SvgIcon, { SvgIconProps } from '@mui/material/SvgIcon' 2 | 3 | // Thank you! https://github.com/mui/material-ui/issues/35218#issuecomment-1977984142 4 | export const BlockIcon = (props: SvgIconProps) => ( 5 | 10 | 14 | 15 | ) 16 | -------------------------------------------------------------------------------- /packages/app/src/utils/db.util.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Kysely, 3 | PostgresAdapter, 4 | DummyDriver, 5 | PostgresIntrospector, 6 | PostgresQueryCompiler, 7 | CompiledQuery, 8 | } from 'kysely' 9 | 10 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 11 | type Database = Record 12 | 13 | export const db = new Kysely({ 14 | dialect: { 15 | createAdapter: () => new PostgresAdapter(), 16 | createDriver: () => new DummyDriver(), 17 | createIntrospector: (db) => new PostgresIntrospector(db), 18 | createQueryCompiler: () => new PostgresQueryCompiler(), 19 | }, 20 | }) 21 | 22 | // Extend the CompiledQuery interface 23 | declare module 'kysely' { 24 | interface CompiledQuery { 25 | sqlWithBindings(): string 26 | } 27 | } 28 | 29 | // Implement the sqlWithBindings method 30 | export const sqlWithBindings = (compiledQuery: CompiledQuery): string => { 31 | let sql = compiledQuery.sql 32 | const parameters = compiledQuery.parameters 33 | 34 | // Replace each parameter placeholder with its corresponding value 35 | parameters.forEach((param, index) => { 36 | const placeholder = `$${index + 1}` 37 | let replacement: string 38 | 39 | if (param === null) { 40 | replacement = 'NULL' 41 | } else if (typeof param === 'string') { 42 | replacement = `'${param.replace(/'/g, "''")}'` // Escape single quotes 43 | } else if (typeof param === 'number' || typeof param === 'boolean') { 44 | replacement = param.toString() 45 | } else if (param instanceof Date) { 46 | replacement = `'${param.toISOString()}'` 47 | } else { 48 | replacement = `'${JSON.stringify(param)}'` 49 | } 50 | 51 | sql = sql.replace(placeholder, replacement) 52 | }) 53 | 54 | return sql 55 | } 56 | -------------------------------------------------------------------------------- /packages/server/utils/__tests__/localAuth.util.test.ts: -------------------------------------------------------------------------------- 1 | // TODO: add tests for localAuth.util.ts 2 | 3 | import { removeIniFile } from '../ini.util' 4 | import { getLocalApiKey, isValidLocalApiKey } from '../localAuth.util' 5 | import { existsSync } from 'fs' 6 | import { CODE_CLIMBER_INI_PATH } from '../node.util' 7 | 8 | describe('localAuth.util', () => { 9 | beforeEach(async () => { 10 | if (existsSync(CODE_CLIMBER_INI_PATH)) { 11 | await removeIniFile(CODE_CLIMBER_INI_PATH) 12 | } 13 | }) 14 | describe('getLocalApiKey', () => { 15 | it('should get local api key', async () => { 16 | const result = await getLocalApiKey() 17 | expect(result).toBeDefined() 18 | expect(result).toHaveLength(36) 19 | }) 20 | it('should throw error if second call to getLocalApiKey is made', async () => { 21 | await expect(async () => { 22 | await getLocalApiKey() 23 | await getLocalApiKey() 24 | }).rejects.toThrow('not available') 25 | }) 26 | it('should get local api key if isAdmin is true', async () => { 27 | await getLocalApiKey() 28 | const result = await getLocalApiKey(true) 29 | expect(result).toBeDefined() 30 | expect(result).toHaveLength(36) 31 | }) 32 | }) 33 | describe('isValidLocalApiKey', () => { 34 | it('should return true if local api key is set', async () => { 35 | const apiKey = await getLocalApiKey() 36 | const result = await isValidLocalApiKey(apiKey) 37 | expect(result).toBe(true) 38 | }) 39 | it('should throw error if api key is invalid', async () => { 40 | await getLocalApiKey() 41 | await expect(async () => { 42 | await isValidLocalApiKey('bad-key') 43 | }).rejects.toThrow('invalid') 44 | }) 45 | }) 46 | }) 47 | -------------------------------------------------------------------------------- /packages/app/src/components/Home/Source/Sources.loading.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Card, 3 | CardContent, 4 | Stack, 5 | Typography, 6 | useTheme, 7 | CircularProgress, 8 | } from '@mui/material' 9 | import AddIcon from '@mui/icons-material/Add' 10 | import { CodeClimbersButton } from '../../common/CodeClimbersButton' 11 | 12 | const SourcesLoading = () => { 13 | const theme = useTheme() 14 | 15 | return ( 16 | 17 | 18 | 19 | 20 | 21 | Sources 22 | 23 | } 27 | disabled={true} 28 | sx={{ 29 | borderRadius: '2px', 30 | textTransform: 'none', 31 | display: 'flex', 32 | alignItems: 'center', 33 | width: 'auto', 34 | height: '32px', 35 | minWidth: 0, 36 | }} 37 | > 38 | Add 39 | 40 | 41 | 48 | 49 | 50 | 51 | ) 52 | } 53 | 54 | export { SourcesLoading } 55 | -------------------------------------------------------------------------------- /packages/app/src/api/utils/db.util.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Kysely, 3 | PostgresAdapter, 4 | DummyDriver, 5 | PostgresIntrospector, 6 | PostgresQueryCompiler, 7 | CompiledQuery, 8 | } from 'kysely' 9 | 10 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 11 | type Database = Record 12 | 13 | const db = new Kysely({ 14 | dialect: { 15 | createAdapter: () => new PostgresAdapter(), 16 | createDriver: () => new DummyDriver(), 17 | createIntrospector: (db) => new PostgresIntrospector(db), 18 | createQueryCompiler: () => new PostgresQueryCompiler(), 19 | }, 20 | }) 21 | 22 | // Extend the CompiledQuery interface 23 | declare module 'kysely' { 24 | interface CompiledQuery { 25 | sqlWithBindings(): string 26 | } 27 | } 28 | 29 | // Implement the sqlWithBindings method 30 | const sqlWithBindings = (compiledQuery: CompiledQuery): string => { 31 | let sql = compiledQuery.sql 32 | const parameters = compiledQuery.parameters 33 | 34 | // Replace each parameter placeholder with its corresponding value 35 | parameters.forEach((param, index) => { 36 | const placeholder = `$${index + 1}` 37 | let replacement: string 38 | 39 | if (param === null) { 40 | replacement = 'NULL' 41 | } else if (typeof param === 'string') { 42 | replacement = `'${param.replace(/'/g, "''")}'` // Escape single quotes 43 | } else if (typeof param === 'number' || typeof param === 'boolean') { 44 | replacement = param.toString() 45 | } else if (param instanceof Date) { 46 | replacement = `'${param.toISOString()}'` 47 | } else { 48 | replacement = `'${JSON.stringify(param)}'` 49 | } 50 | 51 | sql = sql.replace(placeholder, replacement) 52 | }) 53 | 54 | return sql 55 | } 56 | 57 | export { db, sqlWithBindings } 58 | -------------------------------------------------------------------------------- /packages/app/src/components/WeeklyReports/ActiveHoursScore.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Card, CardContent, Stack, Typography } from '@mui/material' 2 | 3 | import { ScoreHeader } from './ScoreHeader' 4 | import { getColorForRating } from '../../api/browser/services/report.service' 5 | 6 | interface Props { 7 | activeHoursScore: CodeClimbers.WeeklyScore & { 8 | breakdown: number 9 | } 10 | } 11 | export const ActiveHoursScore = ({ activeHoursScore }: Props) => { 12 | const color = getColorForRating(activeHoursScore.rating) 13 | 14 | const hours = Math.floor(activeHoursScore.breakdown / 60) 15 | const minutes = activeHoursScore.breakdown % 60 16 | const formattedTime = `${hours}h ${minutes}m` 17 | 18 | const [title, description] = activeHoursScore.recommendation?.split('-') ?? [] 19 | return ( 20 | 21 | 26 | theme.palette.background.paper_raised, 31 | p: 2, 32 | }} 33 | > 34 | 41 | 42 | {formattedTime} total 43 | 44 | {title} 45 | {description} 46 | 47 | 48 | 49 | 50 | 51 | ) 52 | } 53 | -------------------------------------------------------------------------------- /packages/app/src/api/browser/repos/pulse.repo.ts: -------------------------------------------------------------------------------- 1 | import { deepWorkSql } from './queries/deepWork.query' 2 | 3 | const getLatestPulses = () => { 4 | // Example query 5 | const query = ` 6 | SELECT * 7 | FROM activities_pulse 8 | ORDER BY id DESC 9 | LIMIT 10 10 | ` 11 | return query 12 | } 13 | 14 | const getAllPulses = () => { 15 | const query = ` 16 | SELECT * 17 | FROM activities_pulse 18 | ORDER BY created_at DESC 19 | ` 20 | return query 21 | } 22 | 23 | const getDeepWork = (startDate: string, endDate: string) => { 24 | const query = deepWorkSql(startDate, endDate) 25 | return query 26 | } 27 | 28 | const getTimeByProjectCategoryAndRange = ( 29 | startDate: string, 30 | endDate: string, 31 | ) => { 32 | const query = ` 33 | with get_minutes as 34 | ( 35 | select category, project from activities_pulse 36 | where time between '${startDate}' and '${endDate}' 37 | group by category, project, strftime('%s', time) / 120 38 | ) 39 | select category, project as name, count() * 2 as minutes 40 | from get_minutes 41 | group by category, project 42 | order by category asc, minutes desc 43 | ` 44 | return query 45 | } 46 | 47 | const getTimeByCategoryAndRange = (startDate: string, endDate: string) => { 48 | const query = ` 49 | with get_minutes as 50 | ( 51 | select category from activities_pulse 52 | where time between '${startDate}' and '${endDate}' 53 | group by category, strftime('%s', time) / 120 54 | ) 55 | select category, count() * 2 as minutes 56 | from get_minutes 57 | group by category 58 | order by category asc, minutes desc 59 | ` 60 | return query 61 | } 62 | 63 | export { 64 | getAllPulses, 65 | getLatestPulses, 66 | getDeepWork, 67 | getTimeByProjectCategoryAndRange, 68 | getTimeByCategoryAndRange, 69 | } 70 | -------------------------------------------------------------------------------- /packages/server/utils/sqlReader.util.ts: -------------------------------------------------------------------------------- 1 | import { readFile } from 'fs/promises' 2 | import { dirname, join } from 'path' 3 | 4 | // Initialize a cache object 5 | const cache: Record = {} 6 | 7 | // Utility function to read file content and return it as a string 8 | const getFileContentAsString = async ( 9 | fileName: string, 10 | additionalPath = 'queries', 11 | ) => { 12 | try { 13 | // Dynamically determine the directory of the caller 14 | // Create a new Error and use its stack trace 15 | const err = new Error() 16 | const stack = err.stack || '' 17 | // Find the second entry in the stack trace, which should correspond to the caller 18 | const caller = stack.split('\n')[2] || '' 19 | // Extract the file path from the caller string 20 | const match = caller.match(/\((?:file:\/\/)?(.*?):\d+:\d+\)$/) 21 | if (!match) { 22 | throw new Error('Could not determine caller file path') 23 | } 24 | const callerPath = match[1] 25 | const callerDir = dirname(callerPath) 26 | 27 | // Generate a unique cache key based on the caller directory and file name 28 | const cacheKey = `${callerDir}:${additionalPath}:${fileName}` 29 | 30 | // Check if the result is already in the cache 31 | if (cache[cacheKey]) { 32 | return cache[cacheKey] // Return the cached result 33 | } 34 | 35 | // Resolve the file path relative to the caller file's directory 36 | const resolvedPath = join(callerDir, additionalPath, fileName) 37 | // Read and return the file content 38 | const content = await readFile(resolvedPath, 'utf8') 39 | cache[cacheKey] = content // Store the result in the cache 40 | return content 41 | } catch (error) { 42 | console.error('Error reading file:', error) 43 | throw error // Re-throw the error to handle it in the calling function 44 | } 45 | } 46 | 47 | export { getFileContentAsString } 48 | -------------------------------------------------------------------------------- /packages/server/src/types/time.api.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace CodeClimbers { 2 | import { PageMetaDto } from '../v1/dtos/pagination.dto' 3 | export interface WeekOverview { 4 | longestDayMinutes: number 5 | yesterdayMinutes: number 6 | todayMinutes: number 7 | weekMinutes: number 8 | } 9 | 10 | export interface WeekOverviewDao { 11 | message: string 12 | data: WeekOverview 13 | } 14 | 15 | export interface Health { 16 | OK: boolean 17 | app: string 18 | version: string 19 | } 20 | 21 | export interface TimeOverview { 22 | category: string 23 | minutes: number 24 | } 25 | 26 | export interface DeepWorkTime { 27 | flowGroup: number 28 | flowTime: number 29 | flowStart: string 30 | } 31 | 32 | export interface DeepWorkDao { 33 | message: string 34 | data: DeepWorkTime[] 35 | } 36 | 37 | export interface TimeOverviewDao { 38 | message: string 39 | data: TimeOverview[][] 40 | } 41 | 42 | export interface SourceMinutes { 43 | name: string 44 | minutes: number 45 | lastActive: string 46 | } 47 | 48 | export interface SourcesOverviewDao { 49 | message: string 50 | data: SourceMinutes[] 51 | } 52 | 53 | export interface SiteMinutes { 54 | name: string 55 | minutes: number 56 | } 57 | 58 | export interface SitesOverviewDao { 59 | message: string 60 | data: SiteMinutes[] 61 | } 62 | 63 | export interface ProjectTimeOverview { 64 | name: string 65 | minutes: number 66 | } 67 | 68 | export interface PerProjectTimeAndCategoryOverview { 69 | [key: string]: ProjectTimeOverview[] 70 | } 71 | 72 | export interface PerProjectTimeOverviewDao { 73 | message: string 74 | data: PerProjectTimeOverview 75 | } 76 | 77 | export interface PerProjectOverviewByCategoryDao { 78 | message: string 79 | data: ProjectTimeOverview[] 80 | meta: PageMetaDto 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /packages/app/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { CssBaseline, ThemeProvider } from '@mui/material' 2 | import { StrictMode, useEffect } from 'react' 3 | import { createRoot } from 'react-dom/client' 4 | import './index.css' 5 | import { AppRouter } from './routes' 6 | 7 | import '@fontsource/roboto/300.css' 8 | import '@fontsource/roboto/400.css' 9 | import '@fontsource/roboto/500.css' 10 | import '@fontsource/roboto/700.css' 11 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query' 12 | import { dark, light } from './config/theme' 13 | import { useBrowserPreferences } from './hooks/useBrowserPreferences' 14 | import { useThemeStorage } from './hooks/useBrowserStorage' 15 | import { initPosthog } from './utils/posthog.util' 16 | 17 | const queryClient = new QueryClient() 18 | 19 | const FAV_ICONS = { 20 | white: '/images/logo-min-white.png', 21 | dark: '/images/logo-min.png', 22 | } 23 | 24 | const THEMES = { 25 | light, 26 | dark, 27 | } 28 | 29 | const AppRender = () => { 30 | const { prefersDark } = useBrowserPreferences() 31 | const [theme] = useThemeStorage() 32 | initPosthog() 33 | useEffect(() => { 34 | const favicon = document.querySelector( 35 | 'link[rel="icon"]', 36 | ) as HTMLLinkElement | null 37 | 38 | if (!favicon) return 39 | 40 | favicon.href = FAV_ICONS[prefersDark ? 'white' : 'dark'] 41 | }, [prefersDark]) 42 | 43 | const backupTheme = prefersDark ? 'dark' : 'light' 44 | const muiTheme = theme ? THEMES[theme] : THEMES[backupTheme] 45 | 46 | return ( 47 | 48 | 49 | 50 | 51 | 52 | 53 | ) 54 | } 55 | 56 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 57 | createRoot(document.getElementById('root')!).render( 58 | 59 | 60 | , 61 | ) 62 | -------------------------------------------------------------------------------- /packages/app/src/api/browser/user.api.ts: -------------------------------------------------------------------------------- 1 | import { useMutation, useQueryClient } from '@tanstack/react-query' 2 | import { useBetterQuery } from '..' 3 | import { userKeys } from './keys' 4 | import { 5 | getCurrentUser, 6 | updateUserSettings, 7 | updateUser, 8 | } from './repos/user.repo' 9 | import { sqlQueryFn } from './services/query.service' 10 | 11 | type UserWithSettings = CodeClimbers.User & CodeClimbers.UserSettings 12 | // do not use this directly in a component 13 | const useGetCurrentUser = () => { 14 | const queryFn = async () => { 15 | const sql = getCurrentUser() 16 | const records = await sqlQueryFn(sql, 'getCurrentUser') 17 | return records[0] 18 | } 19 | return useBetterQuery({ 20 | queryKey: userKeys.user, 21 | queryFn, 22 | }) 23 | } 24 | 25 | const useUpdateUserSettings = () => { 26 | const queryClient = useQueryClient() 27 | const queryFn = ({ 28 | user_id, 29 | settings, 30 | }: { 31 | user_id: number 32 | settings: Partial 33 | }) => { 34 | const sql = updateUserSettings(user_id, settings) 35 | return sqlQueryFn(sql, 'updateUserSettings') 36 | } 37 | return useMutation({ 38 | mutationFn: queryFn, 39 | onSuccess: () => { 40 | queryClient.invalidateQueries({ queryKey: userKeys.user }) 41 | }, 42 | }) 43 | } 44 | 45 | const useUpdateUser = () => { 46 | const queryClient = useQueryClient() 47 | const queryFn = ({ 48 | user_id, 49 | user, 50 | }: { 51 | user_id: number 52 | user: Partial 53 | }) => { 54 | const sql = updateUser(user_id, user) 55 | return sqlQueryFn(sql, 'updateUser') 56 | } 57 | return useMutation({ 58 | mutationFn: queryFn, 59 | onSuccess: () => { 60 | queryClient.invalidateQueries({ queryKey: userKeys.user }) 61 | }, 62 | }) 63 | } 64 | 65 | export { useGetCurrentUser, useUpdateUserSettings, useUpdateUser } 66 | -------------------------------------------------------------------------------- /scripts/mock_install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Check if version is provided 4 | if [ $# -eq 0 ]; then 5 | echo "Error: Please provide a version number." 6 | echo "Usage: $0 " 7 | exit 1 8 | fi 9 | 10 | VERSION=$1 11 | RUN_MODE=false 12 | 13 | # Check for --run flag 14 | if [[ "$2" == "--run" ]]; then 15 | RUN_MODE=true 16 | fi 17 | 18 | git checkout v$VERSION 19 | # Get the directory of the script 20 | SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" 21 | 22 | # Navigate to the project root (assuming the script is in a subdirectory like 'scripts') 23 | PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" 24 | cd "$PROJECT_ROOT" 25 | 26 | # Create the package 27 | npm pack 28 | 29 | # Create temp directory or real directory based on RUN_MODE 30 | if [ "$RUN_MODE" = true ]; then 31 | INSTALL_DIR="$(dirname "$PROJECT_ROOT")/mock-codeclimbers/codeclimbers_install_$VERSION" 32 | mkdir -p "$INSTALL_DIR" 33 | else 34 | INSTALL_DIR=$(mktemp -d) 35 | fi 36 | cd "$INSTALL_DIR" 37 | 38 | # Create package.json with dynamic version 39 | echo "{\"dependencies\":{\"codeclimbers\":\"file:$PROJECT_ROOT/codeclimbers-$VERSION.tgz\"}}" > package.json 40 | 41 | # set environment variable 42 | if [ "$RUN_MODE" = true ]; then 43 | export CODECLIMBERS_MOCK_INSTALL=false 44 | else 45 | export NODE_ENV=development 46 | export CODECLIMBERS_MOCK_INSTALL=true 47 | fi 48 | 49 | 50 | # Install 51 | npm install 52 | 53 | # Run 54 | node node_modules/codeclimbers/bin/run.js start 55 | 56 | # Capture the exit status 57 | EXIT_STATUS=$? 58 | 59 | # Clean up 60 | if [ "$RUN_MODE" = false ]; then 61 | cd "$PROJECT_ROOT" 62 | rm -rf "$INSTALL_DIR" 63 | rm "codeclimbers-$VERSION.tgz" 64 | else 65 | echo "Installation completed in: $INSTALL_DIR" 66 | echo "The .tgz file is still in the project root directory." 67 | fi 68 | 69 | # Exit with the status from the codeclimbers execution 70 | exit $EXIT_STATUS -------------------------------------------------------------------------------- /packages/server/src/v1/v1.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common' 2 | import { HealthController } from '../common/infrastructure/http/controllers/health.controller' 3 | import { StartupController } from './startup/startup.controller' 4 | import { ActivitiesService } from './activities/activities.service' 5 | import { PulseController } from './activities/pulse.controller' 6 | import { WakatimeController } from './activities/wakatimeProxy.controller' 7 | import { PulseRepo } from './database/pulse.repo' 8 | import { DarwinStartupService } from './startup/darwinStartup.service' 9 | import { StartupServiceFactory } from './startup/startupService.factory' 10 | import { UnsupportedStartupService } from './startup/unsupportedStartup.service' 11 | import { WindowsStartupService } from './startup/windowsStartup.service' 12 | import { LinuxStartupService } from './startup/linuxStartup.service' 13 | import { LocalDbController } from './localdb/localDb.controller' 14 | import { LocalAuthService } from './localdb/localAuth.service' 15 | import { LocalAuthController } from './localdb/localAuth.controller' 16 | import { LocalAuthGuard } from './localdb/localAuth.guard' 17 | import { LocalDbRepo } from './localdb/localDb.repo' 18 | import { ReportService } from './activities/report.service' 19 | import { ReportController } from './activities/report.controller' 20 | 21 | @Module({ 22 | imports: [], 23 | controllers: [ 24 | HealthController, 25 | PulseController, 26 | WakatimeController, 27 | StartupController, 28 | LocalDbController, 29 | LocalAuthController, 30 | ReportController, 31 | ], 32 | providers: [ 33 | ActivitiesService, 34 | StartupServiceFactory, 35 | PulseRepo, 36 | UnsupportedStartupService, 37 | DarwinStartupService, 38 | WindowsStartupService, 39 | LinuxStartupService, 40 | LocalAuthService, 41 | LocalAuthGuard, 42 | LocalDbRepo, 43 | ReportService, 44 | ], 45 | }) 46 | export class V1Module {} 47 | -------------------------------------------------------------------------------- /packages/app/src/components/WeeklyReports/GrowthScore.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Card, CardContent, Typography } from '@mui/material' 2 | import { WeeklyBarGraph } from './WeeklyBarGraph' 3 | import { ScoreHeader } from './ScoreHeader' 4 | import { EmptyState } from './EmptyState' 5 | import { formatMinutes } from '../../utils/time' 6 | 7 | interface Props { 8 | growthScore: CodeClimbers.WeeklyScore & { 9 | breakdown: CodeClimbers.EntityTimeOverviewDB[] 10 | } 11 | } 12 | 13 | export const GrowthScore = ({ growthScore }: Props) => { 14 | const top5Sites = growthScore.breakdown.slice(0, 5) 15 | 16 | const data = top5Sites.map((site) => ({ 17 | name: site.entity.split('//')[1] || site.entity, 18 | minutes: site.minutes, 19 | })) 20 | 21 | return ( 22 | 23 | 28 | theme.palette.background.paper_raised, 33 | }} 34 | > 35 | 43 | {data.length > 0 ? ( 44 | <> 45 | 46 | {formatMinutes(growthScore.actual)} total 47 | 48 | 53 | `${e.id}: ${e.formattedValue} in site: ${e.indexValue}` 54 | } 55 | /> 56 | 57 | ) : ( 58 | 59 | )} 60 | 61 | 62 | 63 | ) 64 | } 65 | -------------------------------------------------------------------------------- /packages/app/src/hooks/useVersionConsole.ts: -------------------------------------------------------------------------------- 1 | import { useGetLocalVersion } from '../services/health.service' 2 | import { useLatestVersion } from '../services/version.service' 3 | import { useTheme } from '@mui/material' 4 | 5 | // only show the banner one time 6 | let hasShownBanner = false 7 | 8 | export const useVersionConsoleBanner = () => { 9 | const theme = useTheme() 10 | const { data: localVersionResponse } = useGetLocalVersion() 11 | const { data: remoteVersionResponse } = useLatestVersion() 12 | 13 | if ( 14 | localVersionResponse?.version && 15 | remoteVersionResponse && 16 | !hasShownBanner 17 | ) { 18 | console.log( 19 | `%c 20 | 21 | @@@@@@@@@@@@@@@@@@@ 22 | @@@@@@@@@@@@@@@@@@@ 23 | @@@ @@@ 24 | @@@ @@@ 25 | 26 | @@@@@@@@@@@@@@@@@@@ 27 | @@@@@@@@@@@@@@@@@@@ 28 | @@@ @@@ 29 | @@@ @@@ 30 | 31 | @@@@@@@@@@@@@@@@@@@ 32 | @@@@@@@@@@@@@@@@@@@ 33 | @@@ @@@ 34 | @@@ @@@ 35 | 36 | %cCODECLIMBERS.IO 37 | %cWelcome to CodeClimbers! We're open source and we'd love to have you join our community on Discord: https://discord.gg/zBnu8jGnHa 38 | `, 39 | `color: ${theme.palette.primary.main}; font-size: 1rem;font-weight: bold;`, 40 | `font-weight: bold; font-size: 2rem; color: ${theme.palette.primary.main}; font-family: 'Courier New', Courier, monospace;`, 41 | `color: ${theme.palette.text.primary}; font-size: 1rem;`, 42 | ) 43 | 44 | console.log( 45 | '%cCLI Version: %c' + localVersionResponse?.version, 46 | `color: ${theme.palette.primary.main}`, 47 | 'color: inherit', 48 | ) 49 | console.log( 50 | '%cBrowser Version: %c' + remoteVersionResponse, 51 | `color: ${theme.palette.primary.main}`, 52 | 'color: inherit', 53 | ) 54 | hasShownBanner = true 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /scripts/checkDependencies.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | const fs = require('fs') 3 | const path = require('path') 4 | // Read root package.json 5 | const rootPackage = JSON.parse(fs.readFileSync('package.json', 'utf8')) 6 | const rootDeps = new Set(Object.keys(rootPackage.dependencies || {})) 7 | 8 | // Get workspace directories 9 | const workspaces = rootPackage.workspaces || [] 10 | 11 | let missingDeps = new Set() 12 | let unusedDeps = new Set(rootDeps) 13 | 14 | // Check each workspace 15 | workspaces.forEach((dir) => { 16 | if (fs.existsSync(path.join(dir, 'package.json'))) { 17 | const packageJson = JSON.parse( 18 | fs.readFileSync(path.join(dir, 'package.json'), 'utf8'), 19 | ) 20 | const deps = new Set([...Object.keys(packageJson.dependencies || {})]) 21 | // Check for missing dependencies 22 | deps.forEach((dep) => { 23 | if (!rootDeps.has(dep)) { 24 | missingDeps.add(dep) 25 | } 26 | unusedDeps.delete(dep) 27 | }) 28 | unusedDeps.delete('@oclif/core') 29 | unusedDeps.delete('@oclif/plugin-warn-if-update-available') 30 | unusedDeps.delete('@codeclimbers/config') 31 | // used in commands which has no package.json 32 | unusedDeps.delete('find-process') 33 | unusedDeps.delete('server') 34 | unusedDeps.delete('open') 35 | unusedDeps.delete('picocolors') 36 | } 37 | }) 38 | 39 | missingDeps.delete('@codeclimbers/server') 40 | // Report results 41 | if (missingDeps.size > 0) { 42 | console.error( 43 | 'ERROR: The following dependencies are missing from the root package.json:', 44 | ) 45 | missingDeps.forEach((dep) => console.error(` - ${dep}`)) 46 | process.exitCode = 1 47 | } 48 | 49 | if (unusedDeps.size > 0) { 50 | console.warn( 51 | 'WARNING: The following dependencies in root package.json are not used in any submodule:', 52 | ) 53 | unusedDeps.forEach((dep) => console.warn(` - ${dep}`)) 54 | } 55 | 56 | if (missingDeps.size === 0 && unusedDeps.size === 0) { 57 | console.log('All dependencies are correctly configured.') 58 | } 59 | -------------------------------------------------------------------------------- /packages/app/src/routes/AppRoutes.tsx: -------------------------------------------------------------------------------- 1 | import { Route, Routes } from 'react-router-dom' 2 | 3 | import { ImportPage } from '../components/ImportPage' 4 | import { ExtensionsPage } from '../components/Extensions/ExtensionsPage' 5 | import { ExtensionsLayout } from '../layouts/ExtensionsLayout' 6 | import { ContributorsPage } from '../components/ContributorsPage' 7 | import { getActiveDashboardExtensionRoutes } from '../services/extensions.service' 8 | import { InstallPage } from '../components/InstallPage' 9 | import { DashboardLayout } from '../layouts/DashboardLayout' 10 | import { ImportLayout } from '../layouts/ImportLayout' 11 | import { ReportsPage } from '../components/WeeklyReports/ReportsPage' 12 | import { HomePage } from '../components/Home/HomePage' 13 | import { GameMakersPage } from '../components/GameMakers/GameMakersPage' 14 | 15 | export const AppRoutes = () => { 16 | const extensions = getActiveDashboardExtensionRoutes() 17 | return ( 18 | <> 19 | 20 | }> 21 | } /> 22 | } 25 | handle={{ title: 'Extensions' }} 26 | /> 27 | } /> 28 | } /> 29 | } /> 30 | 31 | }> 32 | {extensions.map((extension) => { 33 | return ( 34 | } 38 | /> 39 | ) 40 | })} 41 | 42 | }> 43 | } /> 44 | 45 | } /> 46 | 47 | 48 | ) 49 | } 50 | -------------------------------------------------------------------------------- /packages/app/src/extensions/HourlyCategoryReport/hourlyCategoryReport.api.ts: -------------------------------------------------------------------------------- 1 | import { sqlQueryFn } from '../../api/browser/services/query.service' 2 | import { useQuery } from '@tanstack/react-query' 3 | 4 | const getQuery = (startDate: string, endDate: string, category: string) => 5 | `WITH getMinutes (category, time) AS ( 6 | SELECT category, time 7 | FROM activities_pulse 8 | WHERE activities_pulse.time BETWEEN '${startDate}' AND '${endDate}' 9 | AND ${category} 10 | GROUP BY strftime('%s', time) / 120) 11 | SELECT category, time, count() * 2 AS minutes 12 | FROM getMinutes 13 | GROUP BY strftime('%s', time) / 3600 14 | ORDER BY time;` 15 | 16 | export const useGetCodingData = (startDate: string, endDate: string) => { 17 | const query = getQuery( 18 | startDate, 19 | endDate, 20 | "(category is 'coding' or category is 'debugging')", 21 | ) 22 | 23 | return useQuery({ 24 | queryKey: ['hourlyCategoryReport-coding', startDate, endDate], 25 | queryFn: () => sqlQueryFn(query, 'hourlyCategoryReport-coding'), 26 | }) 27 | } 28 | 29 | export const useGetBrowsingData = (startDate: string, endDate: string) => { 30 | const query = getQuery(startDate, endDate, "category is 'browsing'") 31 | 32 | return useQuery({ 33 | queryKey: ['hourlyCategoryReport-browsing', startDate, endDate], 34 | queryFn: () => sqlQueryFn(query, 'hourlyCategoryReport-browsing'), 35 | }) 36 | } 37 | 38 | export const useGetCommunicatingData = (startDate: string, endDate: string) => { 39 | const query = getQuery(startDate, endDate, "category is 'communicating'") 40 | 41 | return useQuery({ 42 | queryKey: ['hourlyCategoryReport-communicating', startDate, endDate], 43 | queryFn: () => sqlQueryFn(query, 'hourlyCategoryReport-communicating'), 44 | }) 45 | } 46 | 47 | export const useGetDesigningData = (startDate: string, endDate: string) => { 48 | const query = getQuery(startDate, endDate, "category is 'designing'") 49 | 50 | return useQuery({ 51 | queryKey: ['hourlyCategoryReport-designing', startDate, endDate], 52 | queryFn: () => sqlQueryFn(query, 'hourlyCategoryReport-designing'), 53 | }) 54 | } 55 | -------------------------------------------------------------------------------- /packages/app/src/components/WeeklyReports/DeepWorkScore.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Card, CardContent, Typography } from '@mui/material' 2 | import { ScoreHeader } from './ScoreHeader' 3 | import { EmptyState } from './EmptyState' 4 | import { WeeklyLineGraph } from './WeeklyLineGraph' 5 | import { Serie } from '@nivo/line' 6 | import dayjs from 'dayjs' 7 | import { formatMinutes } from '../../utils/time' 8 | import { getColorForRating } from '../../api/browser/services/report.service' 9 | 10 | interface Props { 11 | deepWorkScore: CodeClimbers.WeeklyScore & { 12 | breakdown: CodeClimbers.DeepWorkPeriod[] 13 | } 14 | } 15 | 16 | export const DeepWorkScore = ({ deepWorkScore }: Props) => { 17 | const days = deepWorkScore.breakdown 18 | 19 | const color = getColorForRating(deepWorkScore.rating) 20 | const data: Serie[] = [ 21 | { 22 | id: 'deep-work', 23 | color: color.main, 24 | data: days.map((day) => ({ 25 | x: dayjs(day.startDate).format('ddd'), 26 | y: day.time / 60, 27 | })), 28 | }, 29 | ] 30 | 31 | const hasNoDeepWork = data.length === 0 || data[0].data.length === 0 32 | return ( 33 | 34 | 39 | theme.palette.background.paper_raised, 44 | }} 45 | > 46 | 53 | {!hasNoDeepWork ? ( 54 | <> 55 | 56 | {formatMinutes(deepWorkScore.actual)} avg 5 highest days 57 | 58 | 59 | 60 | ) : ( 61 | 62 | )} 63 | 64 | 65 | 66 | ) 67 | } 68 | -------------------------------------------------------------------------------- /packages/app/src/components/common/UpdateBanner/UpdateBanner.tsx: -------------------------------------------------------------------------------- 1 | import { Alert, Box } from '@mui/material' 2 | import { useLatestVersion } from '../../../services/version.service' 3 | import { useBrowserStorage } from '../../../hooks/useBrowserStorage' 4 | import { CodeSnippit } from '../CodeSnippit/CodeSnippit' 5 | import { useGetLocalVersion } from '../../../services/health.service' 6 | 7 | const wasOverTwenyFourHoursAgo = (dismissedAt: number) => { 8 | const twentyFourHours = 1_000 * 60 * 60 * 24 9 | return new Date().getTime() - dismissedAt >= twentyFourHours 10 | } 11 | 12 | export const UpdateBanner = () => { 13 | const { data: localVersionResponse } = useGetLocalVersion() 14 | const [dismissedInfo, setDismissedInfo] = useBrowserStorage({ 15 | key: 'update-banner-dismissed', 16 | value: { 17 | dismissed: false, 18 | dismissedAt: null as number | null, 19 | }, 20 | }) 21 | const wasDismissed = 22 | dismissedInfo?.dismissedAt && 23 | !wasOverTwenyFourHoursAgo(dismissedInfo.dismissedAt) 24 | 25 | // Don't show the banner if the user has dismissed it and it was less than 24 hours ago 26 | const enableVersionPolling = !wasDismissed 27 | 28 | const remoteVersion = useLatestVersion(enableVersionPolling) 29 | 30 | if ( 31 | !remoteVersion.data || 32 | localVersionResponse?.version === remoteVersion.data || 33 | wasDismissed || 34 | remoteVersion.isPending || 35 | remoteVersion.isError 36 | ) { 37 | return null 38 | } 39 | const updateCommand = ` 40 | npx codeclimbers startup:disable && 41 | npx codeclimbers@${remoteVersion.data} start 42 | ` 43 | return ( 44 | 45 | { 48 | setDismissedInfo({ 49 | dismissed: true, 50 | dismissedAt: new Date().getTime(), 51 | }) 52 | }} 53 | > 54 | An update is available! Run the following command to update 55 | 56 | Then reload the page 57 | 58 | 59 | ) 60 | } 61 | -------------------------------------------------------------------------------- /packages/app/src/components/WeeklyReports/ProjectScore.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Card, CardContent, Typography } from '@mui/material' 2 | import { WeeklyBarGraph } from './WeeklyBarGraph' 3 | import { ScoreHeader } from './ScoreHeader' 4 | import { EmptyState } from './EmptyState' 5 | import { formatMinutes } from '../../utils/time' 6 | 7 | interface Props { 8 | projectScore: CodeClimbers.WeeklyScore & { 9 | breakdown: CodeClimbers.PerProjectTimeOverviewDB[] 10 | } 11 | } 12 | 13 | export const ProjectScore = ({ projectScore }: Props) => { 14 | const knownProjects = projectScore.breakdown.filter( 15 | ({ name }) => !name.toLowerCase().includes('<<'), 16 | ) 17 | const top5Projects = knownProjects.slice(0, 5) 18 | 19 | const data = top5Projects.map((project) => ({ 20 | name: project.name, 21 | minutes: project.minutes, 22 | })) 23 | 24 | return ( 25 | 26 | 31 | theme.palette.background.paper_raised, 36 | }} 37 | > 38 | 46 | {data.length > 0 ? ( 47 | <> 48 | 49 | {formatMinutes(projectScore.actual)} total 50 | 51 | 56 | `${e.id}: ${e.formattedValue} in project: ${e.indexValue}` 57 | } 58 | /> 59 | 60 | ) : ( 61 | 62 | )} 63 | 64 | 65 | 66 | ) 67 | } 68 | -------------------------------------------------------------------------------- /packages/app/src/api/platformServer/gameSettings.platformApi.ts: -------------------------------------------------------------------------------- 1 | import { PLATFORM_API_URL, useBetterQuery } from '..' 2 | import { gamemakerKeys } from './keys' 3 | import { platformApiRequest } from '../request' 4 | import { useMutation, useQueryClient } from '@tanstack/react-query' 5 | 6 | const useGetGameSettings = (id: string) => { 7 | const queryFn = () => 8 | platformApiRequest({ 9 | url: `${PLATFORM_API_URL}/game-maker/settings/${id}`, 10 | method: 'GET', 11 | }) 12 | return useBetterQuery({ 13 | queryKey: gamemakerKeys.gameSettings(id), 14 | queryFn, 15 | enabled: !!id, 16 | }) 17 | } 18 | 19 | const useGetAiWeeklyReports = () => { 20 | const queryFn = () => 21 | platformApiRequest({ 22 | url: `${PLATFORM_API_URL}/game-maker/ai-weekly-reports`, 23 | method: 'GET', 24 | }) 25 | return useBetterQuery< 26 | { email: string; startOfWeek: string; performanceReview: string }[], 27 | Error 28 | >({ 29 | queryKey: gamemakerKeys.aiWeeklyReports, 30 | select: (data) => data.reverse().slice(0, 10), 31 | queryFn, 32 | }) 33 | } 34 | 35 | type UpdateGameSettingsProps = { 36 | id: string 37 | settings: object 38 | } 39 | const useUpdateGameSettings = () => { 40 | const mutationFn = ({ id, settings }: UpdateGameSettingsProps) => 41 | platformApiRequest({ 42 | url: `${PLATFORM_API_URL}/game-maker/settings/${id}`, 43 | method: 'POST', 44 | body: settings, 45 | }) 46 | return useMutation({ 47 | mutationFn, 48 | }) 49 | } 50 | 51 | const useRunAiWeeklyReport = () => { 52 | const queryClient = useQueryClient() 53 | const mutationFn = (body: { 54 | email: string 55 | startOfWeek: string 56 | weeklyReport: CodeClimbers.WeeklyScores 57 | }) => 58 | platformApiRequest({ 59 | url: `${PLATFORM_API_URL}/game-maker/ai-weekly-reports`, 60 | method: 'POST', 61 | body, 62 | }) 63 | return useMutation({ 64 | mutationFn, 65 | onSuccess: () => { 66 | queryClient.invalidateQueries({ queryKey: gamemakerKeys.aiWeeklyReports }) 67 | }, 68 | }) 69 | } 70 | 71 | export { 72 | useGetGameSettings, 73 | useUpdateGameSettings, 74 | useRunAiWeeklyReport, 75 | useGetAiWeeklyReports, 76 | } 77 | -------------------------------------------------------------------------------- /packages/app/src/extensions/SqlSandbox/SqlSandbox.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Typography, useTheme } from '@mui/material' 2 | import { useNavigate } from 'react-router-dom' 3 | import AddIcon from '@mui/icons-material/Add' 4 | import { getSqlList } from './sqlSandbox.service' 5 | import { CodeClimbersButton } from '../../components/common/CodeClimbersButton' 6 | 7 | export const SqlSandbox = () => { 8 | const navigate = useNavigate() 9 | const theme = useTheme() 10 | const handleAddClick = () => { 11 | navigate('/sql-sandbox') 12 | } 13 | return ( 14 | 15 | 16 | Sql Sandbox 17 | } 22 | sx={{ 23 | backgroundColor: 24 | theme.palette.mode === 'dark' ? '#EBEBEB' : '#1F2122', 25 | borderRadius: '2px', 26 | textTransform: 'none', 27 | display: 'flex', 28 | alignItems: 'center', 29 | width: 'auto', 30 | height: '32px', 31 | minWidth: 0, 32 | }} 33 | > 34 | Add 35 | 36 | {/* Add a list of saved sql queries pulled from local storage */} 37 | 38 | 39 | Saved Queries 40 | {getSqlList().map((sql) => ( 41 | { 56 | navigate(`/sql-sandbox?sqlId=${sql.id}`) 57 | }} 58 | > 59 | {sql.name} 60 | 61 | ))} 62 | 63 | 64 | ) 65 | } 66 | -------------------------------------------------------------------------------- /packages/app/src/components/Home/HomePage.tsx: -------------------------------------------------------------------------------- 1 | import { Box } from '@mui/material' 2 | import { Navigate } from 'react-router-dom' 3 | 4 | import { useSelectedDate } from '../../hooks/useSelectedDate' 5 | import { Time } from './Time/Time' 6 | import { useGetHealth } from '../../services/health.service' 7 | import { ExtensionsDashboard } from '../Extensions/ExtensionsDashboard' 8 | import { ExtensionsWidget } from './Extensions/ExtensionsWidget' 9 | import { Sources } from './Source/Sources' 10 | import { DateHeader } from './DateHeader' 11 | import { useSetFeaturePreference } from '../../hooks/useSetFeaturePreference' 12 | import { ErrorBoundary } from 'react-error-boundary' 13 | import { ErrorFallback } from '../ErrorFallback' 14 | 15 | const HomePage = () => { 16 | const { data: health, isPending: isHealthPending } = useGetHealth({ 17 | retry: false, 18 | refetchInterval: false, 19 | }) 20 | const { selectedDate, setSelectedDate } = useSelectedDate() 21 | useSetFeaturePreference() 22 | 23 | if (!health && !isHealthPending) return 24 | 25 | return ( 26 |
27 | 31 | 39 | 40 | 42 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 |
62 | ) 63 | } 64 | 65 | export { HomePage } 66 | -------------------------------------------------------------------------------- /packages/server/src/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core' 2 | import { AppModule } from './app.module' 3 | import { Logger, ValidationPipe } from '@nestjs/common' 4 | import { isCli, isProd } from '../utils/environment.util' 5 | import { PROCESS_NAME } from '../utils/constants' 6 | import { updateSettings } from '../utils/ini.util' 7 | import { startMigrations } from './v1/database/migrations' 8 | import { CodeClimberExceptionFilter } from './filters/codeClimbersException.filter' 9 | import { urlencoded, json } from 'express' 10 | 11 | const updatedWakatimeIniValues: Record = { 12 | api_key: 'eacb3beb-dad8-4fa1-b6ba-f89de8bf8f4a', // placeholder value 13 | api_url: 'http://localhost:14400/api/v1/wakatime', 14 | } 15 | 16 | const traceEnvironment = () => { 17 | Logger.debug(`Running as: ${process.env.NODE_ENV}`, 'main.ts') 18 | Logger.debug(`process.env: ${JSON.stringify(process.env)}`, 'main.ts') 19 | } 20 | 21 | export const bootstrap = async () => { 22 | const port = process.env.CODECLIMBERS_SERVER_PORT || 14_400 23 | const app = await NestFactory.create(AppModule, { 24 | logger: !isProd() 25 | ? ['log', 'debug', 'error', 'verbose', 'warn'] 26 | : ['log', 'error', 'warn'], 27 | }) 28 | traceEnvironment() 29 | app.use(json({ limit: '50mb' })) 30 | app.use(urlencoded({ extended: true, limit: '50mb' })) 31 | 32 | app.enableCors({ 33 | origin: isProd() 34 | ? [ 35 | 'https://codeclimbers.io', 36 | /\.codeclimbers\.io$/, 37 | 'http://localhost:5173', 38 | 'chrome-extension://fdmoefklpgbjapealpjfailnmalbgpbe', 39 | ] 40 | : [ 41 | 'https://codeclimbers.io', 42 | /chrome-extension.+$/, 43 | 'http://localhost:5173', 44 | /\.codeclimbers\.io$/, 45 | /\.web\.app$/, 46 | ], 47 | credentials: true, 48 | }) 49 | app.useGlobalPipes( 50 | new ValidationPipe({ 51 | transform: true, 52 | transformOptions: { 53 | enableImplicitConversion: true, 54 | }, 55 | }), 56 | ) 57 | app.useGlobalFilters(new CodeClimberExceptionFilter()) 58 | await updateSettings(updatedWakatimeIniValues) 59 | await startMigrations() 60 | await app.listen(port) 61 | process.title = PROCESS_NAME 62 | } 63 | 64 | if (!isCli()) { 65 | bootstrap() 66 | } 67 | -------------------------------------------------------------------------------- /packages/app/src/components/WeeklyReports/WeeklyLineGraph.tsx: -------------------------------------------------------------------------------- 1 | import { LineSvgProps, ResponsiveLine } from '@nivo/line' 2 | import { useTheme } from '@mui/material' 3 | import { DatumValue } from '@nivo/core' 4 | import { getColorForRating } from '../../api/browser/services/report.service' 5 | 6 | type Props = LineSvgProps & { 7 | rating: CodeClimbers.WeeklyScoreRating 8 | } 9 | 10 | const getEvenValuesBetweenMinAndMax = (min: number, max: number): number[] => { 11 | if (min > max) { 12 | throw new Error('Minimum value cannot be greater than maximum value') 13 | } 14 | const result: number[] = [] 15 | for (let i = Math.ceil(min); i <= max; i++) { 16 | if (i % 2 === 0) result.push(i) 17 | } 18 | return result 19 | } 20 | const getMinMaxFromArray = ( 21 | numbers: DatumValue[], 22 | ): { min: number; max: number } => { 23 | if (numbers.length === 0) { 24 | throw new Error('Array is empty') 25 | } 26 | 27 | const min = Math.min(...(numbers as number[])) 28 | const max = Math.max(...(numbers as number[])) 29 | 30 | return { min, max } 31 | } 32 | 33 | export const WeeklyLineGraph = (props: Props) => { 34 | const theme = useTheme() 35 | 36 | const color = getColorForRating(props.rating) 37 | const yValues = props.data[0].data 38 | .map((x) => x.y) 39 | .filter((x) => x !== undefined && x !== null) 40 | const { min, max } = getMinMaxFromArray(yValues) 41 | const yTickValues = getEvenValuesBetweenMinAndMax(min, max) 42 | 43 | return ( 44 | `${x}h`, 56 | tickValues: yTickValues, 57 | }} 58 | animate={false} 59 | colors={`${color.main}`} 60 | enableGridX={false} 61 | enableGridY={false} 62 | enableArea={true} 63 | areaOpacity={0.05} 64 | areaBaselineValue={0} 65 | pointBorderColor={{ from: 'serieColor' }} 66 | curve="catmullRom" 67 | theme={{ 68 | axis: { 69 | ticks: { 70 | text: { 71 | fill: theme.palette.text.primary, 72 | }, 73 | }, 74 | }, 75 | }} 76 | {...props} 77 | /> 78 | ) 79 | } 80 | --------------------------------------------------------------------------------