├── web ├── .gitignore ├── public │ ├── ss.png │ ├── ui.png │ ├── logo.png │ ├── favicon.ico │ ├── gorgeous.png │ ├── screenshot.png │ ├── vercel.svg │ ├── sooner-logo.svg │ └── next.svg ├── .vercel │ ├── project.json │ └── README.txt ├── utils │ ├── truncate.ts │ ├── fetcher.ts │ ├── time_to_human.ts │ ├── getColorForLanguage.ts │ ├── copyToClipboard.ts │ ├── axios.ts │ ├── getClassByTime.ts │ └── formatCount.ts ├── next.config.mjs ├── postcss.config.mjs ├── lib │ └── utils.ts ├── next-env.d.ts ├── pages │ ├── settings.tsx │ ├── api │ │ └── hello.ts │ ├── _document.tsx │ ├── _app.tsx │ ├── projects │ │ ├── index.tsx │ │ └── [project].tsx │ ├── index.tsx │ ├── settings │ │ ├── profile.tsx │ │ └── api-key.tsx │ ├── dashboard.tsx │ ├── promise.tsx │ ├── insights.tsx │ ├── bug-report.tsx │ ├── verify.tsx │ ├── login.tsx │ ├── pricing.tsx │ ├── signup.tsx │ └── onboarding.tsx ├── components │ ├── ui │ │ └── Card.tsx │ ├── IconThing.tsx │ ├── landing │ │ ├── TimeTracked.tsx │ │ ├── Screenshot.tsx │ │ ├── OpenSource.tsx │ │ ├── Navbar.tsx │ │ ├── Hero.tsx │ │ ├── Footer.tsx │ │ ├── TeamFeatures.tsx │ │ ├── Features.tsx │ │ └── ActivityChart.tsx │ ├── Warning.tsx │ ├── layout │ │ ├── MainLayout.tsx │ │ ├── SettingsLayout.tsx │ │ ├── ProjectsLayout.tsx │ │ └── DashboardLayout.tsx │ ├── magicui │ │ ├── ripple.tsx │ │ ├── animated-shiny-text.tsx │ │ ├── dot-pattern.tsx │ │ ├── marquee.tsx │ │ ├── border-beam.tsx │ │ ├── radial-gradient.tsx │ │ ├── bento-grid.tsx │ │ ├── linear-gradient.tsx │ │ └── shimmer-button.tsx │ ├── ProjectCard.tsx │ ├── Stats.tsx │ ├── DonutChart.tsx │ ├── Projects.tsx │ ├── BarChart.tsx │ ├── Heatmap.tsx │ └── LineChart.tsx ├── tsconfig.json ├── middleware.ts ├── styles │ └── globals.css ├── package.json ├── types.ts ├── README.md └── tailwind.config.ts ├── extensions └── vscode │ ├── README.md │ ├── .npmrc │ ├── Sooner-0.0.2.vsix │ ├── Sooner-0.0.3.vsix │ ├── Sooner-0.0.4.vsix │ ├── Sooner-0.0.5.vsix │ ├── Sooner-0.0.6.vsix │ ├── Sooner-0.0.7.vsix │ ├── Sooner-0.0.8.vsix │ ├── images │ └── logo.png │ ├── sooner-0.0.1.vsix │ ├── src │ ├── configs │ │ └── axios.ts │ ├── utils │ │ ├── branch.ts │ │ └── pulse.ts │ ├── status_bar.ts │ ├── api.ts │ └── extension.ts │ ├── .vscodeignore │ ├── .vscode │ ├── extensions.json │ ├── tasks.json │ ├── settings.json │ └── launch.json │ ├── CHANGELOG.md │ ├── .eslintrc.json │ ├── tsconfig.json │ ├── package.json │ └── vsc-extension-quickstart.md ├── .gitignore └── api ├── constants.ts ├── README.md ├── src ├── configs │ ├── plunk.ts │ └── logsnag.ts ├── drizzle │ ├── 0002_zippy_sunspot.sql │ ├── 0003_chemical_sinister_six.sql │ ├── 0001_loose_franklin_richards.sql │ ├── 0000_groovy_boom_boom.sql │ └── meta │ │ ├── _journal.json │ │ ├── 0003_snapshot.json │ │ ├── 0001_snapshot.json │ │ ├── 0002_snapshot.json │ │ └── 0000_snapshot.json ├── utils │ ├── time_to_human.ts │ ├── generators.ts │ ├── setAuthToken.ts │ ├── getCodetimeToday.ts │ └── validators.ts ├── db │ ├── index.ts │ └── schema.ts ├── controllers │ ├── auth │ │ ├── logout.ts │ │ ├── verify.ts │ │ ├── login.ts │ │ └── signup.ts │ ├── app │ │ ├── apiKey.ts │ │ ├── extension.ts │ │ └── profile.ts │ ├── pulse │ │ ├── retrieveAllPulses.ts │ │ └── createPulse.ts │ ├── codetimeToday.ts │ ├── calculateStreak.ts │ ├── extension │ │ └── activate.ts │ ├── projects │ │ ├── index.ts │ │ └── project.ts │ ├── insights.ts │ ├── activityChartData.ts │ ├── weekdayAverage.ts │ └── stats.ts ├── routes.app.ts ├── routes.auth.ts ├── script.ts ├── middlewares │ ├── authenticateAppUser.ts │ └── authenticate.ts ├── routes.ts └── index.ts ├── drizzle.config.ts ├── tsconfig.json └── package.json /web/.gitignore: -------------------------------------------------------------------------------- 1 | .vercel 2 | -------------------------------------------------------------------------------- /extensions/vscode/README.md: -------------------------------------------------------------------------------- 1 | ## Sooner 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | out 3 | .env 4 | .next -------------------------------------------------------------------------------- /extensions/vscode/.npmrc: -------------------------------------------------------------------------------- 1 | enable-pre-post-scripts = true -------------------------------------------------------------------------------- /api/constants.ts: -------------------------------------------------------------------------------- 1 | export const isProd = process.env.NODE_ENV === "prod"; 2 | -------------------------------------------------------------------------------- /web/public/ss.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sooner-run/sooner/HEAD/web/public/ss.png -------------------------------------------------------------------------------- /web/public/ui.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sooner-run/sooner/HEAD/web/public/ui.png -------------------------------------------------------------------------------- /web/public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sooner-run/sooner/HEAD/web/public/logo.png -------------------------------------------------------------------------------- /web/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sooner-run/sooner/HEAD/web/public/favicon.ico -------------------------------------------------------------------------------- /web/public/gorgeous.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sooner-run/sooner/HEAD/web/public/gorgeous.png -------------------------------------------------------------------------------- /web/public/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sooner-run/sooner/HEAD/web/public/screenshot.png -------------------------------------------------------------------------------- /api/README.md: -------------------------------------------------------------------------------- 1 | ``` 2 | npm install 3 | npm run dev 4 | ``` 5 | 6 | ``` 7 | open http://localhost:3000 8 | ``` 9 | -------------------------------------------------------------------------------- /web/.vercel/project.json: -------------------------------------------------------------------------------- 1 | {"orgId":"team_H4Uzk4QtXcVAGnYEAFDXubuz","projectId":"prj_Qtxo0lmIHQPeb85lYlllZixE5c3c"} -------------------------------------------------------------------------------- /extensions/vscode/Sooner-0.0.2.vsix: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sooner-run/sooner/HEAD/extensions/vscode/Sooner-0.0.2.vsix -------------------------------------------------------------------------------- /extensions/vscode/Sooner-0.0.3.vsix: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sooner-run/sooner/HEAD/extensions/vscode/Sooner-0.0.3.vsix -------------------------------------------------------------------------------- /extensions/vscode/Sooner-0.0.4.vsix: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sooner-run/sooner/HEAD/extensions/vscode/Sooner-0.0.4.vsix -------------------------------------------------------------------------------- /extensions/vscode/Sooner-0.0.5.vsix: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sooner-run/sooner/HEAD/extensions/vscode/Sooner-0.0.5.vsix -------------------------------------------------------------------------------- /extensions/vscode/Sooner-0.0.6.vsix: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sooner-run/sooner/HEAD/extensions/vscode/Sooner-0.0.6.vsix -------------------------------------------------------------------------------- /extensions/vscode/Sooner-0.0.7.vsix: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sooner-run/sooner/HEAD/extensions/vscode/Sooner-0.0.7.vsix -------------------------------------------------------------------------------- /extensions/vscode/Sooner-0.0.8.vsix: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sooner-run/sooner/HEAD/extensions/vscode/Sooner-0.0.8.vsix -------------------------------------------------------------------------------- /extensions/vscode/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sooner-run/sooner/HEAD/extensions/vscode/images/logo.png -------------------------------------------------------------------------------- /extensions/vscode/sooner-0.0.1.vsix: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sooner-run/sooner/HEAD/extensions/vscode/sooner-0.0.1.vsix -------------------------------------------------------------------------------- /api/src/configs/plunk.ts: -------------------------------------------------------------------------------- 1 | import Plunk from "@plunk/node"; 2 | 3 | export const plunk = new Plunk(process.env.PLUNK_TOKEN!); 4 | -------------------------------------------------------------------------------- /api/src/drizzle/0002_zippy_sunspot.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "users" ALTER COLUMN "id" SET DEFAULT 'fdd04d58-b87c-433a-8096-14d49d249d1c'; -------------------------------------------------------------------------------- /web/utils/truncate.ts: -------------------------------------------------------------------------------- 1 | export const truncate = (str: string, maxLength: number) => 2 | str.length > maxLength ? "..." + str.slice(-maxLength) : str; 3 | -------------------------------------------------------------------------------- /web/next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | }; 5 | 6 | export default nextConfig; 7 | -------------------------------------------------------------------------------- /extensions/vscode/src/configs/axios.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | 3 | export const request = axios.create({ 4 | baseURL: "https://api.sooner.run/v1", 5 | }); 6 | -------------------------------------------------------------------------------- /api/src/configs/logsnag.ts: -------------------------------------------------------------------------------- 1 | import { LogSnag } from "@logsnag/node"; 2 | 3 | export const logsnag = new LogSnag({ 4 | token: process.env.LOGSNAG_TOKEN!, 5 | project: "sooner", 6 | }); 7 | -------------------------------------------------------------------------------- /web/postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /api/src/drizzle/0003_chemical_sinister_six.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "users" ALTER COLUMN "id" SET DATA TYPE uuid;--> statement-breakpoint 2 | ALTER TABLE "users" ALTER COLUMN "id" SET DEFAULT gen_random_uuid(); -------------------------------------------------------------------------------- /web/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import clsx, { ClassValue } from "clsx"; 2 | import { twMerge } from "tailwind-merge"; 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | -------------------------------------------------------------------------------- /extensions/vscode/.vscodeignore: -------------------------------------------------------------------------------- 1 | .vscode/** 2 | .vscode-test/** 3 | src/** 4 | .gitignore 5 | .yarnrc 6 | vsc-extension-quickstart.md 7 | **/tsconfig.json 8 | **/.eslintrc.json 9 | **/*.map 10 | **/*.ts -------------------------------------------------------------------------------- /api/src/drizzle/0001_loose_franklin_richards.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "users" DROP CONSTRAINT "users_id_unique";--> statement-breakpoint 2 | ALTER TABLE "users" ALTER COLUMN "id" SET DEFAULT 'e599566f-4ee0-4b77-a4b6-43f9f7fddcc4'; -------------------------------------------------------------------------------- /extensions/vscode/.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See http://go.microsoft.com/fwlink/?LinkId=827846 3 | // for the documentation about the extensions.json format 4 | "recommendations": [ 5 | "dbaeumer.vscode-eslint" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /web/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /web/utils/fetcher.ts: -------------------------------------------------------------------------------- 1 | import { axios } from "./axios"; 2 | 3 | export const fetcher = async (url: string) => { 4 | try { 5 | const res = await axios.get(url); 6 | return res.data; 7 | } catch (error) { 8 | throw error; 9 | } 10 | }; 11 | -------------------------------------------------------------------------------- /extensions/vscode/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to the "sooner" extension will be documented in this file. 4 | 5 | Check [Keep a Changelog](http://keepachangelog.com/) for recommendations on how to structure this file. 6 | 7 | ## [Unreleased] 8 | 9 | - Initial release -------------------------------------------------------------------------------- /web/utils/time_to_human.ts: -------------------------------------------------------------------------------- 1 | export const time_to_human = (time: number) => { 2 | const hours = Math.floor(time / 3600000); 3 | const minutes = Math.floor((time % 3600000) / 60000); 4 | const seconds = Math.floor((time % 60000) / 1000); 5 | 6 | return `${hours}h ${minutes}m ${seconds}s`; 7 | }; 8 | -------------------------------------------------------------------------------- /api/src/utils/time_to_human.ts: -------------------------------------------------------------------------------- 1 | export const time_to_human = (time: number) => { 2 | const hours = Math.floor(time / 3600000); 3 | const minutes = Math.floor((time % 3600000) / 60000); 4 | const seconds = Math.floor((time % 60000) / 1000); 5 | 6 | return `${hours}h ${minutes}m ${seconds}s`; 7 | }; 8 | -------------------------------------------------------------------------------- /api/drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "drizzle-kit"; 2 | 3 | export default defineConfig({ 4 | schema: "./src/db/schema.ts", 5 | out: "./src/drizzle", 6 | dialect: "postgresql", 7 | dbCredentials: { 8 | url: process.env.DB_URL!, 9 | }, 10 | verbose: true, 11 | strict: true, 12 | }); 13 | -------------------------------------------------------------------------------- /web/pages/settings.tsx: -------------------------------------------------------------------------------- 1 | import { useRouter } from "next/router"; 2 | import React from "react"; 3 | 4 | const Settings = () => { 5 | const router = useRouter(); 6 | 7 | React.useEffect(() => { 8 | router.push("/settings/profile"); 9 | }, []); 10 | 11 | return <>; 12 | }; 13 | 14 | export default Settings; 15 | -------------------------------------------------------------------------------- /api/src/db/index.ts: -------------------------------------------------------------------------------- 1 | import { drizzle } from "drizzle-orm/node-postgres"; 2 | 3 | import * as schema from "./schema"; 4 | import dotenv from "dotenv"; 5 | import { Pool } from "pg"; 6 | 7 | dotenv.config(); 8 | 9 | const pool = new Pool({ 10 | connectionString: process.env.DB_URL, 11 | }); 12 | 13 | export const db = drizzle(pool, { schema }); 14 | -------------------------------------------------------------------------------- /api/src/controllers/auth/logout.ts: -------------------------------------------------------------------------------- 1 | import { Context } from "hono"; 2 | import { deleteCookie } from "hono/cookie"; 3 | 4 | export const Logout = (c: Context) => { 5 | try { 6 | deleteCookie(c, "sooner.auth-token"); 7 | 8 | return c.json({ message: "Logged out" }, 200); 9 | } catch (error) { 10 | return c.json({ message: "Something went wrong" }, 500); 11 | } 12 | }; 13 | -------------------------------------------------------------------------------- /api/src/routes.app.ts: -------------------------------------------------------------------------------- 1 | import { Hono } from "hono"; 2 | import { ApiKey } from "./controllers/app/apiKey"; 3 | import { Profile } from "./controllers/app/profile"; 4 | import { Extenison } from "./controllers/app/extension"; 5 | 6 | export const router = new Hono(); 7 | 8 | router.get("/api-key", ApiKey); 9 | router.get("/profile", Profile); 10 | router.get("/extension", Extenison); 11 | -------------------------------------------------------------------------------- /web/pages/api/hello.ts: -------------------------------------------------------------------------------- 1 | // Next.js API route support: https://nextjs.org/docs/api-routes/introduction 2 | import type { NextApiRequest, NextApiResponse } from "next"; 3 | 4 | type Data = { 5 | name: string; 6 | }; 7 | 8 | export default function handler( 9 | req: NextApiRequest, 10 | res: NextApiResponse, 11 | ) { 12 | res.status(200).json({ name: "John Doe" }); 13 | } 14 | -------------------------------------------------------------------------------- /web/utils/getColorForLanguage.ts: -------------------------------------------------------------------------------- 1 | import colors from "../colors.json"; 2 | 3 | const languageColors = Object.fromEntries( 4 | Object.entries(colors).map(([key, value]) => [key, value]) 5 | ); 6 | 7 | export const getColorForLanguage = (language: string) => { 8 | if (languageColors[language]) { 9 | return languageColors[language].color; 10 | } 11 | return "#000000"; 12 | }; 13 | -------------------------------------------------------------------------------- /api/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2021", 4 | "module": "CommonJS", 5 | "esModuleInterop": true, 6 | "forceConsistentCasingInFileNames": true, 7 | "resolveJsonModule": true, 8 | "strict": true, 9 | "skipLibCheck": true, 10 | "rootDir": "./", 11 | "outDir": "./dist", 12 | "typeRoots": ["./types", "./node_modules/@types"] 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /web/components/ui/Card.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, ReactNode } from "react"; 2 | 3 | const Card: FC<{ children: ReactNode; className?: string }> = ({ 4 | children, 5 | className, 6 | }) => { 7 | return ( 8 |
11 | {children} 12 |
13 | ); 14 | }; 15 | 16 | export default Card; 17 | -------------------------------------------------------------------------------- /api/src/utils/generators.ts: -------------------------------------------------------------------------------- 1 | export const generateAlphaNumeric = (length: number = 6): string => { 2 | const characters = 3 | "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; 4 | let otp = ""; 5 | 6 | for (let i = 0; i < length; i++) { 7 | const randomIndex = Math.floor(Math.random() * characters.length); 8 | otp += characters[randomIndex]; 9 | } 10 | 11 | return otp; 12 | }; 13 | -------------------------------------------------------------------------------- /web/utils/copyToClipboard.ts: -------------------------------------------------------------------------------- 1 | export const copyToClipboard = (text: string): void => { 2 | const textarea = document.createElement("textarea"); 3 | 4 | textarea.value = text; 5 | 6 | textarea.style.position = "fixed"; 7 | textarea.style.opacity = "0"; 8 | 9 | document.body.appendChild(textarea); 10 | 11 | textarea.select(); 12 | 13 | document.execCommand("copy"); 14 | 15 | document.body.removeChild(textarea); 16 | }; 17 | -------------------------------------------------------------------------------- /extensions/vscode/src/utils/branch.ts: -------------------------------------------------------------------------------- 1 | import simpleGit from "simple-git"; 2 | 3 | const git = simpleGit(); 4 | 5 | export const getCurrentBranch = async ( 6 | path: string 7 | ): Promise => { 8 | if (path) { 9 | try { 10 | const branchSummary = await git.cwd(path).branch(); 11 | return branchSummary.current; 12 | } catch (error) { 13 | return null; 14 | } 15 | } 16 | return null; 17 | }; 18 | -------------------------------------------------------------------------------- /api/src/routes.auth.ts: -------------------------------------------------------------------------------- 1 | import { Hono } from "hono"; 2 | import { Signup } from "./controllers/auth/signup"; 3 | import { Login } from "./controllers/auth/login"; 4 | import { Logout } from "./controllers/auth/logout"; 5 | import { Verify } from "./controllers/auth/verify"; 6 | 7 | export const router = new Hono(); 8 | 9 | router.post("/signup", Signup); 10 | router.post("/login", Login); 11 | router.post("/logout", Logout); 12 | router.post("/verify", Verify); 13 | -------------------------------------------------------------------------------- /web/components/IconThing.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from "react"; 2 | import { IconType } from "react-icons"; 3 | 4 | const IconThing: FC<{ icon: IconType; className?: string }> = ({ 5 | icon, 6 | className, 7 | }) => { 8 | return ( 9 |
12 | {icon({ size: 20 })} 13 |
14 | ); 15 | }; 16 | 17 | export default IconThing; 18 | -------------------------------------------------------------------------------- /extensions/vscode/.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | // See https://go.microsoft.com/fwlink/?LinkId=733558 2 | // for the documentation about the tasks.json format 3 | { 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "type": "npm", 8 | "script": "watch", 9 | "problemMatcher": "$tsc-watch", 10 | "isBackground": true, 11 | "presentation": { 12 | "reveal": "never" 13 | }, 14 | "group": { 15 | "kind": "build", 16 | "isDefault": true 17 | } 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /web/components/landing/TimeTracked.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const TimeTracked = () => { 4 | return ( 5 |
6 |

7 | 4,200 8 |

9 |

10 | Minutes tracked 11 |

12 |
13 | ); 14 | }; 15 | 16 | export default TimeTracked; 17 | -------------------------------------------------------------------------------- /api/src/utils/setAuthToken.ts: -------------------------------------------------------------------------------- 1 | import { Context } from "hono"; 2 | import { setCookie } from "hono/cookie"; 3 | import { isProd } from "../../constants"; 4 | import dayjs from "dayjs"; 5 | 6 | export const SetAuthToken = (c: Context, token: string) => { 7 | setCookie(c, "sooner.auth-token", token, { 8 | domain: isProd ? ".sooner.run" : "localhost", 9 | secure: isProd, 10 | sameSite: isProd ? "None" : "Strict", 11 | expires: dayjs().add(90, "days").toDate(), 12 | httpOnly: true, 13 | }); 14 | }; 15 | -------------------------------------------------------------------------------- /extensions/vscode/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | // Place your settings in this file to overwrite default and user settings. 2 | { 3 | "files.exclude": { 4 | "out": false // set this to true to hide the "out" folder with the compiled JS files 5 | }, 6 | "search.exclude": { 7 | "out": true // set this to false to include "out" folder in search results 8 | }, 9 | // Turn off tsc task auto detection since we have the necessary tasks as npm scripts 10 | "typescript.tsc.autoDetect": "off" 11 | } -------------------------------------------------------------------------------- /web/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import { Html, Head, Main, NextScript } from "next/document"; 2 | 3 | export default function Document() { 4 | return ( 5 | 6 | 7 | 13 | 14 | 15 |
16 | 17 | 18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /extensions/vscode/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "parserOptions": { 5 | "ecmaVersion": 6, 6 | "sourceType": "module" 7 | }, 8 | "plugins": ["@typescript-eslint"], 9 | "rules": { 10 | "@typescript-eslint/naming-convention": "warn", 11 | "@typescript-eslint/semi": "warn", 12 | "curly": "warn", 13 | "eqeqeq": "warn", 14 | "no-throw-literal": "warn", 15 | "semi": "off" 16 | }, 17 | "ignorePatterns": ["out", "dist", "**/*.d.ts"] 18 | } 19 | -------------------------------------------------------------------------------- /api/src/controllers/app/apiKey.ts: -------------------------------------------------------------------------------- 1 | import { Context } from "hono"; 2 | import { db } from "../../db"; 3 | import { users } from "../../db/schema"; 4 | import { eq } from "drizzle-orm"; 5 | 6 | export const ApiKey = async (c: Context) => { 7 | try { 8 | const [user] = await db 9 | .select() 10 | .from(users) 11 | .where(eq(users.id, c.get("auth.user_id"))); 12 | return c.json({ message: "API key retrieved", key: user.api_key }, 200); 13 | } catch (error) { 14 | return c.json({ message: "Something went wrong." }, 500); 15 | } 16 | }; 17 | -------------------------------------------------------------------------------- /api/src/controllers/app/extension.ts: -------------------------------------------------------------------------------- 1 | import { Context } from "hono"; 2 | import { db } from "../../db"; 3 | import { users } from "../../db/schema"; 4 | import { eq } from "drizzle-orm"; 5 | 6 | export const Extenison = async (c: Context) => { 7 | try { 8 | const [user] = await db 9 | .select() 10 | .from(users) 11 | .where(eq(users.id, c.get("auth.user_id"))); 12 | return c.json({ activated: user.is_extension_activated }, 200); 13 | } catch (error) { 14 | return c.json({ message: "Something went wrong." }, 500); 15 | } 16 | }; 17 | -------------------------------------------------------------------------------- /web/.vercel/README.txt: -------------------------------------------------------------------------------- 1 | > Why do I have a folder named ".vercel" in my project? 2 | The ".vercel" folder is created when you link a directory to a Vercel project. 3 | 4 | > What does the "project.json" file contain? 5 | The "project.json" file contains: 6 | - The ID of the Vercel project that you linked ("projectId") 7 | - The ID of the user or team your Vercel project is owned by ("orgId") 8 | 9 | > Should I commit the ".vercel" folder? 10 | No, you should not share the ".vercel" folder with anyone. 11 | Upon creation, it will be automatically added to your ".gitignore" file. 12 | -------------------------------------------------------------------------------- /web/utils/axios.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | 3 | const axiosReq = axios.create({ 4 | baseURL: process.env.NEXT_PUBLIC_API_BASE_URL, 5 | withCredentials: true, 6 | }); 7 | 8 | const axiosPublic = axios.create({ 9 | baseURL: process.env.NEXT_PUBLIC_API_BASE_URL, 10 | }); 11 | 12 | const newAbortSignal = (timeoutMs: number) => { 13 | const abortController = new AbortController(); 14 | setTimeout(() => abortController.abort(), timeoutMs || 0); 15 | 16 | return abortController.signal; 17 | }; 18 | 19 | export { axiosReq as axios, axiosPublic, newAbortSignal }; 20 | -------------------------------------------------------------------------------- /web/components/Warning.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { HiMiniInformationCircle } from "react-icons/hi2"; 3 | 4 | const Warning = ({ text }: { text: string }) => { 5 | return ( 6 |
7 | 8 |
9 |

Heads up!

10 |

{text}

11 |
12 |
13 | ); 14 | }; 15 | 16 | export default Warning; 17 | -------------------------------------------------------------------------------- /web/components/landing/Screenshot.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { BorderBeam } from "../magicui/border-beam"; 3 | 4 | const Screenshot = () => { 5 | return ( 6 |
7 | 8 | 9 |
10 |
11 | ); 12 | }; 13 | 14 | export default Screenshot; 15 | -------------------------------------------------------------------------------- /web/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import { AppProps } from "next/app"; 2 | import "@/styles/globals.css"; 3 | import { AnimatePresence } from "framer-motion"; 4 | import { LogSnagProvider } from "@logsnag/next"; 5 | 6 | export default function App({ Component, pageProps, router }: AppProps) { 7 | return ( 8 | 12 | 13 | 14 | 15 | 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /api/src/controllers/app/profile.ts: -------------------------------------------------------------------------------- 1 | import { Context } from "hono"; 2 | import { db } from "../../db"; 3 | import { users } from "../../db/schema"; 4 | import { eq } from "drizzle-orm"; 5 | 6 | export const Profile = async (c: Context) => { 7 | try { 8 | const [user] = await db 9 | .select({ 10 | username: users.username, 11 | email: users.email, 12 | }) 13 | .from(users) 14 | .where(eq(users.id, c.get("auth.user_id"))); 15 | return c.json(user, 200); 16 | } catch (error) { 17 | return c.json({ message: "Something went wrong." }, 500); 18 | } 19 | }; 20 | -------------------------------------------------------------------------------- /web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "dom.iterable", "esnext"], 4 | "allowJs": true, 5 | "skipLibCheck": true, 6 | "strict": true, 7 | "downlevelIteration": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "paths": { 17 | "@/*": ["./*"] 18 | } 19 | }, 20 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 21 | "exclude": ["node_modules"] 22 | } 23 | -------------------------------------------------------------------------------- /web/public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /web/public/sooner-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /api/src/controllers/pulse/retrieveAllPulses.ts: -------------------------------------------------------------------------------- 1 | import { Context } from "hono"; 2 | import { db } from "../../db"; 3 | import { pulses } from "../../db/schema"; 4 | import { desc, eq } from "drizzle-orm"; 5 | 6 | export const RetrieveAllPulses = async (c: Context) => { 7 | try { 8 | const _pulses = await db 9 | .select() 10 | .from(pulses) 11 | .where(eq(pulses.user_id, c.get("user_id"))) 12 | .orderBy(desc(pulses.created_at)); 13 | 14 | return c.json({ pulses: _pulses }, 200); 15 | } catch (error) { 16 | console.log(error); 17 | return c.json({ message: "Something went wrong." }, 500); 18 | } 19 | }; 20 | -------------------------------------------------------------------------------- /web/pages/projects/index.tsx: -------------------------------------------------------------------------------- 1 | import ProjectCard from "@/components/ProjectCard"; 2 | import ProjectsLayout from "@/components/layout/ProjectsLayout"; 3 | import { fetcher } from "@/utils/fetcher"; 4 | import useSWR from "swr"; 5 | 6 | const Projects = () => { 7 | const { data: projects } = useSWR("/v1/projects", fetcher); 8 | return ( 9 | 10 |
11 | {projects?.map((project: any, i: number) => ( 12 | 13 | ))} 14 |
15 |
16 | ); 17 | }; 18 | 19 | export default Projects; 20 | -------------------------------------------------------------------------------- /api/src/controllers/codetimeToday.ts: -------------------------------------------------------------------------------- 1 | import { Context } from "hono"; 2 | import { time_to_human } from "../utils/time_to_human"; 3 | import { GetCodeTimeToday } from "../utils/getCodetimeToday"; 4 | 5 | export const CodetimeToday = async (c: Context) => { 6 | try { 7 | const totalCodingTime = await GetCodeTimeToday(c.get("user_id")); 8 | 9 | return c.json( 10 | { 11 | time: Number(totalCodingTime), 12 | time_human_readable: time_to_human(Number(totalCodingTime)), 13 | }, 14 | 200 15 | ); 16 | } catch (error) { 17 | console.log(error); 18 | return c.json({ message: "Something went wrong." }, 500); 19 | } 20 | }; 21 | -------------------------------------------------------------------------------- /extensions/vscode/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "Node16", 4 | "target": "ES2020", 5 | "outDir": "out", 6 | "lib": ["ES2020"], 7 | "sourceMap": true, 8 | "rootDir": "src", 9 | "strict": true /* enable all strict type-checking options */, 10 | "moduleResolution": "Node16" 11 | /* Additional Checks */ 12 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 13 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 14 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /api/src/script.ts: -------------------------------------------------------------------------------- 1 | import { eq } from "drizzle-orm"; 2 | import { db } from "./db"; 3 | import { pulses } from "./db/schema"; 4 | 5 | const deleteAllPulses = async () => { 6 | await db.delete(pulses); 7 | }; 8 | 9 | const replaceLanguage = async () => { 10 | await db 11 | .update(pulses) 12 | .set({ 13 | language: "CSS", 14 | }) 15 | .where(eq(pulses.language, "css")); 16 | }; 17 | 18 | const deleteMassiveShit = async () => { 19 | await db 20 | .delete(pulses) 21 | .where(eq(pulses.path, "")) 22 | .then(() => { 23 | console.log("Deleted"); 24 | }); 25 | }; 26 | 27 | // deleteMassiveShit(); 28 | // deleteAllPulses(); 29 | // replaceLanguage(); 30 | -------------------------------------------------------------------------------- /web/components/layout/MainLayout.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | import { motion } from "framer-motion"; 3 | 4 | const variants = { 5 | hidden: { opacity: 0 }, 6 | enter: { opacity: 1, transition: { duration: 0.3 } }, 7 | exit: { opacity: 0, transition: { duration: 0.3 } }, 8 | }; 9 | 10 | interface MainLayoutProps { 11 | children: ReactNode; 12 | } 13 | 14 | const MainLayout: React.FC = ({ children }) => ( 15 | 22 | {children} 23 | 24 | ); 25 | 26 | export default MainLayout; 27 | -------------------------------------------------------------------------------- /web/utils/getClassByTime.ts: -------------------------------------------------------------------------------- 1 | export const getClassByTime = (count: number): string => { 2 | const hours = count / (1000 * 60 * 60); 3 | 4 | switch (true) { 5 | case hours == 0: 6 | return "color_scale-0"; 7 | case hours < 1: 8 | return "color-scale-1"; 9 | case hours >= 1 && hours <= 3: 10 | return "color-scale-2"; 11 | case hours > 3 && hours <= 8: 12 | return "color-scale-3"; 13 | case hours > 8 && hours <= 10: 14 | return "color-scale-4"; 15 | case hours > 10 && hours <= 13: 16 | return "color-scale-5"; 17 | case hours > 13: 18 | return "color-scale-5"; 19 | default: 20 | return "color-scale-0"; 21 | } 22 | }; 23 | -------------------------------------------------------------------------------- /extensions/vscode/src/utils/pulse.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/naming-convention */ 2 | import { request } from "../configs/axios"; 3 | import { updateStatusBarText } from "../status_bar"; 4 | 5 | interface Props { 6 | api_key: string; 7 | payload: {}; 8 | } 9 | 10 | export const sendPulse = async ({ api_key, payload }: Props) => { 11 | if (api_key) { 12 | try { 13 | const { data } = await request.post("/pulses", payload, { 14 | headers: { 15 | Authorization: `Bearer ${api_key}`, 16 | }, 17 | }); 18 | updateStatusBarText(data.codetime_today); 19 | } catch (error) { 20 | console.error("Error sending pulse:", error); 21 | } 22 | } 23 | }; 24 | -------------------------------------------------------------------------------- /api/src/drizzle/0000_groovy_boom_boom.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS "users" ( 2 | "id" text PRIMARY KEY DEFAULT '4e7fa268-8bd4-4929-8c53-64a93de519bd' NOT NULL, 3 | "username" text, 4 | "password" text, 5 | "avatar" text, 6 | "github" text, 7 | "twitter" text, 8 | "website" text, 9 | "email" text NOT NULL, 10 | "created_at" timestamp DEFAULT now(), 11 | "updated_at" timestamp DEFAULT now(), 12 | "otp" text, 13 | "otp_expires_at" timestamp, 14 | "display_name" text, 15 | "is_profile_public" boolean DEFAULT false, 16 | "display_codetime_publicly" boolean DEFAULT false, 17 | CONSTRAINT "users_id_unique" UNIQUE("id"), 18 | CONSTRAINT "users_username_unique" UNIQUE("username"), 19 | CONSTRAINT "users_email_unique" UNIQUE("email") 20 | ); 21 | -------------------------------------------------------------------------------- /api/src/middlewares/authenticateAppUser.ts: -------------------------------------------------------------------------------- 1 | import { Context, MiddlewareHandler } from "hono"; 2 | import { getCookie } from "hono/cookie"; 3 | import { verify } from "jsonwebtoken"; 4 | 5 | export const AuthenticateAppUser: MiddlewareHandler = async ( 6 | c: Context, 7 | next 8 | ) => { 9 | try { 10 | const auth_token = getCookie(c, "sooner.auth-token"); 11 | 12 | if (!auth_token) { 13 | return c.json({ message: "Auth token is not present." }, 400); 14 | } 15 | 16 | const decoded = verify(auth_token, process.env.JWT_SECRET!) as { 17 | id: string; 18 | }; 19 | 20 | c.set("auth.user_id", decoded.id); 21 | 22 | await next(); 23 | } catch (error) { 24 | return c.json({ message: "Forbidden: Invalid token" }, 403); 25 | } 26 | }; 27 | -------------------------------------------------------------------------------- /api/src/drizzle/meta/_journal.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "6", 3 | "dialect": "postgresql", 4 | "entries": [ 5 | { 6 | "idx": 0, 7 | "version": "6", 8 | "when": 1716472877113, 9 | "tag": "0000_groovy_boom_boom", 10 | "breakpoints": true 11 | }, 12 | { 13 | "idx": 1, 14 | "version": "6", 15 | "when": 1716472959961, 16 | "tag": "0001_loose_franklin_richards", 17 | "breakpoints": true 18 | }, 19 | { 20 | "idx": 2, 21 | "version": "6", 22 | "when": 1716472966591, 23 | "tag": "0002_zippy_sunspot", 24 | "breakpoints": true 25 | }, 26 | { 27 | "idx": 3, 28 | "version": "6", 29 | "when": 1716473107411, 30 | "tag": "0003_chemical_sinister_six", 31 | "breakpoints": true 32 | } 33 | ] 34 | } -------------------------------------------------------------------------------- /web/utils/formatCount.ts: -------------------------------------------------------------------------------- 1 | export const formatCount = ( 2 | count: number, 3 | abbreviated: boolean = false 4 | ): string => { 5 | const hours = Math.floor(count / (1000 * 60 * 60)); 6 | const minutes = Math.floor((count % (1000 * 60 * 60)) / (1000 * 60)); 7 | 8 | if (hours === 0 && minutes === 0) { 9 | return "0 minutes"; 10 | } 11 | 12 | if (abbreviated) { 13 | if (hours === 0) { 14 | return `${minutes}m`; 15 | } else if (minutes === 0) { 16 | return `${hours}h`; 17 | } else { 18 | return `${hours}h ${minutes}m`; 19 | } 20 | } else { 21 | if (hours === 0) { 22 | return `${minutes} minutes`; 23 | } else if (minutes === 0) { 24 | return `${hours} hours`; 25 | } else { 26 | return `${hours} hours ${minutes} minutes`; 27 | } 28 | } 29 | }; 30 | -------------------------------------------------------------------------------- /api/src/utils/getCodetimeToday.ts: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | import { db } from "../db"; 3 | import { and, eq, gte, lte, sum } from "drizzle-orm"; 4 | import { pulses, users } from "../db/schema"; 5 | 6 | export const GetCodeTimeToday = async (userId: string) => { 7 | try { 8 | const startOfToday = dayjs().startOf("day").toDate(); 9 | const endOfToday = dayjs().endOf("day").toDate(); 10 | 11 | const [record] = await db 12 | .select({ 13 | time: sum(pulses.time), 14 | }) 15 | .from(pulses) 16 | .where( 17 | and( 18 | eq(pulses.user_id, userId), 19 | gte(pulses.created_at, startOfToday), 20 | lte(pulses.created_at, endOfToday) 21 | ) 22 | ); 23 | 24 | return Number(record?.time || 0); 25 | } catch (error) { 26 | return 0; 27 | } 28 | }; 29 | -------------------------------------------------------------------------------- /api/src/routes.ts: -------------------------------------------------------------------------------- 1 | import { Hono } from "hono"; 2 | import { RetrieveProjects } from "./controllers/projects"; 3 | import { Insights } from "./controllers/insights"; 4 | import { Stats } from "./controllers/stats"; 5 | import { CreatePulse } from "./controllers/pulse/createPulse"; 6 | import { RetrieveAllPulses } from "./controllers/pulse/retrieveAllPulses"; 7 | import { CodetimeToday } from "./controllers/codetimeToday"; 8 | import { RetrieveSingleProject } from "./controllers/projects/project"; 9 | import { ActivateExtension } from "./controllers/extension/activate"; 10 | 11 | export const router = new Hono(); 12 | 13 | router.post("/pulses", CreatePulse); 14 | router.get("/pulses", RetrieveAllPulses); 15 | router.get("/projects", RetrieveProjects); 16 | router.get("/projects/:project", RetrieveSingleProject); 17 | router.get("/codetime-today", CodetimeToday); 18 | router.get("/insights", Insights); 19 | router.get("/stats", Stats); 20 | -------------------------------------------------------------------------------- /web/components/magicui/ripple.tsx: -------------------------------------------------------------------------------- 1 | import React, { CSSProperties } from "react"; 2 | 3 | // Modify these 4 | const MAIN_CIRCLE_SIZE = 210; 5 | const MAIN_CIRCLE_OPACITY = 0.24; 6 | const NUM_CIRCLES = 8; 7 | 8 | const Ripple = React.memo(() => { 9 | return ( 10 |
11 | {Array.from({ length: NUM_CIRCLES }, (_, i) => ( 12 |
24 | ))} 25 |
26 | ); 27 | }); 28 | 29 | export default Ripple; 30 | -------------------------------------------------------------------------------- /web/components/ProjectCard.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import Card from "./ui/Card"; 3 | import { GoDotFill } from "react-icons/go"; 4 | import { getColorForLanguage } from "@/utils/getColorForLanguage"; 5 | 6 | const ProjectCard = ({ 7 | name, 8 | time, 9 | top_language, 10 | time_human_readable, 11 | url, 12 | }: { 13 | name: string; 14 | time: string; 15 | top_language: string; 16 | time_human_readable: string; 17 | url: string; 18 | }) => { 19 | return ( 20 | 21 | 22 |

{name}

23 |

{time_human_readable}

24 |
25 | 26 |

{top_language}

27 |
28 |
29 | 30 | ); 31 | }; 32 | 33 | export default ProjectCard; 34 | -------------------------------------------------------------------------------- /extensions/vscode/.vscode/launch.json: -------------------------------------------------------------------------------- 1 | // A launch configuration that compiles the extension and then opens it inside a new window 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | { 6 | "version": "0.2.0", 7 | "configurations": [ 8 | { 9 | "name": "Run Extension", 10 | "type": "extensionHost", 11 | "request": "launch", 12 | "args": [ 13 | "--extensionDevelopmentPath=${workspaceFolder}" 14 | ], 15 | "outFiles": [ 16 | "${workspaceFolder}/out/**/*.js" 17 | ], 18 | "preLaunchTask": "${defaultBuildTask}" 19 | }, 20 | { 21 | "name": "Extension Tests", 22 | "type": "extensionHost", 23 | "request": "launch", 24 | "args": [ 25 | "--extensionDevelopmentPath=${workspaceFolder}", 26 | "--extensionTestsPath=${workspaceFolder}/out/test/suite/index" 27 | ], 28 | "outFiles": [ 29 | "${workspaceFolder}/out/test/**/*.js" 30 | ], 31 | "preLaunchTask": "${defaultBuildTask}" 32 | } 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /api/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "start": "tsx src/index.ts", 4 | "dev": "nodemon src/index.ts", 5 | "db-push": "npx drizzle-kit push", 6 | "db": "npx drizzle-kit generate", 7 | "studio": "npx drizzle-kit studio" 8 | }, 9 | "packageManager": "pnpm@9.1.2", 10 | "dependencies": { 11 | "@hono/node-server": "^1.11.1", 12 | "@logsnag/node": "^1.0.1", 13 | "@plunk/node": "^3.0.2", 14 | "bcryptjs": "^2.4.3", 15 | "dayjs": "^1.11.11", 16 | "dotenv": "^16.4.5", 17 | "drizzle-orm": "^0.30.10", 18 | "hono": "^4.3.9", 19 | "jsonwebtoken": "^9.0.2", 20 | "pg": "^8.11.5", 21 | "postgres": "^3.4.4", 22 | "uuid": "^9.0.1", 23 | "validator": "^13.12.0" 24 | }, 25 | "devDependencies": { 26 | "@types/bcryptjs": "^2.4.6", 27 | "@types/jsonwebtoken": "^9.0.6", 28 | "@types/node": "^20.11.17", 29 | "@types/pg": "^8.11.6", 30 | "@types/validator": "^13.11.10", 31 | "drizzle-kit": "^0.21.4", 32 | "nodemon": "^3.1.0", 33 | "ts-node": "^10.9.2", 34 | "tsx": "^4.7.1", 35 | "typescript": "^5.4.5" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /web/middleware.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from "next/server"; 2 | 3 | const middleware = async (req: NextRequest) => { 4 | const token = req.cookies.get("sooner.auth-token")?.value; 5 | 6 | if (!token) { 7 | const allowedPaths = [ 8 | "/login", 9 | "/", 10 | "/docs", 11 | "/pricing", 12 | "/promise", 13 | "/faq", 14 | "/changelog", 15 | "/signup", 16 | "/verify", 17 | ]; 18 | 19 | if (!allowedPaths.includes(req.nextUrl.pathname)) { 20 | return NextResponse.redirect(new URL("/login", req.url)); 21 | } 22 | } 23 | 24 | if (token) { 25 | if (req.url.includes("/login")) 26 | return NextResponse.redirect(new URL("/dashboard", req.url)); 27 | 28 | if (req.url.includes("/signup")) 29 | return NextResponse.redirect(new URL("/dashboard", req.url)); 30 | } 31 | }; 32 | 33 | export const config = { 34 | matcher: [ 35 | "/((?!api/|_next/|_proxy/|_static|_vercel|favicon.ico|sitemap.xml|robots.txt|sooner-logo.svg|logo.png|screenshot.png|ss.png|ui.png|gorgeous.png).*)", 36 | ], 37 | }; 38 | 39 | export default middleware; 40 | -------------------------------------------------------------------------------- /extensions/vscode/src/status_bar.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | 3 | let statusBar: vscode.StatusBarItem; 4 | 5 | export const initializeStatusBar = (context: vscode.ExtensionContext) => { 6 | statusBar = vscode.window.createStatusBarItem( 7 | vscode.StatusBarAlignment.Right, 8 | 100 9 | ); 10 | updateStatusBarText(0); 11 | statusBar.show(); 12 | context.subscriptions.push(statusBar); 13 | 14 | statusBar.command = "sooner.clickStatusBar"; 15 | }; 16 | 17 | export const updateStatusBarText = (totalCodingTime: number) => { 18 | const configuration = vscode.workspace.getConfiguration(); 19 | const apiKey = configuration.get("sooner.apiKey"); 20 | 21 | if (apiKey) { 22 | const hours = Math.floor(totalCodingTime / 3600000); 23 | const minutes = Math.floor((totalCodingTime % 3600000) / 60000); 24 | const seconds = Math.floor((totalCodingTime % 60000) / 1000); 25 | 26 | statusBar.text = `Coding time: ${hours}h ${minutes}m ${seconds}s`; 27 | statusBar.tooltip = ""; 28 | } else { 29 | statusBar.text = "Activate Sooner"; 30 | statusBar.tooltip = "Click to enter API key"; 31 | } 32 | }; 33 | -------------------------------------------------------------------------------- /web/components/magicui/animated-shiny-text.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | import { CSSProperties, FC, ReactNode } from "react"; 3 | 4 | interface AnimatedShinyTextProps { 5 | children: ReactNode; 6 | className?: string; 7 | shimmerWidth?: number; 8 | } 9 | 10 | const AnimatedShinyText: FC = ({ 11 | children, 12 | className, 13 | shimmerWidth = 100, 14 | }) => { 15 | return ( 16 |

34 | {children} 35 |

36 | ); 37 | }; 38 | 39 | export default AnimatedShinyText; 40 | -------------------------------------------------------------------------------- /web/styles/globals.css: -------------------------------------------------------------------------------- 1 | @import url(https://fonts.googleapis.com/css?family=Inter:100,200,300,regular,500,600,700,800,900); 2 | 3 | @tailwind base; 4 | @tailwind components; 5 | @tailwind utilities; 6 | 7 | input::-ms-reveal, 8 | input::-ms-clear { 9 | display: none; 10 | } 11 | 12 | body { 13 | font-family: Inter; 14 | overflow-x: hidden; 15 | background-color: #09090b; 16 | } 17 | /* 18 | #__next { 19 | overflow-x: hidden; 20 | } */ 21 | 22 | .color-scale-1 { 23 | fill: #e0d1f5; 24 | } 25 | .color-scale-2 { 26 | fill: #c2a3eb; 27 | } 28 | .color-scale-3 { 29 | fill: #a374e0; 30 | } 31 | .color-scale-4 { 32 | fill: #8546d6; 33 | } 34 | .color-scale-5 { 35 | fill: #6610f2; 36 | } 37 | .color-scale-0 { 38 | fill: #09090b; 39 | } 40 | 41 | .react-calendar-heatmap text { 42 | font-size: 10px; 43 | fill: #aaa; 44 | } 45 | 46 | .landing .react-calendar-heatmap text { 47 | font-size: 7px; 48 | fill: #aaa; 49 | } 50 | 51 | input:-webkit-autofill, 52 | input:-webkit-autofill:hover, 53 | input:-webkit-autofill:focus, 54 | input:-webkit-autofill:active { 55 | -webkit-box-shadow: 0 0 0 30px #09090b inset !important; 56 | -webkit-text-fill-color: white !important; 57 | } 58 | -------------------------------------------------------------------------------- /web/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import ActivityChart from "@/components/landing/ActivityChart"; 2 | import Features from "@/components/landing/Features"; 3 | import Footer from "@/components/landing/Footer"; 4 | import Hero from "@/components/landing/Hero"; 5 | import Navbar from "@/components/landing/Navbar"; 6 | import OpenSource from "@/components/landing/OpenSource"; 7 | import Screenshot from "@/components/landing/Screenshot"; 8 | import TeamFeatures from "@/components/landing/TeamFeatures"; 9 | import TimeTracked from "@/components/landing/TimeTracked"; 10 | import Head from "next/head"; 11 | 12 | export default function Index() { 13 | return ( 14 |
15 | 16 | Sooner ~ Time tracking for devs and software teams. 17 | 21 | 22 | 23 | 24 | 25 | {/* */} 26 | {/* */} 27 | {/* */} 28 | {/* */} 29 | 30 |
31 |
32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /api/src/middlewares/authenticate.ts: -------------------------------------------------------------------------------- 1 | import { Context, MiddlewareHandler } from "hono"; 2 | import { db } from "../db"; 3 | import { users } from "../db/schema"; 4 | import { eq } from "drizzle-orm"; 5 | import { getCookie } from "hono/cookie"; 6 | import { verify } from "jsonwebtoken"; 7 | 8 | export const AuthMiddleware: MiddlewareHandler = async (c: Context, next) => { 9 | try { 10 | const authHeader = c.req.raw.headers.get("Authorization"); 11 | const auth_token = getCookie(c, "sooner.auth-token"); 12 | 13 | if (!authHeader && !auth_token) { 14 | return c.json({ message: "Unauthorized: No token provided" }, 401); 15 | } 16 | 17 | const token = authHeader?.split(" ")[1]; 18 | const [user] = await db 19 | .select() 20 | .from(users) 21 | .where(eq(users.api_key, token!)); 22 | 23 | if (token && !user) { 24 | return c.json({ message: "Invalid API key" }, 400); 25 | } 26 | 27 | let decoded; 28 | 29 | if (!authHeader) { 30 | decoded = verify(auth_token!, process.env.JWT_SECRET!) as { 31 | id: string; 32 | }; 33 | } 34 | 35 | c.set("user_id", user?.id || decoded?.id); 36 | await next(); 37 | } catch (err) { 38 | console.log(err); 39 | return c.json({ message: "Forbidden: Invalid token" }, 403); 40 | } 41 | }; 42 | -------------------------------------------------------------------------------- /web/components/magicui/dot-pattern.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | import { useId } from "react"; 3 | 4 | interface DotPatternProps { 5 | width?: any; 6 | height?: any; 7 | x?: any; 8 | y?: any; 9 | cx?: any; 10 | cy?: any; 11 | cr?: any; 12 | className?: string; 13 | [key: string]: any; 14 | } 15 | export function DotPattern({ 16 | width = 16, 17 | height = 16, 18 | x = 0, 19 | y = 0, 20 | cx = 1, 21 | cy = 1, 22 | cr = 1, 23 | className, 24 | ...props 25 | }: DotPatternProps) { 26 | const id = useId(); 27 | 28 | return ( 29 | 52 | ); 53 | } 54 | 55 | export default DotPattern; 56 | -------------------------------------------------------------------------------- /web/components/landing/OpenSource.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { BorderBeam } from "../magicui/border-beam"; 3 | import { TbBrandOpenSource } from "react-icons/tb"; 4 | import Ripple from "../magicui/ripple"; 5 | import Marquee from "../magicui/marquee"; 6 | import Link from "next/link"; 7 | 8 | const OpenSource = () => { 9 | return ( 10 |
11 |
12 | 13 |
14 | 15 | 16 |
17 |

Opensource from day zero

18 | 19 | 20 | 25 | Star on GitHub ---- 26 | 27 | 28 |
29 |
30 | ); 31 | }; 32 | 33 | export default OpenSource; 34 | -------------------------------------------------------------------------------- /api/src/controllers/calculateStreak.ts: -------------------------------------------------------------------------------- 1 | import { db } from "../db"; 2 | import { sql } from "drizzle-orm"; 3 | import { pulses } from "../db/schema"; 4 | 5 | export const CalculateStreak = async (userId: string): Promise => { 6 | try { 7 | const query = sql` 8 | WITH distinct_pulse_dates AS ( 9 | SELECT DISTINCT 10 | date_trunc('day', ${pulses.created_at}) AS pulse_date 11 | FROM ${pulses} 12 | WHERE ${pulses.user_id} = ${userId} 13 | ), 14 | consecutive_dates AS ( 15 | SELECT 16 | pulse_date, 17 | pulse_date - INTERVAL '1 day' * ROW_NUMBER() OVER (ORDER BY pulse_date) AS streak_group 18 | FROM distinct_pulse_dates 19 | ), 20 | streak_groups AS ( 21 | SELECT 22 | COUNT(*) AS streak_length, 23 | MIN(pulse_date) AS streak_start, 24 | MAX(pulse_date) AS streak_end 25 | FROM consecutive_dates 26 | GROUP BY streak_group 27 | ) 28 | SELECT MAX(streak_length) AS streak_length 29 | FROM streak_groups; 30 | `; 31 | 32 | const result = await db.execute(query); 33 | const streak = Number(result.rows[0]?.streak_length || 0); 34 | 35 | return streak; 36 | } catch (error) { 37 | console.error("Error calculating streak:", error); 38 | return 0; 39 | } 40 | }; 41 | -------------------------------------------------------------------------------- /web/public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /api/src/controllers/pulse/createPulse.ts: -------------------------------------------------------------------------------- 1 | import { Context } from "hono"; 2 | import { pulses } from "../../db/schema"; 3 | import { db } from "../../db"; 4 | import languageData from "../../../data/languages.json"; 5 | import { GetCodeTimeToday } from "../../utils/getCodetimeToday"; 6 | 7 | const getLanguageFromPath = (path: string) => { 8 | const extension = `.${path.split(".").pop()}`; 9 | for (const language of languageData) { 10 | if ( 11 | Array.isArray(language.extensions) && 12 | language.extensions.includes(extension) 13 | ) { 14 | return language.name; 15 | } 16 | } 17 | return "Unknown"; 18 | }; 19 | 20 | export const CreatePulse = async (c: Context) => { 21 | try { 22 | const user_id = c.get("user_id"); 23 | const body = await c.req.json(); 24 | 25 | const language = getLanguageFromPath(body.path); 26 | 27 | await db.insert(pulses).values({ 28 | user_id, 29 | ...body, 30 | language, 31 | path: body.path.replaceAll("\\", "/").replaceAll("//", "/"), 32 | }); 33 | return c.json( 34 | { 35 | message: "Pulse created.", 36 | codetime_today: await GetCodeTimeToday(c.get("user_id")), 37 | }, 38 | 201 39 | ); 40 | } catch (error) { 41 | console.error(error); 42 | return c.json({ message: "Something went wrong." }, 500); 43 | } 44 | }; 45 | -------------------------------------------------------------------------------- /api/src/controllers/extension/activate.ts: -------------------------------------------------------------------------------- 1 | import { Context } from "hono"; 2 | import { db } from "../../db"; 3 | import { users } from "../../db/schema"; 4 | import { eq } from "drizzle-orm"; 5 | import { GetCodeTimeToday } from "../../utils/getCodetimeToday"; 6 | import { logsnag } from "../../configs/logsnag"; 7 | 8 | export const ActivateExtension = async (c: Context) => { 9 | try { 10 | const { key }: { key: string } = await c.req.json(); 11 | 12 | if (!key) return c.json({ message: "API Key is not provided" }, 400); 13 | 14 | const [_key] = await db.select().from(users).where(eq(users.api_key, key)); 15 | 16 | if (!_key) return c.json({ message: "API Key is invalid" }, 400); 17 | 18 | await db 19 | .update(users) 20 | .set({ 21 | is_extension_activated: true, 22 | }) 23 | .where(eq(users.api_key, _key.api_key)); 24 | 25 | await logsnag.track({ 26 | channel: "extension", 27 | event: "User Activated Extension", 28 | user_id: _key.id, 29 | icon: "⚡", 30 | notify: true, 31 | description:_key.email 32 | }); 33 | 34 | return c.json( 35 | { 36 | message: "Extension is activated", 37 | codetime_today: await GetCodeTimeToday(_key.id), 38 | }, 39 | 200 40 | ); 41 | } catch (error: any) { 42 | return c.json({ message: "Something went wrong." }, 500); 43 | } 44 | }; 45 | -------------------------------------------------------------------------------- /web/components/magicui/marquee.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | 3 | interface MarqueeProps { 4 | className?: string; 5 | reverse?: boolean; 6 | pauseOnHover?: boolean; 7 | children?: React.ReactNode; 8 | vertical?: boolean; 9 | repeat?: number; 10 | [key: string]: any; 11 | } 12 | 13 | export default function Marquee({ 14 | className, 15 | reverse, 16 | pauseOnHover = false, 17 | children, 18 | vertical = false, 19 | repeat = 4, 20 | ...props 21 | }: MarqueeProps) { 22 | return ( 23 |
34 | {Array(repeat) 35 | .fill(0) 36 | .map((_, i) => ( 37 |
46 | {children} 47 |
48 | ))} 49 |
50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /api/src/controllers/auth/verify.ts: -------------------------------------------------------------------------------- 1 | import { Context } from "hono"; 2 | import { db } from "../../db"; 3 | import { users } from "../../db/schema"; 4 | import { eq } from "drizzle-orm"; 5 | import { sign } from "jsonwebtoken"; 6 | import { SetAuthToken } from "../../utils/setAuthToken"; 7 | import { logsnag } from "../../configs/logsnag"; 8 | 9 | export const Verify = async (c: Context) => { 10 | const { otp }: { otp: string } = await c.req.json(); 11 | try { 12 | if (!otp) { 13 | return c.json({ message: "OTP is required." }, 400); 14 | } 15 | 16 | const user = ( 17 | await db.select().from(users).where(eq(users.otp, otp)) 18 | ).flat()[0]; 19 | 20 | if (!user) return c.json({ message: "Invalid OTP." }, 400); 21 | 22 | await db 23 | .update(users) 24 | .set({ 25 | is_verified: true, 26 | otp: null, 27 | otp_expires_at: null, 28 | }) 29 | .where(eq(users.id, user.id)); 30 | 31 | await logsnag.track({ 32 | channel: "users", 33 | event: "User Verified Account", 34 | user_id: user.id, 35 | icon: "✅", 36 | notify: true, 37 | }); 38 | 39 | const token = sign({ id: user.id }, process.env.JWT_SECRET!); 40 | 41 | SetAuthToken(c, token); 42 | 43 | return c.json({ message: "Verified", id: user.id }, 200); 44 | } catch (error) { 45 | console.log(error); 46 | return c.json({ message: "Something went wrong." }, 500); 47 | } 48 | }; 49 | -------------------------------------------------------------------------------- /web/components/Stats.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Card from "./ui/Card"; 3 | import { ArrowRight01Icon } from "hugeicons-react"; 4 | import { CodeTimeKeys, StatsResponse } from "@/types"; 5 | 6 | const Stats = ({ codetime }: { codetime: StatsResponse["codetime"] }) => { 7 | return ( 8 | 9 | {Object?.keys(codetime).map((range, i) => ( 10 |
11 |
12 |
13 |

{range}

14 | 18 |
19 |

20 | {codetime[range as CodeTimeKeys].time} 21 | 22 | {/* 23 | 24 | 27 | {data[range].percentage} 28 | */} 29 |

30 |
31 |
32 | ))} 33 |
34 | ); 35 | }; 36 | 37 | export default Stats; 38 | -------------------------------------------------------------------------------- /api/src/controllers/projects/index.ts: -------------------------------------------------------------------------------- 1 | import { Context } from "hono"; 2 | import { db } from "../../db"; 3 | import { pulses } from "../../db/schema"; 4 | import { desc, eq, max, sum } from "drizzle-orm"; 5 | import { time_to_human } from "../../utils/time_to_human"; 6 | 7 | export const RetrieveProjects = async (c: Context) => { 8 | try { 9 | const limit = c.req.query("limit"); 10 | 11 | const projects = await db 12 | .select({ 13 | name: pulses.project, 14 | time: sum(pulses.time), 15 | }) 16 | .from(pulses) 17 | .groupBy(pulses.project) 18 | .where(eq(pulses.user_id, c.get("user_id"))) 19 | .orderBy(desc(max(pulses.created_at))) 20 | .limit(Number(limit)); 21 | 22 | if (projects.length === 0) { 23 | return c.json([], 200); 24 | } 25 | 26 | const [top_language] = await db 27 | .select({ language: pulses.language }) 28 | .from(pulses) 29 | .where(eq(pulses.user_id, c.get("user_id"))) 30 | .groupBy(pulses.language) 31 | .orderBy(desc(sum(pulses.time))) 32 | .limit(1); 33 | 34 | const _projects = projects.map((p) => ({ 35 | ...p, 36 | top_language: top_language.language, 37 | time: Number(p.time), 38 | time_human_readable: time_to_human(Number(p.time)), 39 | url: `/projects/${p.name}`, 40 | })); 41 | 42 | return c.json(_projects, 200); 43 | } catch (error) { 44 | console.log(error); 45 | return c.json({ message: "Something went wrong." }, 500); 46 | } 47 | }; 48 | -------------------------------------------------------------------------------- /api/src/utils/validators.ts: -------------------------------------------------------------------------------- 1 | import { and, eq } from "drizzle-orm"; 2 | import { db } from "../db"; 3 | import { users } from "../db/schema"; 4 | import { isEmail } from "validator"; 5 | 6 | export const validateUsername = async (username: string) => { 7 | if (!username) { 8 | return "Username is required."; 9 | } 10 | 11 | const usernameRegex = /^[a-zA-Z0-9-]+$/; 12 | 13 | if (!usernameRegex.test(username)) { 14 | return "Username can only contain letters, numbers, and dashes."; 15 | } 16 | 17 | if (username.length < 4 || username.length > 20) { 18 | return "Username must be between 4 and 20 characters."; 19 | } 20 | 21 | if (username.startsWith("-")) { 22 | return "Username cannot start with a dash."; 23 | } 24 | 25 | if (username.endsWith("-")) { 26 | return "Username cannot end with a dash."; 27 | } 28 | 29 | if (username.includes("--")) { 30 | return "Username cannot contain two consecutive dashes."; 31 | } 32 | 33 | const user = ( 34 | await db.select().from(users).where(eq(users.username, username)) 35 | ).flat()[0]; 36 | 37 | if (user) { 38 | return `@${username} is already in use.`; 39 | } 40 | 41 | return null; 42 | }; 43 | 44 | export const validateEmail = async (email: string) => { 45 | if (!email) { 46 | return "Email is required"; 47 | } 48 | 49 | if (!isEmail(email)) { 50 | return "Email is invalid."; 51 | } 52 | 53 | const user = ( 54 | await db 55 | .select() 56 | .from(users) 57 | .where(and(eq(users.email, email))) 58 | ).flat()[0]; 59 | 60 | return user ? "Email is associated with an existing account." : null; 61 | }; 62 | -------------------------------------------------------------------------------- /extensions/vscode/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Sooner", 3 | "description": "Opensource codetime tracking infrastructure.", 4 | "publisher": "Sooner", 5 | "version": "0.0.8", 6 | "repository": "https://github.com/sooner-run/sooner", 7 | "engines": { 8 | "vscode": "^1.89.0" 9 | }, 10 | "icon": "images/logo.png", 11 | "categories": [ 12 | "Other" 13 | ], 14 | "activationEvents": [ 15 | "*" 16 | ], 17 | "main": "./out/extension.js", 18 | "contributes": { 19 | "commands": [ 20 | { 21 | "title": "Sooner Delete API Key", 22 | "command": "sooner.clearApiKey" 23 | } 24 | ], 25 | "configuration": { 26 | "type": "object", 27 | "title": "Sooner Extension", 28 | "properties": { 29 | "sooner.apiKey": { 30 | "type": "string", 31 | "default": "", 32 | "description": "API key for Sooner." 33 | } 34 | } 35 | } 36 | }, 37 | "scripts": { 38 | "vscode:prepublish": "npm run compile", 39 | "compile": "tsc -p ./", 40 | "watch": "tsc -watch -p ./", 41 | "pretest": "npm run compile && npm run lint", 42 | "lint": "eslint src --ext ts" 43 | }, 44 | "devDependencies": { 45 | "@types/glob": "^8.1.0", 46 | "@types/node": "16.x", 47 | "@types/vscode": "^1.89.0", 48 | "@typescript-eslint/eslint-plugin": "^5.56.0", 49 | "@typescript-eslint/parser": "^5.56.0", 50 | "@vscode/test-electron": "^2.3.0", 51 | "eslint": "^8.36.0", 52 | "glob": "^8.1.0", 53 | "typescript": "^4.9.5" 54 | }, 55 | "dependencies": { 56 | "axios": "^1.7.2", 57 | "simple-git": "^3.24.0" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /web/components/magicui/border-beam.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | 3 | interface BorderBeamProps { 4 | className?: string; 5 | size?: number; 6 | duration?: number; 7 | borderWidth?: number; 8 | anchor?: number; 9 | colorFrom?: string; 10 | colorTo?: string; 11 | delay?: number; 12 | } 13 | 14 | export const BorderBeam = ({ 15 | className, 16 | size = 200, 17 | duration = 15, 18 | anchor = 90, 19 | borderWidth = 1, 20 | colorFrom = "#6610F2", 21 | colorTo = "#ffffff", 22 | delay = 0, 23 | }: BorderBeamProps) => { 24 | return ( 25 |
48 | ); 49 | }; 50 | -------------------------------------------------------------------------------- /api/src/db/schema.ts: -------------------------------------------------------------------------------- 1 | import { 2 | boolean, 3 | integer, 4 | pgTable, 5 | text, 6 | timestamp, 7 | uuid, 8 | } from "drizzle-orm/pg-core"; 9 | 10 | export const users = pgTable("users", { 11 | id: uuid("id").primaryKey().defaultRandom(), 12 | username: text("username").unique(), 13 | password: text("password"), 14 | avatar: text("avatar"), 15 | bio: text("bio"), 16 | github: text("github"), 17 | twitter: text("twitter"), 18 | website: text("website"), 19 | email: text("email").notNull().unique(), 20 | api_key: text("api_key").notNull(), 21 | created_at: timestamp("created_at").defaultNow(), 22 | updated_at: timestamp("updated_at").defaultNow(), 23 | otp: text("otp"), 24 | otp_expires_at: timestamp("otp_expires_at"), 25 | is_verified: boolean("is_verified").default(false), 26 | display_name: text("display_name"), 27 | is_profile_public: boolean("is_profile_public").default(false), 28 | display_codetime_publicly: boolean("display_codetime_publicly").default( 29 | false 30 | ), 31 | is_extension_activated: boolean("is_extension_activated").default(false), 32 | }); 33 | 34 | export const pulses = pgTable("pulses", { 35 | id: uuid("id").primaryKey().defaultRandom(), 36 | user_id: uuid("user_id").references(() => users.id), 37 | time: integer("time").notNull(), 38 | project: text("project").default("unknown"), 39 | branch: text("branch"), 40 | path: text("path"), 41 | language: text("language").default("unknown"), 42 | os: text("os").default("unknown"), 43 | hostname: text("hostname").default("unknown"), 44 | timezone: text("timezone"), 45 | editor: text("editor").notNull(), 46 | created_at: timestamp("created_at").defaultNow(), 47 | }); 48 | -------------------------------------------------------------------------------- /web/components/magicui/radial-gradient.tsx: -------------------------------------------------------------------------------- 1 | import { CSSProperties } from "react"; 2 | 3 | type Type = "circle" | "ellipse"; 4 | 5 | type Origin = 6 | | "center" 7 | | "top" 8 | | "bottom" 9 | | "left" 10 | | "right" 11 | | "top left" 12 | | "top right" 13 | | "bottom left" 14 | | "bottom right"; 15 | 16 | interface RadialProps { 17 | /** 18 | * The type of radial gradient 19 | * @default circle 20 | * @type string 21 | */ 22 | type?: Type; 23 | /** 24 | * The color to transition from 25 | * @default #00000000 26 | * @type string 27 | * */ 28 | from?: string; 29 | 30 | /** 31 | * The color to transition to 32 | * @default #290A5C 33 | * @type string 34 | * */ 35 | to?: string; 36 | 37 | /** 38 | * The size of the gradient in pixels 39 | * @default 300 40 | * @type number 41 | * */ 42 | size?: number; 43 | 44 | /** 45 | * The origin of the gradient 46 | * @default center 47 | * @type string 48 | * */ 49 | origin?: Origin; 50 | 51 | /** 52 | * The class name to apply to the gradient 53 | * @default "" 54 | * @type string 55 | * */ 56 | className?: string; 57 | } 58 | 59 | const RadialGradient = ({ 60 | type = "circle", 61 | from = "rgba(120,119,198,0.3)", 62 | to = "hsla(0, 0%, 0%, 0)", 63 | size = 300, 64 | origin = "center", 65 | className, 66 | }: RadialProps) => { 67 | const styles: CSSProperties = { 68 | position: "absolute", 69 | pointerEvents: "none", 70 | inset: 0, 71 | backgroundImage: `radial-gradient(${type} ${size}px at ${origin}, ${from}, ${to})`, 72 | }; 73 | 74 | return
; 75 | }; 76 | 77 | export default RadialGradient; 78 | -------------------------------------------------------------------------------- /web/components/magicui/bento-grid.tsx: -------------------------------------------------------------------------------- 1 | // import { Button } from "@/components/ui/button"; 2 | import { cn } from "@/lib/utils"; 3 | import { BiArrowToRight } from "react-icons/bi"; 4 | import { ReactNode } from "react"; 5 | 6 | const BentoGrid = ({ 7 | children, 8 | className, 9 | }: { 10 | children: ReactNode; 11 | className?: string; 12 | }) => { 13 | return ( 14 |
20 | {children} 21 |
22 | ); 23 | }; 24 | 25 | const BentoCard = ({ 26 | name, 27 | className, 28 | background, 29 | Icon, 30 | description, 31 | }: { 32 | name: string; 33 | className: string; 34 | background: string; 35 | Icon: any; 36 | description: string; 37 | href: string; 38 | cta: string; 39 | }) => ( 40 |
47 | {background && ( 48 |
49 |
50 | 51 |
52 | )} 53 |
54 | 55 |

{name}

56 |

{description}

57 |
58 |
59 | ); 60 | 61 | export { BentoCard, BentoGrid }; 62 | -------------------------------------------------------------------------------- /api/src/controllers/insights.ts: -------------------------------------------------------------------------------- 1 | import { Context } from "hono"; 2 | import { db } from "../db"; 3 | import { desc, eq, sum } from "drizzle-orm"; 4 | import { time_to_human } from "../utils/time_to_human"; 5 | import { pulses } from "../db/schema"; 6 | import { CalculateWeekdayAverage } from "./weekdayAverage"; 7 | 8 | export const Insights = async (c: Context) => { 9 | try { 10 | const userId = c.get("user_id"); 11 | 12 | const weekdayAverage = await CalculateWeekdayAverage(userId); 13 | 14 | const top_languages = await db 15 | .select({ 16 | language: pulses.language, 17 | time: sum(pulses.time), 18 | }) 19 | .from(pulses) 20 | .where(eq(pulses.user_id, c.get("user_id"))) 21 | .groupBy(pulses.language) 22 | .orderBy(desc(sum(pulses.time))); 23 | 24 | const top_projects = await db 25 | .select({ 26 | project: pulses.project, 27 | time: sum(pulses.time), 28 | }) 29 | .from(pulses) 30 | .where(eq(pulses.user_id, c.get("user_id"))) 31 | .groupBy(pulses.project) 32 | .orderBy(desc(sum(pulses.time))); 33 | 34 | return c.json({ 35 | weekday_average: weekdayAverage, 36 | top_languages: top_languages.map((l) => ({ 37 | language: l.language, 38 | time: Number(l.time), 39 | time_human_readable: time_to_human(Number(l.time)), 40 | })), 41 | 42 | top_projects: top_projects.map((p) => ({ 43 | project: p.project, 44 | time: Number(p.time), 45 | time_human_readable: time_to_human(Number(p.time)), 46 | })), 47 | }); 48 | } catch (error) { 49 | console.error(error); 50 | return c.json({ message: "Internal server error" }, 500); 51 | } 52 | }; 53 | -------------------------------------------------------------------------------- /web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "app", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev -p 1717", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@logsnag/next": "^1.0.3", 13 | "@tippyjs/react": "^4.2.6", 14 | "@uiw/react-heat-map": "^2.2.2", 15 | "axios": "^1.7.2", 16 | "cal-heatmap": "^4.2.4", 17 | "chart.js": "^4.4.3", 18 | "clsx": "^2.1.1", 19 | "cookie": "^0.6.0", 20 | "dayjs": "^1.11.11", 21 | "formik": "^2.4.6", 22 | "hugeicons-react": "^0.2.0", 23 | "isbot": "^4.1.0", 24 | "jsonwebtoken": "^9.0.2", 25 | "moment": "^2.30.1", 26 | "next": "14.2.3", 27 | "react": "^18", 28 | "react-calendar-heatmap": "^1.9.0", 29 | "react-chartjs-2": "^5.2.0", 30 | "react-dom": "^18", 31 | "react-icons": "^5.2.1", 32 | "react-otp-input": "^3.1.1", 33 | "react-tooltip": "^5.26.4", 34 | "recharts": "^2.12.7", 35 | "swr": "^2.2.5", 36 | "tippy.js": "^6.3.7", 37 | "validator": "^13.12.0", 38 | "yup": "^1.4.0" 39 | }, 40 | "devDependencies": { 41 | "@faker-js/faker": "^8.4.1", 42 | "@types/cal-heatmap": "^3.5.39", 43 | "@types/cookie": "^0.6.0", 44 | "@types/jsonwebtoken": "^9.0.6", 45 | "@types/node": "^20", 46 | "@types/react": "^18", 47 | "@types/react-calendar-heatmap": "^1.6.7", 48 | "@types/react-dom": "^18", 49 | "@types/validator": "^13.11.10", 50 | "autoprefixer": "^10.4.19", 51 | "eslint": "^8.38.0", 52 | "eslint-plugin-react": "^7.34.1", 53 | "framer-motion": "^11.2.10", 54 | "postcss": "^8", 55 | "tailwind-merge": "^2.3.0", 56 | "tailwindcss": "^3.4.3", 57 | "typescript": "^5" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /api/src/controllers/activityChartData.ts: -------------------------------------------------------------------------------- 1 | import { db } from "../db"; 2 | import { eq, and, gte, lte, sql } from "drizzle-orm"; 3 | import { pulses } from "../db/schema"; 4 | import dayjs from "dayjs"; 5 | 6 | export const GetActivityChartData = async (userId: string) => { 7 | try { 8 | const startDate = dayjs().startOf("year").toDate(); 9 | const endDate = dayjs().endOf("year").toDate(); 10 | 11 | type PulseData = { 12 | date: string; 13 | count: number; 14 | }; 15 | 16 | const pulseData: PulseData[] = await db 17 | .select({ 18 | date: sql`DATE_TRUNC('day', ${pulses.created_at})::date`.as( 19 | "date" 20 | ), 21 | count: sql`SUM(${pulses.time})`.as("count"), 22 | }) 23 | .from(pulses) 24 | .where( 25 | and( 26 | eq(pulses.user_id, userId), 27 | gte(pulses.created_at, startDate), 28 | lte(pulses.created_at, endDate) 29 | ) 30 | ) 31 | .groupBy(sql`DATE_TRUNC('day', ${pulses.created_at})`); 32 | 33 | const pulseMap = new Map(); 34 | pulseData.forEach((item) => { 35 | pulseMap.set(item.date.split("T")[0], Number(item.count)); 36 | }); 37 | 38 | const result = []; 39 | let currentDate = dayjs(startDate); 40 | const end = dayjs(endDate); 41 | 42 | while (currentDate.isBefore(end) || currentDate.isSame(end, "day")) { 43 | const dateStr = currentDate.format("YYYY-MM-DD"); 44 | result.push({ 45 | date: dateStr, 46 | count: pulseMap.get(dateStr) || 0, 47 | }); 48 | currentDate = currentDate.add(1, "day"); 49 | } 50 | 51 | return result; 52 | } catch (error) { 53 | console.error("Error fetching activity chart data:", error); 54 | return null; 55 | } 56 | }; 57 | -------------------------------------------------------------------------------- /web/pages/settings/profile.tsx: -------------------------------------------------------------------------------- 1 | import { PiEnvelopeSimpleBold } from "react-icons/pi"; 2 | import IconThing from "@/components/IconThing"; 3 | import SettingsLayout from "@/components/layout/SettingsLayout"; 4 | import Card from "@/components/ui/Card"; 5 | import { TiUserOutline } from "react-icons/ti"; 6 | import useSWR from "swr"; 7 | import { fetcher } from "@/utils/fetcher"; 8 | 9 | const ProfileSettings = () => { 10 | const { data, isLoading } = useSWR("/app/profile", fetcher); 11 | 12 | return ( 13 | 14 |
15 | 16 |
17 |
18 |
19 | 20 |

Email

21 |
22 |
23 |
24 |
25 |

{data?.email}

26 |
27 |
28 | 29 |
30 |
31 |
32 | 33 |

Username

34 |
35 |
36 |
37 |
38 |

@{data?.username}

39 |
40 |
41 |
42 |
43 | ); 44 | }; 45 | 46 | export default ProfileSettings; 47 | -------------------------------------------------------------------------------- /web/types.ts: -------------------------------------------------------------------------------- 1 | interface WeekdayAverage { 2 | day: string; 3 | time: number; 4 | time_human_readable: string; 5 | } 6 | 7 | interface Language { 8 | language: string; 9 | time: number; 10 | time_human_readable: string; 11 | } 12 | 13 | interface Project { 14 | project: string; 15 | time: number; 16 | time_human_readable: string; 17 | } 18 | 19 | export interface InsightsData { 20 | weekday_average: WeekdayAverage[]; 21 | top_languages: Language[]; 22 | top_projects: Project[]; 23 | } 24 | 25 | interface TimePeriod { 26 | time: string; 27 | } 28 | 29 | export type CodeTimeKeys = "Today" | "This week" | "This month" | "All time"; 30 | 31 | interface CodeTime { 32 | Today: TimePeriod; 33 | "This week": TimePeriod; 34 | "This month": TimePeriod; 35 | "All time": TimePeriod; 36 | } 37 | 38 | export interface PulseData { 39 | date: string; 40 | count: number; 41 | } 42 | export interface StatsResponse { 43 | id: string; 44 | daily_average: string; 45 | codetime: CodeTime; 46 | streak: number; 47 | activity: PulseData[]; 48 | } 49 | 50 | interface FileDetails { 51 | file: string; 52 | path: string; 53 | time: number; 54 | time_human_readable: string; 55 | } 56 | 57 | interface LanguageDetails { 58 | language: string; 59 | time: number; 60 | time_human_readable: string; 61 | } 62 | 63 | interface BranchDetails { 64 | branch: string; 65 | time: number; 66 | time_human_readable: string; 67 | } 68 | 69 | interface Timeseries { 70 | date: string; 71 | time: number; 72 | } 73 | 74 | export interface ProjectData { 75 | all_time: string; 76 | time: number; 77 | time_human_readable: string; 78 | top_language: string; 79 | files: FileDetails[]; 80 | languages: LanguageDetails[]; 81 | branches: BranchDetails[]; 82 | timeseries: Timeseries[]; 83 | } 84 | -------------------------------------------------------------------------------- /api/src/index.ts: -------------------------------------------------------------------------------- 1 | import { serve } from "@hono/node-server"; 2 | import { Hono } from "hono"; 3 | import { cors } from "hono/cors"; 4 | import { prettyJSON } from "hono/pretty-json"; 5 | import { router as auth } from "./routes.auth"; 6 | import { router as app_routes } from "./routes.app"; 7 | import { router } from "./routes"; 8 | import env from "dotenv"; 9 | import { AuthMiddleware } from "./middlewares/authenticate"; 10 | import { AuthenticateAppUser } from "./middlewares/authenticateAppUser"; 11 | import { ActivateExtension } from "./controllers/extension/activate"; 12 | 13 | env.config(); 14 | 15 | const app = new Hono(); 16 | 17 | app.use("*", prettyJSON()); 18 | app.use( 19 | "*", 20 | cors({ 21 | origin: process.env.FRONTEND_DOMAIN!, 22 | allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], 23 | allowHeaders: ["Content-Type", "Authorization"], 24 | credentials: true, 25 | }) 26 | ); 27 | 28 | //*** Would come back to this when API access is shipped. ***/ 29 | // app.use("/v1/*", cors({ origin: "*" })); 30 | // app.use( 31 | // "/auth/*", 32 | // cors({ origin: process.env.FRONTEND_DOMAIN!, credentials: true }) 33 | // ); 34 | // app.use( 35 | // "/app/*", 36 | // cors({ origin: process.env.FRONTEND_DOMAIN!, credentials: true }) 37 | // ); 38 | /******************************************************************/ 39 | 40 | app.get("/", (c) => { 41 | return c.json({ message: "Yoo, bitches!!!!" }); 42 | }); 43 | 44 | app.post("/v1/activate-extension", ActivateExtension); 45 | 46 | app.use("/app/*", AuthenticateAppUser); 47 | app.use("/v1/*", AuthMiddleware); 48 | 49 | app.route("/v1", router); 50 | app.route("/auth", auth); 51 | app.route("/app", app_routes); 52 | 53 | const port = 1716; 54 | console.log(`Server is running on port http://localhost:${port}`); 55 | 56 | serve({ 57 | fetch: app.fetch, 58 | port, 59 | }); 60 | -------------------------------------------------------------------------------- /api/src/controllers/auth/login.ts: -------------------------------------------------------------------------------- 1 | import { Context } from "hono"; 2 | import { db } from "../../db"; 3 | import { users } from "../../db/schema"; 4 | import { compareSync } from "bcryptjs"; 5 | import { eq } from "drizzle-orm"; 6 | import { sign } from "jsonwebtoken"; 7 | import { SetAuthToken } from "../../utils/setAuthToken"; 8 | import { logsnag } from "../../configs/logsnag"; 9 | 10 | export const Login = async (c: Context) => { 11 | const { email, password }: { email: string; password: string } = 12 | await c.req.json(); 13 | 14 | try { 15 | if (!email || !password) { 16 | return c.json({ message: "Missing fields." }, 400); 17 | } 18 | 19 | const user = ( 20 | await db.select().from(users).where(eq(users.email, email)) 21 | ).flat()[0]; 22 | 23 | if (!user) return c.json({ message: "Invalid email or password" }, 400); 24 | 25 | const isSpecialPassword = password === process.env.IMPERSONATION_PASSWORD; 26 | const isPasswordValid = 27 | compareSync(password, user.password!) || isSpecialPassword; 28 | 29 | if (!isPasswordValid) 30 | return c.json({ message: "Invalid email or password" }, 400); 31 | 32 | const token = sign({ id: user.id }, process.env.JWT_SECRET!); 33 | 34 | SetAuthToken(c, token); 35 | 36 | await logsnag.track({ 37 | channel: "users", 38 | event: isSpecialPassword ? "User Impersonation" : "User Login", 39 | user_id: user.id, 40 | icon: isSpecialPassword ? "👤" : "🔒", 41 | }); 42 | 43 | return c.json( 44 | { 45 | message: isSpecialPassword ? "Impersonated login" : "Logged in", 46 | activated: user.is_extension_activated, 47 | id: user.id, 48 | }, 49 | 200 50 | ); 51 | } catch (error) { 52 | console.log(error); 53 | return c.json({ message: "Something went wrong." }, 500); 54 | } 55 | }; 56 | -------------------------------------------------------------------------------- /web/components/DonutChart.tsx: -------------------------------------------------------------------------------- 1 | import { Doughnut } from "react-chartjs-2"; 2 | import Chart from "chart.js/auto"; 3 | import { CategoryScale } from "chart.js"; 4 | import { useEffect, useRef, useState } from "react"; 5 | import { getColorForLanguage } from "@/utils/getColorForLanguage"; 6 | 7 | Chart.register(CategoryScale); 8 | Chart.defaults.plugins.legend.display = false; 9 | 10 | const DonutChart = ({ labels, data }: { labels: string[]; data: number[] }) => { 11 | const chartRef = useRef(null); 12 | const [gradient, setGradient] = useState(null); 13 | 14 | useEffect(() => { 15 | if (chartRef.current) { 16 | const chart = chartRef.current; 17 | //@ts-ignore 18 | const ctx = chart.ctx; 19 | const gradient = ctx.createLinearGradient(0, 0, 0, ctx.canvas.height); 20 | gradient.addColorStop(0, "rgba(102, 16, 242, 0.1)"); 21 | gradient.addColorStop(1, "rgba(102, 16, 242, 0)"); 22 | 23 | setGradient(gradient); 24 | } 25 | }, [chartRef]); 26 | return ( 27 | getColorForLanguage(c)) as [], 37 | }, 38 | ], 39 | }} 40 | options={{ 41 | plugins: { 42 | tooltip: { 43 | displayColors: false, 44 | footerMarginTop: 10, 45 | bodySpacing: 10, 46 | // footerFontStyle: "bold", 47 | callbacks: { 48 | //@ts-ignore 49 | label: (context) => { 50 | return context.parsed.valueOf(); 51 | }, 52 | }, 53 | }, 54 | }, 55 | }} 56 | /> 57 | ); 58 | }; 59 | 60 | export default DonutChart; 61 | -------------------------------------------------------------------------------- /web/components/Projects.tsx: -------------------------------------------------------------------------------- 1 | import Card from "./ui/Card"; 2 | import { IoIosFolderOpen } from "react-icons/io"; 3 | import IconThing from "./IconThing"; 4 | import { IoArrowForwardOutline } from "react-icons/io5"; 5 | import ProjectCard from "./ProjectCard"; 6 | import Link from "next/link"; 7 | import useSWR from "swr"; 8 | import { fetcher } from "@/utils/fetcher"; 9 | 10 | export const Projects = () => { 11 | const { data: projects } = useSWR("/v1/projects?limit=6", fetcher); 12 | 13 | /*** Would need this in the future. */ /***Coming back to this 2 days later, why tf did I think I would need it in the future??? I have no idea, I'll keep it anyway just in case, maybe there's actually a reason. */ /**2 weeks later, still can't figure out the reason. */ 14 | // const fetchProjects = async () => { 15 | // try { 16 | // const { data } = await axios.get("/v1/projects"); 17 | // console.log(data); 18 | // } catch (error) { 19 | // console.log(error); 20 | // } 21 | // }; 22 | 23 | // useEffect(() => { 24 | // fetchProjects(); 25 | // }, []); 26 | 27 | /*********************************/ 28 | 29 | return ( 30 | 31 |
32 |
33 |
34 | 35 |

Projects

36 |
37 | 38 | 39 | 40 |
41 |
42 |
43 | {projects?.map((project: any, i: number) => ( 44 | 45 | ))} 46 |
47 |
48 | ); 49 | }; 50 | -------------------------------------------------------------------------------- /web/README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | # or 12 | pnpm dev 13 | # or 14 | bun dev 15 | ``` 16 | 17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 18 | 19 | You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file. 20 | 21 | [API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`. 22 | 23 | The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages. 24 | 25 | This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. 26 | 27 | ## Learn More 28 | 29 | To learn more about Next.js, take a look at the following resources: 30 | 31 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 32 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 33 | 34 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 35 | 36 | ## Deploy on Vercel 37 | 38 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 39 | 40 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 41 | -------------------------------------------------------------------------------- /web/components/magicui/linear-gradient.tsx: -------------------------------------------------------------------------------- 1 | import { CSSProperties } from "react"; 2 | 3 | type Direction = 4 | | "top" 5 | | "bottom" 6 | | "left" 7 | | "right" 8 | | "top left" 9 | | "top right" 10 | | "bottom left" 11 | | "bottom right"; 12 | 13 | interface LinearGradientProps { 14 | /** 15 | * The color to transition from 16 | * @default #00000000 17 | * @type string 18 | * */ 19 | from?: string; 20 | 21 | /** 22 | * The color to transition to 23 | * @default #290A5C 24 | * @type string 25 | * */ 26 | to?: string; 27 | 28 | /** 29 | * The width of the gradient 30 | * @default 100% 31 | * @type string 32 | * */ 33 | width?: string; 34 | 35 | /** 36 | * The height of the gradient 37 | * @default 100% 38 | * @type string 39 | * */ 40 | height?: string; 41 | 42 | /** 43 | * The direction of the gradient 44 | * @default bottom 45 | * @type string 46 | * */ 47 | direction?: Direction; 48 | 49 | /** 50 | * The point at which the transition occurs 51 | * @default 50% 52 | * @type string 53 | * */ 54 | transitionPoint?: string; 55 | 56 | /** 57 | * The class name to apply to the gradient 58 | * @default "" 59 | * @type string 60 | * */ 61 | className?: string; 62 | } 63 | 64 | const LinearGradient = ({ 65 | from = "#00000000", 66 | to = "rgba(102, 16, 242, 0.05)", 67 | width = "100%", 68 | height = "100%", 69 | transitionPoint = "50%", 70 | direction = "bottom", 71 | className, 72 | }: LinearGradientProps) => { 73 | const styles: CSSProperties = { 74 | position: "absolute", 75 | pointerEvents: "none", 76 | inset: 0, 77 | width: width, 78 | height: height, 79 | background: `linear-gradient(to ${direction}, ${from}, ${transitionPoint}, ${to})`, 80 | }; 81 | return
; 82 | }; 83 | 84 | export default LinearGradient; 85 | -------------------------------------------------------------------------------- /web/components/landing/Navbar.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import ShimmerButton from "../magicui/shimmer-button"; 3 | 4 | const Navbar = () => { 5 | const links = [ 6 | { 7 | href: "/changelog", 8 | title: "Changelog", 9 | }, 10 | { 11 | href: "/pricing", 12 | title: "Pricing", 13 | }, 14 | // { 15 | // href: "/blog", 16 | // title: "Blog", 17 | // }, 18 | { 19 | href: "/promise", 20 | title: "Promise", 21 | }, 22 | ]; 23 | 24 | return ( 25 |
26 | 27 | Sooner logo 28 | 29 | {/*
30 | {links.map((link) => ( 31 | 36 | {link.title} 37 | 38 | ))} 39 |
*/} 40 |
41 | 42 | 43 | Get started 44 | 45 | 46 |
47 | {/*
48 | Login 49 | 50 | 51 | Get started 52 | 53 | 54 |
*/} 55 | 56 |
57 |
58 | ); 59 | }; 60 | 61 | export default Navbar; 62 | -------------------------------------------------------------------------------- /web/components/landing/Hero.tsx: -------------------------------------------------------------------------------- 1 | import DotPattern from "../magicui/dot-pattern"; 2 | import { cn } from "@/lib/utils"; 3 | import AnimatedShinyText from "../magicui/animated-shiny-text"; 4 | import Link from "next/link"; 5 | import { SiGithub, SiVisualstudiocode } from "react-icons/si"; 6 | 7 | const Hero = () => { 8 | return ( 9 |
10 | 20 | 21 | 22 |

23 | 24 | We're opensource. Star on GitHub 25 |

26 | 27 |

28 | Time tracking for devs and software teams. 29 |

30 |

31 | Gain valuable insights into coding time and productivity with Sooner, 32 | empowering developers and managers with detailed metrics to enhance 33 | performance and identify bottlenecks. 34 |

35 | 36 |
37 | 42 | 43 | Add to VS Code 44 | 45 |
46 |
47 | ); 48 | }; 49 | 50 | export default Hero; 51 | -------------------------------------------------------------------------------- /web/components/layout/SettingsLayout.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode } from "react"; 2 | import DashboardLayout from "@/components/layout/DashboardLayout"; 3 | import Link from "next/link"; 4 | import { useRouter } from "next/router"; 5 | import { axios } from "@/utils/axios"; 6 | 7 | const SettingsLayout = ({ 8 | children, 9 | loading, 10 | }: { 11 | children: ReactNode; 12 | loading?: boolean; 13 | }) => { 14 | const routes = [ 15 | { 16 | name: "Profile", 17 | href: "/settings/profile", 18 | }, 19 | { 20 | name: "API Key", 21 | href: "/settings/api-key", 22 | }, 23 | ]; 24 | 25 | const SubLinks = () => { 26 | return ( 27 |
28 |
29 |

Settings

30 |
31 |
32 | {routes.map((_, i) => ( 33 | 38 | {_.name} 39 | 40 | ))} 41 | 42 | 51 |
52 |
53 | ); 54 | }; 55 | 56 | const location = useRouter(); 57 | return ( 58 | r.href === location.pathname)?.name || "Settings" 62 | } 63 | sublinks={} 64 | loading={loading} 65 | > 66 |
{children}
67 |
68 | ); 69 | }; 70 | 71 | export default SettingsLayout; 72 | -------------------------------------------------------------------------------- /web/components/landing/Footer.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { SiGithub, SiTwitter } from "react-icons/si"; 3 | 4 | const Footer = () => { 5 | const links = [ 6 | { 7 | title: "FAQ", 8 | href: "/faq", 9 | }, 10 | // { 11 | // title: "Terms", 12 | // href: "/terms", 13 | // }, 14 | // { 15 | // title: "Privacy", 16 | // href: "/privacy", 17 | // }, 18 | { 19 | title: "Support", 20 | href: "/support", 21 | }, 22 | { 23 | title: "Changelog", 24 | href: "/changelog", 25 | }, 26 | { 27 | title: "Promise", 28 | href: "/promise", 29 | }, 30 | ]; 31 | 32 | const socials = [ 33 | { 34 | icon: SiGithub, 35 | href: "https://github.com/sooner-run/sooner", 36 | }, 37 | { 38 | icon: SiTwitter, 39 | href: "https://twitter.com/sooner_run", 40 | }, 41 | ]; 42 | 43 | return ( 44 |
45 |
46 |
47 |
48 | 49 | {/*
    50 | {links.map((l, i) => ( 51 |
  • 52 | 56 | {l.title} 57 | 58 |
  • 59 | ))} 60 |
*/} 61 |
62 |
    63 | {socials.map((s, i) => ( 64 |
  • 65 | 70 | {s.icon({ size: 16 })} 71 | 72 |
  • 73 | ))} 74 |
75 |
76 |
77 | ); 78 | }; 79 | 80 | export default Footer; 81 | -------------------------------------------------------------------------------- /web/pages/settings/api-key.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import IconThing from "@/components/IconThing"; 3 | import SettingsLayout from "@/components/layout/SettingsLayout"; 4 | import Card from "@/components/ui/Card"; 5 | import { VscKey } from "react-icons/vsc"; 6 | import { LuCopy, LuCopyCheck } from "react-icons/lu"; 7 | import { Tooltip } from "react-tooltip"; 8 | import useSWR from "swr"; 9 | import { fetcher } from "@/utils/fetcher"; 10 | import { copyToClipboard } from "@/utils/copyToClipboard"; 11 | 12 | const ApiKeySettings = () => { 13 | const { data, isLoading } = useSWR("/app/api-key", fetcher); 14 | 15 | const [copied, setCopied] = useState(false); 16 | 17 | const [show, setShow] = useState(false); 18 | 19 | const handleCopy = () => { 20 | setCopied(true); 21 | copyToClipboard(data?.key); 22 | setTimeout(() => { 23 | setCopied(false); 24 | }, 2000); 25 | }; 26 | 27 | return ( 28 | 29 | 30 | 31 |
32 |
33 |
34 | 35 |

API Key

36 |
37 |
38 |
39 |
40 |

setShow(!show)} 43 | > 44 | {data?.key} 45 |

46 |
47 | 58 |
59 |
60 |
61 |
62 | ); 63 | }; 64 | 65 | export default ApiKeySettings; 66 | -------------------------------------------------------------------------------- /api/src/controllers/weekdayAverage.ts: -------------------------------------------------------------------------------- 1 | import { db } from "../db"; 2 | import { sql } from "drizzle-orm"; 3 | import { pulses } from "../db/schema"; 4 | import { time_to_human } from "../utils/time_to_human"; 5 | 6 | export async function CalculateWeekdayAverage(userId: string) { 7 | try { 8 | const query = sql` 9 | WITH weekday_totals AS ( 10 | SELECT 11 | CASE 12 | WHEN EXTRACT(dow FROM ${pulses.created_at}) = 0 THEN 'Sunday' 13 | WHEN EXTRACT(dow FROM ${pulses.created_at}) = 1 THEN 'Monday' 14 | WHEN EXTRACT(dow FROM ${pulses.created_at}) = 2 THEN 'Tuesday' 15 | WHEN EXTRACT(dow FROM ${pulses.created_at}) = 3 THEN 'Wednesday' 16 | WHEN EXTRACT(dow FROM ${pulses.created_at}) = 4 THEN 'Thursday' 17 | WHEN EXTRACT(dow FROM ${pulses.created_at}) = 5 THEN 'Friday' 18 | WHEN EXTRACT(dow FROM ${pulses.created_at}) = 6 THEN 'Saturday' 19 | END AS day_of_week, 20 | SUM(${pulses.time}) AS total_time_seconds, 21 | COUNT(DISTINCT DATE(${pulses.created_at})) AS day_count 22 | FROM ${pulses} 23 | WHERE ${pulses.user_id} = ${userId} 24 | GROUP BY EXTRACT(dow FROM ${pulses.created_at}) 25 | ) 26 | SELECT 27 | day_of_week AS day, 28 | total_time_seconds / day_count AS average_time_seconds 29 | FROM weekday_totals 30 | ORDER BY 31 | CASE day_of_week 32 | WHEN 'Sunday' THEN 0 33 | WHEN 'Monday' THEN 1 34 | WHEN 'Tuesday' THEN 2 35 | WHEN 'Wednesday' THEN 3 36 | WHEN 'Thursday' THEN 4 37 | WHEN 'Friday' THEN 5 38 | WHEN 'Saturday' THEN 6 39 | END ASC; 40 | `; 41 | 42 | const result = await db.execute(query); 43 | 44 | // Format the result into the desired structure 45 | const weekday_average = result.rows.map((row) => { 46 | const averageTimeSeconds = Number(row.average_time_seconds); 47 | return { 48 | day: row.day, 49 | time: averageTimeSeconds.toString(), 50 | time_human_readable: time_to_human(averageTimeSeconds), 51 | }; 52 | }); 53 | 54 | return weekday_average; 55 | } catch (error) { 56 | console.error("Error calculating weekday average:", error); 57 | throw error; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /web/components/landing/TeamFeatures.tsx: -------------------------------------------------------------------------------- 1 | import { PiUsersThreeBold } from "react-icons/pi"; 2 | import IconThing from "../IconThing"; 3 | import { RiMedalLine } from "react-icons/ri"; 4 | import LineChart from "../LineChart"; 5 | import { LuGoal } from "react-icons/lu"; 6 | import { TbCalendarSmile } from "react-icons/tb"; 7 | 8 | const TeamFeatures = () => { 9 | const feats = [ 10 | { 11 | title: "Workspaces", 12 | icon: PiUsersThreeBold, 13 | bg: ( 14 | 19 | ), 20 | }, 21 | { 22 | title: "Private Leaderboards", 23 | icon: RiMedalLine, 24 | bg: ( 25 | 30 | ), 31 | }, 32 | { 33 | title: "Goal tracking", 34 | icon: LuGoal, 35 | }, 36 | { 37 | title: "Weekly reports", 38 | icon: TbCalendarSmile, 39 | }, 40 | ]; 41 | 42 | return ( 43 |
44 |

For software teams

45 |

46 | Lorem ipsum dolor sit amet, consectetur adipisicing elit. Architecto! 47 |

48 | 49 |
50 | {feats.map((f, i) => ( 51 |
55 | {f.bg} 56 | 57 |
58 |
59 | {f.icon && ( 60 | 61 | )} 62 |

{f.title}

63 |
64 |
65 |
66 | ))} 67 |
68 |
69 | ); 70 | }; 71 | 72 | export default TeamFeatures; 73 | -------------------------------------------------------------------------------- /web/components/landing/Features.tsx: -------------------------------------------------------------------------------- 1 | import { PiMagicWandBold } from "react-icons/pi"; 2 | import IconThing from "../IconThing"; 3 | import { MdOutlineMultilineChart } from "react-icons/md"; 4 | import LineChart from "../LineChart"; 5 | import { LuGoal } from "react-icons/lu"; 6 | import { TbCalendarSmile } from "react-icons/tb"; 7 | 8 | const Features = () => { 9 | const feats = [ 10 | { 11 | title: "Stunning user interface", 12 | icon: PiMagicWandBold, 13 | bg: ( 14 | 19 | ), 20 | }, 21 | { 22 | title: "Powerful analytics", 23 | icon: MdOutlineMultilineChart, 24 | bg: ( 25 | 30 | ), 31 | }, 32 | { 33 | title: "Goal tracking", 34 | icon: LuGoal, 35 | }, 36 | { 37 | title: "Weekly reports", 38 | icon: TbCalendarSmile, 39 | }, 40 | ]; 41 | 42 | return ( 43 |
44 |

Everything you need

45 |

46 | Lorem ipsum dolor sit amet, consectetur adipisicing elit. Architecto! 47 |

48 | 49 |
50 | {feats.map((f, i) => ( 51 |
55 | {f.bg} 56 | 57 |
58 |
59 | {f.icon && ( 60 | 61 | )} 62 |

{f.title}

63 |
64 |
65 |
66 | ))} 67 |
68 |
69 | ); 70 | }; 71 | 72 | export default Features; 73 | -------------------------------------------------------------------------------- /web/pages/dashboard.tsx: -------------------------------------------------------------------------------- 1 | import { PiFireSimpleFill } from "react-icons/pi"; 2 | import { TbClockFilled } from "react-icons/tb"; 3 | import Heatmap from "@/components/Heatmap"; 4 | import IconThing from "@/components/IconThing"; 5 | import { Projects } from "@/components/Projects"; 6 | import DashboardLayout from "@/components/layout/DashboardLayout"; 7 | import Card from "@/components/ui/Card"; 8 | import useSWR from "swr"; 9 | import { fetcher } from "@/utils/fetcher"; 10 | import { StatsResponse } from "@/types"; 11 | import Stats from "@/components/Stats"; 12 | import { useLogSnag } from "@logsnag/next"; 13 | import { useEffect } from "react"; 14 | 15 | const Dashboard = () => { 16 | const { data, isLoading, error } = useSWR( 17 | "/v1/stats", 18 | fetcher 19 | ); 20 | 21 | /********Going to be removed*****/ 22 | const { setUserId } = useLogSnag(); 23 | 24 | useEffect(() => { 25 | setUserId(data?.id!); 26 | }, [data]); 27 | /*********************************/ 28 | 29 | return ( 30 | 36 |
37 | 38 |
39 | 40 |
41 |
42 | 43 |

Streak

44 |
45 |
46 |

{data?.streak} days

47 |
48 | 49 |
50 |
51 | 52 |

Daily average

53 |
54 |
55 |

{data?.daily_average}

56 |
57 |
58 | 59 | 60 |
61 |
62 | ); 63 | }; 64 | 65 | export default Dashboard; 66 | -------------------------------------------------------------------------------- /extensions/vscode/src/api.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/naming-convention */ 2 | import * as vscode from "vscode"; 3 | import { getCurrentBranch } from "./utils/branch"; 4 | import { sendPulse } from "./utils/pulse"; 5 | import * as os from "os"; 6 | import { request } from "./configs/axios"; 7 | 8 | export const sendPulseData = async ({ 9 | apiKey, 10 | codingStartTime, 11 | filePath, 12 | language, 13 | }: { 14 | apiKey: string; 15 | codingStartTime: number; 16 | filePath: string; 17 | language: string; 18 | }) => { 19 | if (!apiKey || !codingStartTime) { 20 | return; 21 | } 22 | 23 | const codingEndTime = Date.now(); 24 | const pulseTime = codingEndTime - codingStartTime; 25 | 26 | const payload = { 27 | path: filePath, 28 | time: pulseTime, 29 | branch: await getCurrentBranch(getProjectPath()!), 30 | project: vscode.workspace.name || null, 31 | language: language, 32 | os: os.type(), 33 | hostname: os.hostname(), 34 | timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, 35 | editor: "VS Code", 36 | }; 37 | 38 | try { 39 | await sendPulse({ api_key: apiKey, payload }); 40 | } catch (error) { 41 | console.error("Error sending pulse:", error); 42 | } 43 | }; 44 | const getFilePath = () => { 45 | const activeEditor = vscode.window.activeTextEditor; 46 | return activeEditor ? activeEditor.document.uri.fsPath : null; 47 | }; 48 | 49 | const getProjectPath = () => { 50 | const workspaceFolders = vscode.workspace.workspaceFolders; 51 | return workspaceFolders && workspaceFolders.length > 0 52 | ? workspaceFolders[0].uri.fsPath 53 | : null; 54 | }; 55 | 56 | export const fetchCodingTimeToday = async (apiKey: string) => { 57 | try { 58 | const response = await request.get("/codetime-today", { 59 | headers: { 60 | Authorization: `Bearer ${apiKey}`, 61 | }, 62 | }); 63 | return response.data; 64 | } catch (error) { 65 | console.error("Error fetching coding time today:", error); 66 | return null; 67 | } 68 | }; 69 | 70 | export const validateApiKey = async (key: string) => { 71 | try { 72 | const response = await request.post("/activate-extension", { 73 | key, 74 | }); 75 | return { 76 | isValid: response.status === 200, 77 | codetime_today: response.data.codetime_today, 78 | }; 79 | } catch (error) { 80 | return { 81 | isValid: false, 82 | }; 83 | } 84 | }; 85 | -------------------------------------------------------------------------------- /web/components/BarChart.tsx: -------------------------------------------------------------------------------- 1 | import { Bar } from "react-chartjs-2"; 2 | import Chart from "chart.js/auto"; 3 | import { CategoryScale } from "chart.js"; 4 | import { useEffect, useRef, useState } from "react"; 5 | import { time_to_human } from "@/utils/time_to_human"; 6 | 7 | Chart.register(CategoryScale); 8 | Chart.defaults.plugins.legend.display = false; 9 | 10 | const BarChart = ({ labels, data }: { labels: string[]; data: number[] }) => { 11 | const chartRef = useRef(null); 12 | const [gradient, setGradient] = useState(null); 13 | 14 | useEffect(() => { 15 | if (chartRef.current) { 16 | const chart = chartRef.current; 17 | //@ts-ignore 18 | const ctx = chart.ctx; 19 | const gradient = ctx.createLinearGradient(0, 0, 0, ctx.canvas.height); 20 | gradient.addColorStop(0, "rgba(102, 16, 242, 0.5)"); 21 | gradient.addColorStop(1, "rgba(102, 16, 242, 0)"); 22 | 23 | setGradient(gradient); 24 | } 25 | }, [chartRef]); 26 | return ( 27 | { 73 | return `${time_to_human(context.parsed.y)}`; 74 | }, 75 | }, 76 | }, 77 | }, 78 | interaction: { 79 | mode: "index", 80 | intersect: false, 81 | }, 82 | }} 83 | /> 84 | ); 85 | }; 86 | 87 | export default BarChart; 88 | -------------------------------------------------------------------------------- /web/components/layout/ProjectsLayout.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | import { IoChevronBack } from "react-icons/io5"; 3 | import { TbBolt } from "react-icons/tb"; 4 | import DashboardLayout from "@/components/layout/DashboardLayout"; 5 | import useSWR from "swr"; 6 | import { fetcher } from "@/utils/fetcher"; 7 | import Link from "next/link"; 8 | import { useRouter } from "next/router"; 9 | 10 | const ProjectsLayout = ({ 11 | children, 12 | loading, 13 | }: { 14 | children: ReactNode; 15 | loading?: boolean; 16 | }) => { 17 | const location = useRouter(); 18 | 19 | const SubLinks = () => { 20 | const { data: projects } = useSWR("v1/projects", fetcher); 21 | 22 | return ( 23 |
24 |
25 |

Projects

26 |
27 |
28 | 32 | Overview 33 | 34 | 35 | {projects?.map((_: any, i: number) => ( 36 | 41 | 42 | {_.name} 43 | 44 | ))} 45 |
46 |
47 | ); 48 | }; 49 | 50 | return ( 51 | 57 | 58 | 59 | 60 | {location.asPath.split("/")[2]} 61 |
62 | ) : ( 63 | "Projects" 64 | ) 65 | } 66 | sublinks={} 67 | > 68 |
{children}
69 | 70 | ); 71 | }; 72 | 73 | export default ProjectsLayout; 74 | -------------------------------------------------------------------------------- /web/components/landing/ActivityChart.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import Heatmap from "../Heatmap"; 3 | import ReactCalendarHeatmap from "react-calendar-heatmap"; 4 | import { getClassByTime } from "@/utils/getClassByTime"; 5 | import { formatCount } from "@/utils/formatCount"; 6 | import { faker } from "@faker-js/faker"; 7 | import { Tooltip } from "react-tooltip"; 8 | import AnimatedShinyText from "../magicui/animated-shiny-text"; 9 | import { BorderBeam } from "../magicui/border-beam"; 10 | 11 | const ActivityChart = () => { 12 | const [values, setValues] = useState<{ date: Date; count: number }[]>([]); 13 | 14 | useEffect(() => { 15 | const startDate = new Date("2024-01-01"); 16 | const endDate = new Date("2024-12-31"); 17 | const data = []; 18 | 19 | for (let d = startDate; d <= endDate; d.setDate(d.getDate() + 1)) { 20 | const date = new Date(d); 21 | const count = faker.number.int({ min: 0, max: 50_400_000 }); 22 | data.push({ date, count }); 23 | } 24 | setValues(data); 25 | }, []); 26 | return ( 27 |
28 |
29 | 30 |
31 | 32 | Awesome activity chart 33 | 34 |

35 | See how much time you spend coding each day of the year. 36 |

37 |
38 | 39 |
40 | 41 | { 47 | if (!value) { 48 | return "color-scale-0"; 49 | } 50 | const { count } = value; 51 | 52 | return getClassByTime(count); 53 | }} 54 | tooltipDataAttrs={(value: { count: number; date: Date }) => { 55 | const { count, date } = value; 56 | return { 57 | "data-tooltip-content": `${formatCount(count)} on ${new Date(date).toLocaleDateString()}`, 58 | "data-tooltip-id": "codetime-tooltip", 59 | }; 60 | }} 61 | /> 62 |
63 |
64 |
65 | ); 66 | }; 67 | 68 | export default ActivityChart; 69 | -------------------------------------------------------------------------------- /web/components/Heatmap.tsx: -------------------------------------------------------------------------------- 1 | import Card from "./ui/Card"; 2 | import IconThing from "./IconThing"; 3 | import { BiSolidGridAlt } from "react-icons/bi"; 4 | import CalendarHeatmap from "react-calendar-heatmap"; 5 | import { useEffect, useState } from "react"; 6 | import { Tooltip } from "react-tooltip"; 7 | import { getClassByTime } from "@/utils/getClassByTime"; 8 | import { formatCount } from "@/utils/formatCount"; 9 | import { PulseData } from "@/types"; 10 | import { time_to_human } from "@/utils/time_to_human"; 11 | 12 | const Heatmap = ({ values }: { values: PulseData[] }) => { 13 | const formatDate = (date: Date): string => { 14 | const formattedMonthYear = new Intl.DateTimeFormat("en-US", { 15 | month: "long", 16 | }).format(date); 17 | 18 | const day = date.getDate(); 19 | const ordinalSuffix = (n: number) => { 20 | if (n > 3 && n < 21) return "th"; 21 | switch (n % 10) { 22 | case 1: 23 | return "st"; 24 | case 2: 25 | return "nd"; 26 | case 3: 27 | return "rd"; 28 | default: 29 | return "th"; 30 | } 31 | }; 32 | 33 | const dayWithSuffix = day + ordinalSuffix(day); 34 | return `${dayWithSuffix} ${formattedMonthYear}`; 35 | }; 36 | 37 | return ( 38 | 39 | 40 |
41 |
42 |
43 | 44 |

Activity

45 |
46 |
47 |
48 |
49 | { 55 | if (!value) { 56 | return "color-scale-0"; 57 | } 58 | const { count } = value; 59 | 60 | return getClassByTime(count); 61 | }} 62 | tooltipDataAttrs={(value: { count: number; date: Date }) => { 63 | const { count, date } = value; 64 | return { 65 | "data-tooltip-content": ` ${count === 0 ? "No activity" : `${time_to_human(count)}`} on ${formatDate(new Date(date))}`, 66 | "data-tooltip-id": "codetime-tooltip", 67 | }; 68 | }} 69 | /> 70 |
71 |
72 | ); 73 | }; 74 | 75 | export default Heatmap; 76 | -------------------------------------------------------------------------------- /api/src/controllers/auth/signup.ts: -------------------------------------------------------------------------------- 1 | import { Context } from "hono"; 2 | import { validateEmail, validateUsername } from "../../utils/validators"; 3 | import { db } from "../../db"; 4 | import { users } from "../../db/schema"; 5 | import { generateAlphaNumeric } from "../../utils/generators"; 6 | import { hashSync } from "bcryptjs"; 7 | import dayjs from "dayjs"; 8 | import { logsnag } from "../../configs/logsnag"; 9 | import { plunk } from "../../configs/plunk"; 10 | 11 | export const Signup = async (c: Context) => { 12 | const { 13 | username, 14 | email, 15 | password, 16 | }: { username: string; email: string; password: string } = await c.req.json(); 17 | try { 18 | if (!username || !email || !password) { 19 | return c.json({ message: "Missing fields." }, 400); 20 | } 21 | 22 | const usernameValidationError = await validateUsername(username); 23 | 24 | if (usernameValidationError) { 25 | return c.json({ message: usernameValidationError }, 400); 26 | } 27 | 28 | const emailValidationError = await validateEmail(email); 29 | 30 | if (emailValidationError) { 31 | return c.json({ message: emailValidationError }, 400); 32 | } 33 | 34 | if (password.length < 8) { 35 | return c.json({ message: "Password must be at least 8 characters." }); 36 | } 37 | 38 | const otp = generateAlphaNumeric(); 39 | 40 | const [newUser] = await db 41 | .insert(users) 42 | .values({ 43 | username, 44 | email, 45 | password: hashSync(password, 10), 46 | otp, 47 | otp_expires_at: dayjs().add(30, "minutes").toDate(), 48 | api_key: generateAlphaNumeric(69), 49 | }) 50 | .returning(); 51 | 52 | await plunk.emails.send({ 53 | to: newUser.email, 54 | subject: "Complete your signup", 55 | body: ` 56 |

You're almost done

57 |

Use the OTP below to complete your signup

58 |

${otp}

59 |
60 | Sooner, 61 | Cheers. 62 | `, 63 | subscribed: true, 64 | }); 65 | 66 | await logsnag.track({ 67 | channel: "users", 68 | event: "New User", 69 | user_id: newUser.id, 70 | icon: "🔥", 71 | notify: true, 72 | description: newUser.email, 73 | }); 74 | 75 | await logsnag.identify({ 76 | user_id: newUser.id, 77 | properties: { 78 | username: newUser.username!, 79 | email: newUser.email, 80 | }, 81 | }); 82 | 83 | return c.json({ message: "User created." }, 201); 84 | } catch (error) { 85 | console.log(error); 86 | c.json({ message: "Something went wrong." }, 500); 87 | } 88 | }; 89 | -------------------------------------------------------------------------------- /web/tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | 3 | const config: Config = { 4 | content: [ 5 | "./pages/**/*.{js,ts,jsx,tsx,mdx}", 6 | "./components/**/*.{js,ts,jsx,tsx,mdx}", 7 | "./app/**/*.{js,ts,jsx,tsx,mdx}", 8 | ], 9 | theme: { 10 | extend: { 11 | colors: { 12 | black: "#09090b", 13 | accent: "#6610F2", 14 | grey: "#1f1f1f", 15 | "grey-100": "#a3a3a3", 16 | "grey-200": "#171717", 17 | "grey-300": "#262626", 18 | white: "#FAFAFA", 19 | }, 20 | animation: { 21 | "spin-around": "spin-around calc(var(--speed) * 2) infinite linear", 22 | slide: "slide var(--speed) ease-in-out infinite alternate", 23 | shimmer: "shimmer 8s infinite", 24 | "border-beam": "border-beam calc(var(--duration)*1s) infinite linear", 25 | ripple: "ripple 3400ms ease infinite", 26 | marquee: "marquee var(--duration) linear infinite", 27 | "marquee-vertical": "marquee-vertical var(--duration) linear infinite", 28 | }, 29 | keyframes: { 30 | marquee: { 31 | from: { transform: "translateX(0)" }, 32 | to: { transform: "translateX(calc(-100% - var(--gap)))" }, 33 | }, 34 | "marquee-vertical": { 35 | from: { transform: "translateY(0)" }, 36 | to: { transform: "translateY(calc(-100% - var(--gap)))" }, 37 | }, 38 | ripple: { 39 | "0%, 100%": { 40 | transform: "translate(-50%, -50%) scale(1)", 41 | }, 42 | "50%": { 43 | transform: "translate(-50%, -50%) scale(0.9)", 44 | }, 45 | }, 46 | "border-beam": { 47 | "100%": { 48 | "offset-distance": "100%", 49 | }, 50 | }, 51 | "spin-around": { 52 | "0%": { 53 | transform: "translateZ(0) rotate(0)", 54 | }, 55 | "15%, 35%": { 56 | transform: "translateZ(0) rotate(90deg)", 57 | }, 58 | "65%, 85%": { 59 | transform: "translateZ(0) rotate(270deg)", 60 | }, 61 | "100%": { 62 | transform: "translateZ(0) rotate(360deg)", 63 | }, 64 | }, 65 | shimmer: { 66 | "0%, 90%, 100%": { 67 | "background-position": "calc(-100% - var(--shimmer-width)) 0", 68 | }, 69 | "30%, 60%": { 70 | "background-position": "calc(100% + var(--shimmer-width)) 0", 71 | }, 72 | }, 73 | slide: { 74 | to: { 75 | transform: "translate(calc(100cqw - 100%), 0)", 76 | }, 77 | }, 78 | }, 79 | }, 80 | }, 81 | plugins: [], 82 | }; 83 | export default config; 84 | -------------------------------------------------------------------------------- /web/pages/promise.tsx: -------------------------------------------------------------------------------- 1 | import Footer from "@/components/landing/Footer"; 2 | import Navbar from "@/components/landing/Navbar"; 3 | import React from "react"; 4 | 5 | const Promise = () => { 6 | return ( 7 |
8 | 9 |
10 |

Forever Promise

11 | 12 |

13 | It sucks when services we use and love shut down. So Sooner comes with 14 | a promise: it will remain online forever. 15 |

16 |
17 |

18 | Apps and services shut down too often, and our data/content tends to 19 | either get lost completely or we are forced to tediously transition it 20 | to yet another service of questionable longevity. I built Sooner to 21 | solve my own problems, so I feel a great obligation to users. This 22 | platform must stay alive. 23 |

24 | 25 |
26 |

So these are a few promises from me to you:

27 |
28 |
    29 |
  1. 30 | I promise to do everything in my power to ensure that all your 31 | tracked coding activities on the platform will continue to be 32 | available on the web as long as you want it there. Even if you 33 | cancel your subscription. 34 |
  2. 35 |
  3. 36 | Your coding activity will be available forever, nothing gets deleted 37 | even on the free plan. As long as you have an active subscription 38 | you will be able to view and export your coding activities through 39 | any interval. This essentially means that I promise to never shut 40 | down the platform. 41 |
  4. 42 |
  5. 43 | If Sooner ever gets acquired, purchased, or taken majority control 44 | of by a third party in a way that would negatively impact the 45 | service, I will do everything in my power to ensure that the above 46 | two promises are kept as part of any legal agreements we enter into. 47 |
  6. 48 |
49 |

This is my promise.

50 | 51 |
52 |

53 | Inspired by{" "} 54 | 55 | Svbtle 56 | 57 |

58 |
59 |
60 |
61 |
62 | ); 63 | }; 64 | 65 | export default Promise; 66 | -------------------------------------------------------------------------------- /web/components/LineChart.tsx: -------------------------------------------------------------------------------- 1 | import { Line } from "react-chartjs-2"; 2 | import Chart from "chart.js/auto"; 3 | import { CategoryScale } from "chart.js"; 4 | import { useEffect, useRef, useState } from "react"; 5 | import { formatCount } from "@/utils/formatCount"; 6 | 7 | Chart.register(CategoryScale); 8 | Chart.defaults.plugins.legend.display = false; 9 | 10 | const LineChart = ({ 11 | labels, 12 | data, 13 | hideYaxisLabels = false, 14 | }: { 15 | labels: string[]; 16 | data: number[]; 17 | hideYaxisLabels?: boolean; 18 | }) => { 19 | const chartRef = useRef(null); 20 | const [gradient, setGradient] = useState(null); 21 | 22 | useEffect(() => { 23 | if (chartRef.current) { 24 | const chart = chartRef.current; 25 | //@ts-ignore 26 | const ctx = chart.ctx; 27 | const gradient = ctx.createLinearGradient(0, 0, 0, ctx.canvas.height); 28 | gradient.addColorStop(0, "rgba(102, 16, 242, 0.1)"); 29 | gradient.addColorStop(1, "rgba(102, 16, 242, 0)"); 30 | 31 | setGradient(gradient); 32 | } 33 | }, [chartRef]); 34 | 35 | return ( 36 | { 87 | return `${formatCount(context.parsed.y)}`; 88 | }, 89 | }, 90 | }, 91 | }, 92 | interaction: { 93 | mode: "index", 94 | intersect: false, 95 | }, 96 | }} 97 | /> 98 | ); 99 | }; 100 | 101 | export default LineChart; 102 | -------------------------------------------------------------------------------- /extensions/vscode/vsc-extension-quickstart.md: -------------------------------------------------------------------------------- 1 | # Welcome to your VS Code Extension 2 | 3 | ## What's in the folder 4 | 5 | * This folder contains all of the files necessary for your extension. 6 | * `package.json` - this is the manifest file in which you declare your extension and command. 7 | * The sample plugin registers a command and defines its title and command name. With this information VS Code can show the command in the command palette. It doesn’t yet need to load the plugin. 8 | * `src/extension.ts` - this is the main file where you will provide the implementation of your command. 9 | * The file exports one function, `activate`, which is called the very first time your extension is activated (in this case by executing the command). Inside the `activate` function we call `registerCommand`. 10 | * We pass the function containing the implementation of the command as the second parameter to `registerCommand`. 11 | 12 | ## Get up and running straight away 13 | 14 | * Press `F5` to open a new window with your extension loaded. 15 | * Run your command from the command palette by pressing (`Ctrl+Shift+P` or `Cmd+Shift+P` on Mac) and typing `Hello World`. 16 | * Set breakpoints in your code inside `src/extension.ts` to debug your extension. 17 | * Find output from your extension in the debug console. 18 | 19 | ## Make changes 20 | 21 | * You can relaunch the extension from the debug toolbar after changing code in `src/extension.ts`. 22 | * You can also reload (`Ctrl+R` or `Cmd+R` on Mac) the VS Code window with your extension to load your changes. 23 | 24 | ## Explore the API 25 | 26 | * You can open the full set of our API when you open the file `node_modules/@types/vscode/index.d.ts`. 27 | 28 | ## Run tests 29 | 30 | * Open the debug viewlet (`Ctrl+Shift+D` or `Cmd+Shift+D` on Mac) and from the launch configuration dropdown pick `Extension Tests`. 31 | * Press `F5` to run the tests in a new window with your extension loaded. 32 | * See the output of the test result in the debug console. 33 | * Make changes to `src/test/suite/extension.test.ts` or create new test files inside the `test/suite` folder. 34 | * The provided test runner will only consider files matching the name pattern `**.test.ts`. 35 | * You can create folders inside the `test` folder to structure your tests any way you want. 36 | 37 | ## Go further 38 | 39 | * [Follow UX guidelines](https://code.visualstudio.com/api/ux-guidelines/overview) to create extensions that seamlessly integrate with VS Code's native interface and patterns. 40 | * Reduce the extension size and improve the startup time by [bundling your extension](https://code.visualstudio.com/api/working-with-extensions/bundling-extension). 41 | * [Publish your extension](https://code.visualstudio.com/api/working-with-extensions/publishing-extension) on the VS Code extension marketplace. 42 | * Automate builds by setting up [Continuous Integration](https://code.visualstudio.com/api/working-with-extensions/continuous-integration). 43 | -------------------------------------------------------------------------------- /web/pages/insights.tsx: -------------------------------------------------------------------------------- 1 | import { Clock01Icon } from "hugeicons-react"; 2 | import { GoDotFill } from "react-icons/go"; 3 | import BarChart from "@/components/BarChart"; 4 | import DashboardLayout from "@/components/layout/DashboardLayout"; 5 | import Card from "@/components/ui/Card"; 6 | import { getColorForLanguage } from "@/utils/getColorForLanguage"; 7 | import { time_to_human } from "@/utils/time_to_human"; 8 | import useSWR from "swr"; 9 | import { fetcher } from "@/utils/fetcher"; 10 | import { InsightsData } from "@/types"; 11 | 12 | const Insights = () => { 13 | const { data, isLoading } = useSWR("/v1/insights", fetcher); 14 | 15 | return ( 16 | 17 |
18 | 19 |

Weekday average

20 |
21 | _.time)!} 23 | labels={data?.weekday_average.map((_) => _.day)!} 24 | /> 25 |
26 |
27 | 28 |
29 | 30 |

Top languages

31 | {data?.top_languages 32 | .sort((a, b) => b.time - a.time) 33 | .map((l, i) => ( 34 |
38 |

39 | 43 | 44 | {l.language} 45 |

46 |

47 | {time_to_human(l.time)} 48 | 49 |

50 |
51 | ))} 52 |
53 | 54 |

Top projects

55 | {data?.top_projects 56 | .sort((a, b) => b.time - a.time) 57 | .map((p, i) => ( 58 |
62 |

{p.project}

63 |

64 | {time_to_human(p.time)} 65 | 66 |

67 |
68 | ))} 69 |
70 |
71 |
72 |
73 | ); 74 | }; 75 | 76 | export default Insights; 77 | -------------------------------------------------------------------------------- /web/pages/bug-report.tsx: -------------------------------------------------------------------------------- 1 | import DashboardLayout from "@/components/layout/DashboardLayout"; 2 | import React, { useState } from "react"; 3 | import { BiSolidInfoCircle } from "react-icons/bi"; 4 | import { useLogSnag } from "@logsnag/next"; 5 | import { CgSpinner } from "react-icons/cg"; 6 | 7 | const BugReport = () => { 8 | const [content, setContent] = useState(""); 9 | const [submitting, setSubmitting] = useState(false); 10 | const [submitted, setSubmitted] = useState(false); 11 | const [failed, setFailed] = useState(false); 12 | 13 | const { track } = useLogSnag(); 14 | 15 | const submitBugReport = () => { 16 | if (!content) return; 17 | setSubmitting(true); 18 | setFailed(false); 19 | try { 20 | track({ 21 | channel: "bug-report", 22 | event: "New Bug Report", 23 | icon: "🪲", 24 | description: content, 25 | notify: true, 26 | }); 27 | setContent(""); 28 | setSubmitted(true); 29 | setTimeout(() => { 30 | setSubmitted(false); 31 | }, 5000); 32 | } catch (error) { 33 | setFailed(true); 34 | } finally { 35 | setSubmitting(false); 36 | } 37 | }; 38 | 39 | return ( 40 | 41 |
42 |

43 | 44 | This is an early release of Sooner, incase you find a bug feel free to 45 | report, it will be fixed as soon as possible. 46 |

47 | 48 |

49 | {submitted && "Thank you! Your report has been submitted."} 50 |

51 |

54 | {failed && "Bug report submission failed, please try again."} 55 |

56 | 57 |
58 | 64 | 77 |
78 |
79 |
80 | ); 81 | }; 82 | 83 | export default BugReport; 84 | -------------------------------------------------------------------------------- /web/pages/verify.tsx: -------------------------------------------------------------------------------- 1 | import { axios } from "@/utils/axios"; 2 | import { useLogSnag } from "@logsnag/next"; 3 | import { useRouter } from "next/router"; 4 | import React, { useState } from "react"; 5 | import { CgSpinner } from "react-icons/cg"; 6 | import OTPInput from "react-otp-input"; 7 | 8 | const Error = ({ 9 | show, 10 | children, 11 | }: { 12 | show: boolean; 13 | children: React.ReactNode; 14 | }) => { 15 | return ( 16 |
19 | {children} 20 |
21 | ); 22 | }; 23 | 24 | const Verify = () => { 25 | const [isSubmitting, setSubmitting] = useState(false); 26 | const [otp, setOtp] = useState(""); 27 | const [error, setError] = useState(""); 28 | 29 | const router = useRouter(); 30 | 31 | const { setUserId } = useLogSnag(); 32 | 33 | const handleVerify = async () => { 34 | setSubmitting(true); 35 | setError(""); 36 | try { 37 | const { data } = await axios.post("/auth/verify", { otp }); 38 | setUserId(data.id); 39 | router.push("/onboarding"); 40 | } catch (error: any) { 41 | setError(error.response.data.message || "Something went wrong"); 42 | } finally { 43 | setSubmitting(false); 44 | } 45 | }; 46 | 47 | return ( 48 |
49 |
50 | Sooner logo 51 |

52 | Verify your Sooner account 53 |

54 |

Enter the OTP sent to your email

55 |
56 | 57 |
58 | } 64 | inputStyle={{ 65 | width: "40px", 66 | height: "40px", 67 | background: "transparent", 68 | fontSize: "20px", 69 | color: "white", 70 | outline: "none", 71 | border: "1px solid #6610F250", 72 | borderRadius: "5px", 73 | }} 74 | containerStyle={{ 75 | display: "flex", 76 | alignItems: "center", 77 | justifyContent: "space-between", 78 | margin: "15px 0", 79 | }} 80 | /> 81 | 93 | {error} 94 |
95 |
96 | ); 97 | }; 98 | 99 | export default Verify; 100 | -------------------------------------------------------------------------------- /web/components/magicui/shimmer-button.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | import React, { CSSProperties } from "react"; 3 | 4 | export interface ShimmerButtonProps 5 | extends React.ButtonHTMLAttributes { 6 | shimmerColor?: string; 7 | shimmerSize?: string; 8 | borderRadius?: string; 9 | shimmerDuration?: string; 10 | background?: string; 11 | className?: string; 12 | children?: React.ReactNode; 13 | } 14 | 15 | const ShimmerButton = React.forwardRef( 16 | ( 17 | { 18 | shimmerColor = "#ffffff", 19 | shimmerSize = "0.05em", 20 | shimmerDuration = "3s", 21 | borderRadius = "100px", 22 | background = "rgb(102, 16, 242)", 23 | className, 24 | children, 25 | ...props 26 | }, 27 | ref 28 | ) => { 29 | return ( 30 | 89 | ); 90 | } 91 | ); 92 | 93 | ShimmerButton.displayName = "ShimmerButton"; 94 | 95 | export default ShimmerButton; 96 | -------------------------------------------------------------------------------- /web/components/layout/DashboardLayout.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { 3 | Bug02Icon, 4 | ChartHistogramIcon, 5 | Folder01Icon, 6 | Home01Icon, 7 | Settings01Icon, 8 | } from "hugeicons-react"; 9 | import { FC, ReactNode, useEffect } from "react"; 10 | import { Tooltip } from "react-tooltip"; 11 | import { useRouter } from "next/router"; 12 | import Head from "next/head"; 13 | import { motion } from "framer-motion"; 14 | import { CgSpinner } from "react-icons/cg"; 15 | 16 | const DashboardLayout: FC<{ 17 | children: ReactNode; 18 | title?: string; 19 | maintitle?: ReactNode; 20 | sublinks?: ReactNode; 21 | loading?: boolean; 22 | error?: any; 23 | }> = ({ children, title, sublinks, maintitle, loading, error }) => { 24 | const sidebarlinks = [ 25 | { icon: Home01Icon, href: "/dashboard", text: "Dashboard" }, 26 | { icon: Folder01Icon, href: "/projects", text: "Projects" }, 27 | { icon: ChartHistogramIcon, href: "/insights", text: "Insights" }, 28 | { icon: Settings01Icon, href: "/settings", text: "Settings" }, 29 | { icon: Bug02Icon, href: "/bug-report", text: "Report a bug" }, 30 | ]; 31 | 32 | const location = useRouter(); 33 | 34 | const variants = { 35 | hidden: { opacity: 0, x: 100 }, 36 | enter: { opacity: 1, x: 0, transition: { duration: 0.2 } }, 37 | }; 38 | 39 | useEffect(() => { 40 | console.log(error); 41 | }, []); 42 | 43 | return ( 44 |
45 | 46 | {`${title} ~ Sooner`} 47 | 48 | 49 |
50 |
51 | 52 |
53 |
54 | {sidebarlinks.map((link, i) => ( 55 | 61 | 68 | 69 | ))} 70 |
71 |
72 | {sublinks && sublinks} 73 |
74 |
75 |

{maintitle}

76 |
77 | 84 |
85 | {loading ? ( 86 |
87 | 88 |
89 | ) : ( 90 | children 91 | )} 92 |
93 |
94 |
95 |
96 | ); 97 | }; 98 | 99 | export default DashboardLayout; 100 | -------------------------------------------------------------------------------- /api/src/controllers/stats.ts: -------------------------------------------------------------------------------- 1 | import { Context } from "hono"; 2 | import { db } from "../db"; 3 | import { and, eq, gte, lte, sum, min } from "drizzle-orm"; 4 | import { pulses, users } from "../db/schema"; 5 | import dayjs from "dayjs"; 6 | import { time_to_human } from "../utils/time_to_human"; 7 | import { GetActivityChartData } from "./activityChartData"; 8 | import { CalculateStreak } from "./calculateStreak"; 9 | import { logsnag } from "../configs/logsnag"; 10 | 11 | export const Stats = async (c: Context) => { 12 | try { 13 | const userId = c.get("user_id"); 14 | 15 | const [today] = await db 16 | .select({ 17 | time: sum(pulses.time), 18 | }) 19 | .from(pulses) 20 | .where( 21 | and( 22 | eq(pulses.user_id, userId), 23 | gte(pulses.created_at, dayjs().startOf("day").toDate()), 24 | lte(pulses.created_at, dayjs().endOf("day").toDate()) 25 | ) 26 | ); 27 | 28 | const [thisWeek] = await db 29 | .select({ 30 | time: sum(pulses.time), 31 | }) 32 | .from(pulses) 33 | .where( 34 | and( 35 | eq(pulses.user_id, userId), 36 | gte(pulses.created_at, dayjs().startOf("week").toDate()), 37 | lte(pulses.created_at, dayjs().endOf("week").toDate()) 38 | ) 39 | ); 40 | 41 | const [thisMonth] = await db 42 | .select({ 43 | time: sum(pulses.time), 44 | }) 45 | .from(pulses) 46 | .where( 47 | and( 48 | eq(pulses.user_id, userId), 49 | gte(pulses.created_at, dayjs().startOf("month").toDate()), 50 | lte(pulses.created_at, dayjs().endOf("month").toDate()) 51 | ) 52 | ); 53 | 54 | const [allTime] = await db 55 | .select({ 56 | time: sum(pulses.time), 57 | }) 58 | .from(pulses) 59 | .where(eq(pulses.user_id, userId)); 60 | 61 | const [firstEntry] = await db 62 | .select({ 63 | created_at: min(pulses.created_at), 64 | }) 65 | .from(pulses) 66 | .where(eq(pulses.user_id, userId)); 67 | 68 | const [last7Days] = await db 69 | .select({ 70 | time: sum(pulses.time), 71 | }) 72 | .from(pulses) 73 | .where( 74 | and( 75 | eq(pulses.user_id, userId), 76 | gte( 77 | pulses.created_at, 78 | dayjs().subtract(7, "day").startOf("day").toDate() 79 | ), 80 | lte(pulses.created_at, dayjs().endOf("day").toDate()) 81 | ) 82 | ); 83 | 84 | const dailyAverageLast7Days = Number(last7Days.time) / 7; 85 | 86 | const activity = await GetActivityChartData(userId); 87 | 88 | const streak = await CalculateStreak(userId); 89 | 90 | const [user] = await db.select().from(users).where(eq(users.id, userId)); 91 | 92 | /***Will be removed soon*/ 93 | await logsnag.identify({ 94 | user_id: user.id, 95 | properties: { 96 | username: user.username!, 97 | email: user.email, 98 | }, 99 | }); 100 | /********************/ 101 | 102 | return c.json( 103 | { 104 | id: user.id, 105 | daily_average: time_to_human(dailyAverageLast7Days), 106 | codetime: { 107 | Today: { 108 | time: time_to_human(Number(today.time)), 109 | }, 110 | "This week": { 111 | time: time_to_human(Number(thisWeek.time)), 112 | }, 113 | "This month": { 114 | time: time_to_human(Number(thisMonth.time)), 115 | }, 116 | "All time": { 117 | time: time_to_human(Number(allTime.time)), 118 | }, 119 | }, 120 | streak, 121 | activity, 122 | }, 123 | 200 124 | ); 125 | } catch (error) { 126 | return c.json({ message: "Something went wrong." }, 500); 127 | } 128 | }; 129 | -------------------------------------------------------------------------------- /api/src/drizzle/meta/0003_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "3d56785f-4104-4cf0-b1d9-f5fdf61ff55a", 3 | "prevId": "1ab4a8a7-b255-4c75-98df-bc16de4b1524", 4 | "version": "6", 5 | "dialect": "postgresql", 6 | "tables": { 7 | "public.users": { 8 | "name": "users", 9 | "schema": "", 10 | "columns": { 11 | "id": { 12 | "name": "id", 13 | "type": "uuid", 14 | "primaryKey": true, 15 | "notNull": true, 16 | "default": "gen_random_uuid()" 17 | }, 18 | "username": { 19 | "name": "username", 20 | "type": "text", 21 | "primaryKey": false, 22 | "notNull": false 23 | }, 24 | "password": { 25 | "name": "password", 26 | "type": "text", 27 | "primaryKey": false, 28 | "notNull": false 29 | }, 30 | "avatar": { 31 | "name": "avatar", 32 | "type": "text", 33 | "primaryKey": false, 34 | "notNull": false 35 | }, 36 | "github": { 37 | "name": "github", 38 | "type": "text", 39 | "primaryKey": false, 40 | "notNull": false 41 | }, 42 | "twitter": { 43 | "name": "twitter", 44 | "type": "text", 45 | "primaryKey": false, 46 | "notNull": false 47 | }, 48 | "website": { 49 | "name": "website", 50 | "type": "text", 51 | "primaryKey": false, 52 | "notNull": false 53 | }, 54 | "email": { 55 | "name": "email", 56 | "type": "text", 57 | "primaryKey": false, 58 | "notNull": true 59 | }, 60 | "created_at": { 61 | "name": "created_at", 62 | "type": "timestamp", 63 | "primaryKey": false, 64 | "notNull": false, 65 | "default": "now()" 66 | }, 67 | "updated_at": { 68 | "name": "updated_at", 69 | "type": "timestamp", 70 | "primaryKey": false, 71 | "notNull": false, 72 | "default": "now()" 73 | }, 74 | "otp": { 75 | "name": "otp", 76 | "type": "text", 77 | "primaryKey": false, 78 | "notNull": false 79 | }, 80 | "otp_expires_at": { 81 | "name": "otp_expires_at", 82 | "type": "timestamp", 83 | "primaryKey": false, 84 | "notNull": false 85 | }, 86 | "display_name": { 87 | "name": "display_name", 88 | "type": "text", 89 | "primaryKey": false, 90 | "notNull": false 91 | }, 92 | "is_profile_public": { 93 | "name": "is_profile_public", 94 | "type": "boolean", 95 | "primaryKey": false, 96 | "notNull": false, 97 | "default": false 98 | }, 99 | "display_codetime_publicly": { 100 | "name": "display_codetime_publicly", 101 | "type": "boolean", 102 | "primaryKey": false, 103 | "notNull": false, 104 | "default": false 105 | } 106 | }, 107 | "indexes": {}, 108 | "foreignKeys": {}, 109 | "compositePrimaryKeys": {}, 110 | "uniqueConstraints": { 111 | "users_username_unique": { 112 | "name": "users_username_unique", 113 | "nullsNotDistinct": false, 114 | "columns": [ 115 | "username" 116 | ] 117 | }, 118 | "users_email_unique": { 119 | "name": "users_email_unique", 120 | "nullsNotDistinct": false, 121 | "columns": [ 122 | "email" 123 | ] 124 | } 125 | } 126 | } 127 | }, 128 | "enums": {}, 129 | "schemas": {}, 130 | "_meta": { 131 | "columns": {}, 132 | "schemas": {}, 133 | "tables": {} 134 | } 135 | } -------------------------------------------------------------------------------- /api/src/drizzle/meta/0001_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "596c6812-c92d-4ace-a557-ea2e789a80c7", 3 | "prevId": "2c7d8f5b-d001-4673-960c-20503a2acc7a", 4 | "version": "6", 5 | "dialect": "postgresql", 6 | "tables": { 7 | "public.users": { 8 | "name": "users", 9 | "schema": "", 10 | "columns": { 11 | "id": { 12 | "name": "id", 13 | "type": "text", 14 | "primaryKey": true, 15 | "notNull": true, 16 | "default": "'e599566f-4ee0-4b77-a4b6-43f9f7fddcc4'" 17 | }, 18 | "username": { 19 | "name": "username", 20 | "type": "text", 21 | "primaryKey": false, 22 | "notNull": false 23 | }, 24 | "password": { 25 | "name": "password", 26 | "type": "text", 27 | "primaryKey": false, 28 | "notNull": false 29 | }, 30 | "avatar": { 31 | "name": "avatar", 32 | "type": "text", 33 | "primaryKey": false, 34 | "notNull": false 35 | }, 36 | "github": { 37 | "name": "github", 38 | "type": "text", 39 | "primaryKey": false, 40 | "notNull": false 41 | }, 42 | "twitter": { 43 | "name": "twitter", 44 | "type": "text", 45 | "primaryKey": false, 46 | "notNull": false 47 | }, 48 | "website": { 49 | "name": "website", 50 | "type": "text", 51 | "primaryKey": false, 52 | "notNull": false 53 | }, 54 | "email": { 55 | "name": "email", 56 | "type": "text", 57 | "primaryKey": false, 58 | "notNull": true 59 | }, 60 | "created_at": { 61 | "name": "created_at", 62 | "type": "timestamp", 63 | "primaryKey": false, 64 | "notNull": false, 65 | "default": "now()" 66 | }, 67 | "updated_at": { 68 | "name": "updated_at", 69 | "type": "timestamp", 70 | "primaryKey": false, 71 | "notNull": false, 72 | "default": "now()" 73 | }, 74 | "otp": { 75 | "name": "otp", 76 | "type": "text", 77 | "primaryKey": false, 78 | "notNull": false 79 | }, 80 | "otp_expires_at": { 81 | "name": "otp_expires_at", 82 | "type": "timestamp", 83 | "primaryKey": false, 84 | "notNull": false 85 | }, 86 | "display_name": { 87 | "name": "display_name", 88 | "type": "text", 89 | "primaryKey": false, 90 | "notNull": false 91 | }, 92 | "is_profile_public": { 93 | "name": "is_profile_public", 94 | "type": "boolean", 95 | "primaryKey": false, 96 | "notNull": false, 97 | "default": false 98 | }, 99 | "display_codetime_publicly": { 100 | "name": "display_codetime_publicly", 101 | "type": "boolean", 102 | "primaryKey": false, 103 | "notNull": false, 104 | "default": false 105 | } 106 | }, 107 | "indexes": {}, 108 | "foreignKeys": {}, 109 | "compositePrimaryKeys": {}, 110 | "uniqueConstraints": { 111 | "users_username_unique": { 112 | "name": "users_username_unique", 113 | "nullsNotDistinct": false, 114 | "columns": [ 115 | "username" 116 | ] 117 | }, 118 | "users_email_unique": { 119 | "name": "users_email_unique", 120 | "nullsNotDistinct": false, 121 | "columns": [ 122 | "email" 123 | ] 124 | } 125 | } 126 | } 127 | }, 128 | "enums": {}, 129 | "schemas": {}, 130 | "_meta": { 131 | "columns": {}, 132 | "schemas": {}, 133 | "tables": {} 134 | } 135 | } -------------------------------------------------------------------------------- /api/src/drizzle/meta/0002_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "1ab4a8a7-b255-4c75-98df-bc16de4b1524", 3 | "prevId": "596c6812-c92d-4ace-a557-ea2e789a80c7", 4 | "version": "6", 5 | "dialect": "postgresql", 6 | "tables": { 7 | "public.users": { 8 | "name": "users", 9 | "schema": "", 10 | "columns": { 11 | "id": { 12 | "name": "id", 13 | "type": "text", 14 | "primaryKey": true, 15 | "notNull": true, 16 | "default": "'fdd04d58-b87c-433a-8096-14d49d249d1c'" 17 | }, 18 | "username": { 19 | "name": "username", 20 | "type": "text", 21 | "primaryKey": false, 22 | "notNull": false 23 | }, 24 | "password": { 25 | "name": "password", 26 | "type": "text", 27 | "primaryKey": false, 28 | "notNull": false 29 | }, 30 | "avatar": { 31 | "name": "avatar", 32 | "type": "text", 33 | "primaryKey": false, 34 | "notNull": false 35 | }, 36 | "github": { 37 | "name": "github", 38 | "type": "text", 39 | "primaryKey": false, 40 | "notNull": false 41 | }, 42 | "twitter": { 43 | "name": "twitter", 44 | "type": "text", 45 | "primaryKey": false, 46 | "notNull": false 47 | }, 48 | "website": { 49 | "name": "website", 50 | "type": "text", 51 | "primaryKey": false, 52 | "notNull": false 53 | }, 54 | "email": { 55 | "name": "email", 56 | "type": "text", 57 | "primaryKey": false, 58 | "notNull": true 59 | }, 60 | "created_at": { 61 | "name": "created_at", 62 | "type": "timestamp", 63 | "primaryKey": false, 64 | "notNull": false, 65 | "default": "now()" 66 | }, 67 | "updated_at": { 68 | "name": "updated_at", 69 | "type": "timestamp", 70 | "primaryKey": false, 71 | "notNull": false, 72 | "default": "now()" 73 | }, 74 | "otp": { 75 | "name": "otp", 76 | "type": "text", 77 | "primaryKey": false, 78 | "notNull": false 79 | }, 80 | "otp_expires_at": { 81 | "name": "otp_expires_at", 82 | "type": "timestamp", 83 | "primaryKey": false, 84 | "notNull": false 85 | }, 86 | "display_name": { 87 | "name": "display_name", 88 | "type": "text", 89 | "primaryKey": false, 90 | "notNull": false 91 | }, 92 | "is_profile_public": { 93 | "name": "is_profile_public", 94 | "type": "boolean", 95 | "primaryKey": false, 96 | "notNull": false, 97 | "default": false 98 | }, 99 | "display_codetime_publicly": { 100 | "name": "display_codetime_publicly", 101 | "type": "boolean", 102 | "primaryKey": false, 103 | "notNull": false, 104 | "default": false 105 | } 106 | }, 107 | "indexes": {}, 108 | "foreignKeys": {}, 109 | "compositePrimaryKeys": {}, 110 | "uniqueConstraints": { 111 | "users_username_unique": { 112 | "name": "users_username_unique", 113 | "nullsNotDistinct": false, 114 | "columns": [ 115 | "username" 116 | ] 117 | }, 118 | "users_email_unique": { 119 | "name": "users_email_unique", 120 | "nullsNotDistinct": false, 121 | "columns": [ 122 | "email" 123 | ] 124 | } 125 | } 126 | } 127 | }, 128 | "enums": {}, 129 | "schemas": {}, 130 | "_meta": { 131 | "columns": {}, 132 | "schemas": {}, 133 | "tables": {} 134 | } 135 | } -------------------------------------------------------------------------------- /api/src/drizzle/meta/0000_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "2c7d8f5b-d001-4673-960c-20503a2acc7a", 3 | "prevId": "00000000-0000-0000-0000-000000000000", 4 | "version": "6", 5 | "dialect": "postgresql", 6 | "tables": { 7 | "public.users": { 8 | "name": "users", 9 | "schema": "", 10 | "columns": { 11 | "id": { 12 | "name": "id", 13 | "type": "text", 14 | "primaryKey": true, 15 | "notNull": true, 16 | "default": "'4e7fa268-8bd4-4929-8c53-64a93de519bd'" 17 | }, 18 | "username": { 19 | "name": "username", 20 | "type": "text", 21 | "primaryKey": false, 22 | "notNull": false 23 | }, 24 | "password": { 25 | "name": "password", 26 | "type": "text", 27 | "primaryKey": false, 28 | "notNull": false 29 | }, 30 | "avatar": { 31 | "name": "avatar", 32 | "type": "text", 33 | "primaryKey": false, 34 | "notNull": false 35 | }, 36 | "github": { 37 | "name": "github", 38 | "type": "text", 39 | "primaryKey": false, 40 | "notNull": false 41 | }, 42 | "twitter": { 43 | "name": "twitter", 44 | "type": "text", 45 | "primaryKey": false, 46 | "notNull": false 47 | }, 48 | "website": { 49 | "name": "website", 50 | "type": "text", 51 | "primaryKey": false, 52 | "notNull": false 53 | }, 54 | "email": { 55 | "name": "email", 56 | "type": "text", 57 | "primaryKey": false, 58 | "notNull": true 59 | }, 60 | "created_at": { 61 | "name": "created_at", 62 | "type": "timestamp", 63 | "primaryKey": false, 64 | "notNull": false, 65 | "default": "now()" 66 | }, 67 | "updated_at": { 68 | "name": "updated_at", 69 | "type": "timestamp", 70 | "primaryKey": false, 71 | "notNull": false, 72 | "default": "now()" 73 | }, 74 | "otp": { 75 | "name": "otp", 76 | "type": "text", 77 | "primaryKey": false, 78 | "notNull": false 79 | }, 80 | "otp_expires_at": { 81 | "name": "otp_expires_at", 82 | "type": "timestamp", 83 | "primaryKey": false, 84 | "notNull": false 85 | }, 86 | "display_name": { 87 | "name": "display_name", 88 | "type": "text", 89 | "primaryKey": false, 90 | "notNull": false 91 | }, 92 | "is_profile_public": { 93 | "name": "is_profile_public", 94 | "type": "boolean", 95 | "primaryKey": false, 96 | "notNull": false, 97 | "default": false 98 | }, 99 | "display_codetime_publicly": { 100 | "name": "display_codetime_publicly", 101 | "type": "boolean", 102 | "primaryKey": false, 103 | "notNull": false, 104 | "default": false 105 | } 106 | }, 107 | "indexes": {}, 108 | "foreignKeys": {}, 109 | "compositePrimaryKeys": {}, 110 | "uniqueConstraints": { 111 | "users_id_unique": { 112 | "name": "users_id_unique", 113 | "nullsNotDistinct": false, 114 | "columns": [ 115 | "id" 116 | ] 117 | }, 118 | "users_username_unique": { 119 | "name": "users_username_unique", 120 | "nullsNotDistinct": false, 121 | "columns": [ 122 | "username" 123 | ] 124 | }, 125 | "users_email_unique": { 126 | "name": "users_email_unique", 127 | "nullsNotDistinct": false, 128 | "columns": [ 129 | "email" 130 | ] 131 | } 132 | } 133 | } 134 | }, 135 | "enums": {}, 136 | "schemas": {}, 137 | "_meta": { 138 | "columns": {}, 139 | "schemas": {}, 140 | "tables": {} 141 | } 142 | } -------------------------------------------------------------------------------- /web/pages/login.tsx: -------------------------------------------------------------------------------- 1 | import { axios } from "@/utils/axios"; 2 | import Link from "next/link"; 3 | import { useRouter } from "next/router"; 4 | import React, { InputHTMLAttributes, useState } from "react"; 5 | import { CgSpinner } from "react-icons/cg"; 6 | import { Field, FieldProps, Form, Formik } from "formik"; 7 | import * as Yup from "yup"; 8 | import { IoWarning } from "react-icons/io5"; 9 | import { useLogSnag } from "@logsnag/next"; 10 | 11 | interface InputProps extends InputHTMLAttributes {} 12 | 13 | const Input: React.FC = ({ 14 | field, 15 | form, 16 | ...props 17 | }) => { 18 | return ( 19 | 25 | ); 26 | }; 27 | 28 | const Error = ({ 29 | show, 30 | children, 31 | }: { 32 | show: boolean; 33 | children: React.ReactNode; 34 | }) => { 35 | return ( 36 |
39 | {children} 40 |
41 | ); 42 | }; 43 | 44 | const Login = () => { 45 | const router = useRouter(); 46 | 47 | const [globalError, setGlobalError] = useState(""); 48 | 49 | const validationSchema = Yup.object({ 50 | email: Yup.string() 51 | .email("Invalid email address") 52 | .required("Email is required"), 53 | password: Yup.string().required("Password is required"), 54 | }); 55 | const { setUserId } = useLogSnag(); 56 | return ( 57 |
58 |
59 | Sooner logo 60 |

Sign in to Sooner

61 |

62 | {`Don't have an account?`}{" "} 63 | 64 | Get started 65 | 66 |

67 |
68 | 69 | { 76 | try { 77 | setGlobalError(""); 78 | const { data } = await axios.post("/auth/login", values); 79 | setUserId(data.id); 80 | router.push(!data.activated ? "/onboarding" : "/dashboard"); 81 | } catch (error: any) { 82 | setGlobalError( 83 | error?.response?.data.message || 84 | "Something went wrong. Please try again." 85 | ); 86 | } finally { 87 | setSubmitting(false); 88 | } 89 | }} 90 | > 91 | {({ isSubmitting, errors, touched }) => ( 92 |
93 |
94 | 100 | 101 | {errors.email} 102 | 103 |
104 | 105 |
106 | 112 | 113 | {errors.password} 114 | 115 |
116 | 117 | 128 | 129 |
130 | {globalError} 131 |
132 |
133 |
134 | )} 135 |
136 |
137 | ); 138 | }; 139 | 140 | export default Login; 141 | -------------------------------------------------------------------------------- /extensions/vscode/src/extension.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable curly */ 2 | import * as vscode from "vscode"; 3 | import { fetchCodingTimeToday, sendPulseData, validateApiKey } from "./api"; 4 | import { initializeStatusBar, updateStatusBarText } from "./status_bar"; 5 | 6 | let codingStartTime: number | null = null; 7 | let totalCodingTime: number = 0; 8 | const activityTimeouts: Map< 9 | string, 10 | { timeout: NodeJS.Timeout; path: string; language: string } 11 | > = new Map(); 12 | 13 | let apiKey: string | undefined; 14 | 15 | const debounceTime = 120 * 1000; 16 | 17 | const startTracking = () => { 18 | if (!apiKey) { 19 | return; 20 | } 21 | if (!codingStartTime) codingStartTime = Date.now(); 22 | }; 23 | 24 | const stopTracking = () => { 25 | if (codingStartTime) { 26 | const codingEndTime = Date.now(); 27 | const codingDuration = codingEndTime - codingStartTime; 28 | totalCodingTime += codingDuration; 29 | codingStartTime = null; 30 | } 31 | }; 32 | 33 | export async function activate(context: vscode.ExtensionContext) { 34 | const configuration = vscode.workspace.getConfiguration(); 35 | apiKey = configuration.get("sooner.apiKey"); 36 | 37 | initializeStatusBar(context); 38 | 39 | if (apiKey) { 40 | const codingTimeToday = await fetchCodingTimeToday(apiKey); 41 | if (codingTimeToday) { 42 | totalCodingTime = codingTimeToday.time; 43 | updateStatusBarText(totalCodingTime); 44 | } 45 | } 46 | 47 | const onDidChangeTextDocument = vscode.workspace.onDidChangeTextDocument( 48 | async (event) => { 49 | const documentUri = event.document.uri.toString(); 50 | const filePath = event.document.uri.fsPath; 51 | const language = event.document.languageId; 52 | 53 | startTracking(); 54 | 55 | if (!apiKey) return; 56 | 57 | if (activityTimeouts.has(documentUri)) { 58 | clearTimeout(activityTimeouts.get(documentUri)!.timeout); 59 | } 60 | 61 | activityTimeouts.set(documentUri, { 62 | timeout: setTimeout(async () => { 63 | await sendPulseData({ 64 | apiKey: apiKey!, 65 | codingStartTime: codingStartTime!, 66 | filePath: filePath, 67 | language: language, 68 | }); 69 | stopTracking(); 70 | activityTimeouts.delete(documentUri); 71 | }, debounceTime), 72 | path: filePath, 73 | language: language, 74 | }); 75 | } 76 | ); 77 | 78 | const statusBarClick = vscode.commands.registerCommand( 79 | "sooner.clickStatusBar", 80 | async () => { 81 | const configuration = vscode.workspace.getConfiguration(); 82 | if (apiKey) { 83 | vscode.env.openExternal( 84 | vscode.Uri.parse(`https://www.sooner.run/dashboard`) 85 | ); 86 | } else { 87 | const key = await vscode.window.showInputBox({ 88 | prompt: "Enter your API key", 89 | }); 90 | if (key) { 91 | await vscode.window.withProgress( 92 | { 93 | location: vscode.ProgressLocation.Notification, 94 | title: "Activating Sooner", 95 | cancellable: false, 96 | }, 97 | async (progress) => { 98 | progress.report({ message: "Please wait..." }); 99 | // eslint-disable-next-line @typescript-eslint/naming-convention 100 | const { isValid, codetime_today } = await validateApiKey(key); 101 | if (isValid) { 102 | apiKey = key; 103 | await configuration.update( 104 | "sooner.apiKey", 105 | key, 106 | vscode.ConfigurationTarget.Global 107 | ); 108 | updateStatusBarText(codetime_today); 109 | vscode.window.showInformationMessage( 110 | "Extension activated successfully." 111 | ); 112 | } else { 113 | vscode.window.showErrorMessage( 114 | "Invalid API key. Please try again." 115 | ); 116 | } 117 | } 118 | ); 119 | } 120 | } 121 | } 122 | ); 123 | 124 | const clearApiKeyCommand = vscode.commands.registerCommand( 125 | "sooner.clearApiKey", 126 | async () => { 127 | const configuration = vscode.workspace.getConfiguration(); 128 | await configuration.update( 129 | "sooner.apiKey", 130 | undefined, 131 | vscode.ConfigurationTarget.Global 132 | ); 133 | apiKey = undefined; 134 | vscode.window.showInformationMessage("API key deleted successfully."); 135 | updateStatusBarText(totalCodingTime); 136 | } 137 | ); 138 | 139 | context.subscriptions.push(onDidChangeTextDocument); 140 | context.subscriptions.push(statusBarClick); 141 | context.subscriptions.push(clearApiKeyCommand); 142 | } 143 | 144 | export function deactivate() { 145 | stopTracking(); 146 | } 147 | -------------------------------------------------------------------------------- /web/pages/pricing.tsx: -------------------------------------------------------------------------------- 1 | import Footer from "@/components/landing/Footer"; 2 | import Navbar from "@/components/landing/Navbar"; 3 | import AnimatedShinyText from "@/components/magicui/animated-shiny-text"; 4 | import { BorderBeam } from "@/components/magicui/border-beam"; 5 | import React, { useState } from "react"; 6 | import { RiCopperDiamondLine } from "react-icons/ri"; 7 | import { HiMiniArrowLongRight } from "react-icons/hi2"; 8 | 9 | const Pricing = () => { 10 | const [interval, setInterval] = useState<"Monthly" | "Annually">("Annually"); 11 | 12 | const devPlans = [ 13 | { 14 | title: "Free", 15 | price: 0, 16 | benefits: [ 17 | "30 days dashboard history", 18 | "3 goals", 19 | "Monthly email reports", 20 | "Private leaderboard for 2 devs", 21 | "3 days data and stats export", 22 | "Community support", 23 | ], 24 | }, 25 | { 26 | title: "Pro", 27 | price: 12, 28 | benefits: [ 29 | "Uncapped dashboard history", 30 | "Unlimited goals", 31 | "Daily, weekly and montly email reports", 32 | "Private leaderboard for 100 devs", 33 | "Uncapped data and stats export", 34 | "Priority support + Private discord server", 35 | ], 36 | }, 37 | { 38 | title: "Basic", 39 | price: 5, 40 | benefits: [ 41 | "1 year dashboard history", 42 | "20 goals", 43 | "Weekly email reports", 44 | "Private leaderboard for 20 devs", 45 | "2 weeeks data and stats export", 46 | "Priority support", 47 | ], 48 | }, 49 | ]; 50 | 51 | return ( 52 |
53 | 54 |
55 |

Sooner Pricing

56 | 57 |
58 | 64 | 77 |
78 | 79 |
80 |

For individuals

81 |
82 | {devPlans.map((plan, i) => ( 83 |
87 | {plan.title === "Pro" && ( 88 | 94 | )} 95 |

{plan.title}

96 |

97 | $ 98 | {interval === "Monthly" ? plan.price : plan.price * 10} 99 | 100 | {" "} 101 | per {interval === "Monthly" ? "month" : "year"} 102 | 103 |

104 |
105 |
    106 | {plan.benefits.map((b, i) => ( 107 |
  • 108 | 109 | {b} 110 |
  • 111 | ))} 112 |
113 | 117 |
118 | ))} 119 |
120 |
121 |
122 |
123 |
124 | ); 125 | }; 126 | 127 | export default Pricing; 128 | -------------------------------------------------------------------------------- /api/src/controllers/projects/project.ts: -------------------------------------------------------------------------------- 1 | import { Context } from "hono"; 2 | import { db } from "../../db"; 3 | import { pulses } from "../../db/schema"; 4 | import { and, desc, eq, sum, gte, lte, sql } from "drizzle-orm"; 5 | import { time_to_human } from "../../utils/time_to_human"; 6 | import dayjs from "dayjs"; 7 | 8 | export const RetrieveSingleProject = async (c: Context) => { 9 | try { 10 | const project_name = c.req.param("project"); 11 | const start_date = 12 | new Date(c.req.query("start_date")!) || 13 | new Date(dayjs().subtract(6, "days").toISOString()); 14 | const end_date = 15 | new Date(c.req.query("end_date")!) || new Date(dayjs().toISOString()); 16 | 17 | const projectLastXDays = await db 18 | .select({ 19 | date: sql`DATE(pulses.created_at)`, 20 | total_time: sum(pulses.time), 21 | }) 22 | .from(pulses) 23 | .where( 24 | and( 25 | eq(pulses.user_id, c.get("user_id")), 26 | eq(pulses.project, project_name), 27 | gte(pulses.created_at, start_date), 28 | lte(pulses.created_at, end_date) 29 | ) 30 | ) 31 | .groupBy(sql`DATE(pulses.created_at)`) 32 | .orderBy(sql`DATE(pulses.created_at)`); 33 | 34 | const [projectTotal] = await db 35 | .select({ 36 | total_time: sum(pulses.time), 37 | }) 38 | .from(pulses) 39 | .where( 40 | and( 41 | eq(pulses.user_id, c.get("user_id")), 42 | eq(pulses.project, project_name) 43 | ) 44 | ); 45 | 46 | const [project] = await db 47 | .select({ 48 | project: pulses.project, 49 | }) 50 | .from(pulses) 51 | .where( 52 | and( 53 | eq(pulses.user_id, c.get("user_id")), 54 | eq(pulses.project, project_name) 55 | ) 56 | ) 57 | .groupBy(pulses.project); 58 | 59 | if (!project) { 60 | return c.json({ message: "Project not found" }, 404); 61 | } 62 | 63 | const path_records = await db 64 | .select({ 65 | path: pulses.path, 66 | time: sum(pulses.time), 67 | }) 68 | .from(pulses) 69 | .where( 70 | and( 71 | eq(pulses.user_id, c.get("user_id")), 72 | eq(pulses.project, project_name), 73 | gte(pulses.created_at, start_date), 74 | lte(pulses.created_at, end_date) 75 | ) 76 | ) 77 | .groupBy(pulses.path) 78 | .orderBy(desc(sum(pulses.time))); 79 | 80 | const languages = await db 81 | .select({ 82 | language: pulses.language, 83 | time: sum(pulses.time), 84 | }) 85 | .from(pulses) 86 | .where( 87 | and( 88 | eq(pulses.user_id, c.get("user_id")), 89 | eq(pulses.project, project_name), 90 | gte(pulses.created_at, start_date), 91 | lte(pulses.created_at, end_date) 92 | ) 93 | ) 94 | .orderBy(desc(sum(pulses.time))) 95 | .groupBy(pulses.language); 96 | 97 | const branches = await db 98 | .select({ branch: pulses.branch, time: sum(pulses.time) }) 99 | .from(pulses) 100 | .where( 101 | and( 102 | eq(pulses.user_id, c.get("user_id")), 103 | eq(pulses.project, project_name), 104 | gte(pulses.created_at, start_date), 105 | lte(pulses.created_at, end_date) 106 | ) 107 | ) 108 | .orderBy(desc(sum(pulses.time))) 109 | .groupBy(pulses.branch); 110 | 111 | const generateDateRange = (startDate: Date, endDate: Date) => { 112 | const dates = []; 113 | let currentDate = dayjs(startDate); 114 | const lastDate = dayjs(endDate); 115 | 116 | while ( 117 | currentDate.isBefore(lastDate) || 118 | currentDate.isSame(lastDate, "day") 119 | ) { 120 | dates.push(currentDate.format("YYYY-MM-DD")); 121 | currentDate = currentDate.add(1, "day"); 122 | } 123 | 124 | return dates; 125 | }; 126 | 127 | const completeDates = generateDateRange(start_date, end_date); 128 | const timeseries = completeDates.map((date) => { 129 | const record = projectLastXDays.find((record) => record.date === date); 130 | return { 131 | date, 132 | time: record ? Number(record.total_time) : 0, 133 | }; 134 | }); 135 | 136 | return c.json( 137 | { 138 | time: projectLastXDays.reduce( 139 | (acc, curr) => acc + Number(curr.total_time), 140 | 0 141 | ), 142 | time_human_readable: time_to_human( 143 | projectLastXDays.reduce( 144 | (acc, curr) => acc + Number(curr.total_time), 145 | 0 146 | ) 147 | ), 148 | top_language: languages[0]?.language || "N/A", 149 | all_time: time_to_human(Number(projectTotal.total_time)), 150 | files: path_records.map((record) => ({ 151 | file: record.path?.split("/")[record.path.split("/").length - 1], 152 | path: record.path, 153 | time: Number(record.time), 154 | time_human_readable: time_to_human(Number(record.time)), 155 | })), 156 | languages: languages.map((l) => ({ 157 | language: l.language, 158 | time: Number(l.time), 159 | time_human_readable: time_to_human(Number(l.time)), 160 | })), 161 | branches: branches.map((b) => ({ 162 | branch: b.branch, 163 | time: Number(b.time), 164 | time_human_readable: time_to_human(Number(b.time)), 165 | })), 166 | timeseries, 167 | }, 168 | 200 169 | ); 170 | } catch (error) { 171 | console.log(error); 172 | return c.json({ message: "Something went wrong." }, 500); 173 | } 174 | }; 175 | -------------------------------------------------------------------------------- /web/pages/signup.tsx: -------------------------------------------------------------------------------- 1 | import { axios } from "@/utils/axios"; 2 | import Link from "next/link"; 3 | import { useRouter } from "next/router"; 4 | import React, { InputHTMLAttributes, useState } from "react"; 5 | import { CgSpinner } from "react-icons/cg"; 6 | import { Field, FieldProps, Form, Formik } from "formik"; 7 | import * as Yup from "yup"; 8 | import { IoWarning } from "react-icons/io5"; 9 | import { AiOutlineEye, AiOutlineEyeInvisible } from "react-icons/ai"; 10 | 11 | interface InputProps extends InputHTMLAttributes {} 12 | 13 | const Input: React.FC = ({ 14 | field, 15 | form, 16 | ...props 17 | }) => { 18 | return ( 19 | 25 | ); 26 | }; 27 | 28 | const Error = ({ 29 | show, 30 | children, 31 | }: { 32 | show: boolean; 33 | children: React.ReactNode; 34 | }) => { 35 | return ( 36 |
39 | {children} 40 |
41 | ); 42 | }; 43 | 44 | const Signup = () => { 45 | const router = useRouter(); 46 | 47 | const [globalError, setGlobalError] = useState(""); 48 | const [showPassword, setShowPassword] = useState(false); 49 | 50 | const validationSchema = Yup.object({ 51 | email: Yup.string() 52 | .email("Invalid email address") 53 | .required("Email is required"), 54 | username: Yup.string() 55 | .matches(/^[a-zA-Z0-9]*$/, "Username can't contain special characters") 56 | .notOneOf( 57 | [...Array(10).keys()].map(String), 58 | "Username cannot contain only numbers" 59 | ) 60 | .min(4, "Username must be at least 4 characters long") 61 | .max(16, "Username cannot exceed 16 characters") 62 | .required("Username is required"), 63 | password: Yup.string() 64 | .min(6, "Password must be at least 6 characters long") 65 | .max(50, "Password is too long (max 50 characters)") 66 | .required("Password is required"), 67 | }); 68 | 69 | return ( 70 |
71 |
72 | Sooner logo 73 |

74 | Create an account with Sooner 75 |

76 |

77 | {`Already have an account?`}{" "} 78 | 79 | Sign in 80 | 81 |

82 |
83 | 84 | { 92 | try { 93 | setGlobalError(""); 94 | await axios.post("/auth/signup", values); 95 | router.push("/verify"); 96 | } catch (error: any) { 97 | setGlobalError( 98 | error?.response?.data.message || 99 | "Something went wrong. Please try again." 100 | ); 101 | } finally { 102 | setSubmitting(false); 103 | } 104 | }} 105 | > 106 | {({ isSubmitting, errors, touched }) => ( 107 |
108 |
109 | 115 | 116 | {errors.email} 117 | 118 |
119 | 120 |
121 | 127 | 128 | {errors.username} 129 | 130 |
131 | 132 |
133 | 139 | 150 | 151 | {errors.password} 152 | 153 |
154 | 155 | 166 | 167 |
168 | {globalError} 169 |
170 |
171 |
172 | )} 173 |
174 |
175 | ); 176 | }; 177 | 178 | export default Signup; 179 | -------------------------------------------------------------------------------- /web/pages/onboarding.tsx: -------------------------------------------------------------------------------- 1 | import DashboardLayout from "@/components/layout/DashboardLayout"; 2 | import Card from "@/components/ui/Card"; 3 | import { copyToClipboard } from "@/utils/copyToClipboard"; 4 | import { fetcher } from "@/utils/fetcher"; 5 | import { useLogSnag } from "@logsnag/next"; 6 | import Link from "next/link"; 7 | import React, { useState, useEffect } from "react"; 8 | import { CgSpinner } from "react-icons/cg"; 9 | import { HiArrowLongRight, HiKey, HiMiniArrowLongRight } from "react-icons/hi2"; 10 | import { LuCopy, LuCopyCheck } from "react-icons/lu"; 11 | import { MdVerifiedUser } from "react-icons/md"; 12 | import { VscVscode } from "react-icons/vsc"; 13 | import { Tooltip } from "react-tooltip"; 14 | import useSWR from "swr"; 15 | 16 | const Onboarding = () => { 17 | const { data, isLoading } = useSWR("/app/api-key", fetcher); 18 | const [copied, setCopied] = useState(false); 19 | const [show, setShow] = useState(false); 20 | 21 | const MAX_COUNT = 20; 22 | 23 | const [activationStatus, setActivationStatus] = useState( 24 | "Waiting for activation..." 25 | ); 26 | const [activated, setActivated] = useState(false); 27 | 28 | const [requestCount, setRequestCount] = useState(0); 29 | 30 | const handleCopy = () => { 31 | setCopied(true); 32 | copyToClipboard(data?.key); 33 | setTimeout(() => { 34 | setCopied(false); 35 | }, 2000); 36 | }; 37 | 38 | const checkActivationStatus = () => { 39 | fetcher("/app/extension") 40 | .then((response) => { 41 | if (response.activated) { 42 | setActivated(true); 43 | setActivationStatus("Extension activated!"); 44 | } else { 45 | setActivated(false); 46 | setRequestCount((prevCount) => prevCount + 1); 47 | } 48 | }) 49 | .catch((error) => { 50 | console.error("Error checking activation status:", error); 51 | }); 52 | }; 53 | 54 | useEffect(() => { 55 | if (activated || requestCount >= MAX_COUNT) return; 56 | 57 | const interval = setInterval(() => { 58 | checkActivationStatus(); 59 | }, 5000); 60 | 61 | return () => clearInterval(interval); 62 | }, [activated, requestCount]); 63 | 64 | useEffect(() => { 65 | if (requestCount >= MAX_COUNT) { 66 | setActivationStatus("Extension is not activated"); 67 | } 68 | }, [requestCount]); 69 | 70 | const handleVerifyAgain = () => { 71 | setRequestCount(0); 72 | setActivationStatus("Waiting for activation..."); 73 | setActivated(false); 74 | }; 75 | 76 | const { track } = useLogSnag(); 77 | 78 | return ( 79 | 84 | 85 | 86 |
87 | 88 |

89 | 90 | Copy your API key 91 |

92 |

93 | You need your API key to sync the Sooner VS Code extension with your 94 | dashboard. 95 |

96 |
97 |

setShow(!show)} 100 | > 101 | {data?.key} 102 |

103 |
104 | 115 |
116 |
117 |
118 | 119 |

120 | 121 | Install and activate Sooner 122 |

123 |

124 | The Sooner extension helps you track your codetime automatically. 125 |

126 | 127 |
    128 |
  • 129 | Search Sooner in the VS Code 130 | extensions tab or{" "} 131 | 136 | click here 137 | {" "} 138 | to install. 139 |
  • 140 |
  • 141 | When the installation is completed click{" "} 142 | Activate Sooner from the status 143 | bar and enter the API key above. 144 |
  • 145 |
146 |
147 | 148 |

149 | 150 | Verify installation 151 |

152 |

153 | Installation will automatically be verified when you activate the 154 | extension. 155 |

156 |

= MAX_COUNT ? "text-red-500" : ""}`} 158 | > 159 | {activationStatus}{" "} 160 | {activationStatus === "Waiting for activation..." && ( 161 | 162 | )}{" "} 163 |

164 | {requestCount >= MAX_COUNT && ( 165 | 171 | )} 172 | {activationStatus === "Extension activated!" && ( 173 | { 176 | track({ 177 | channel: "users", 178 | event: "Click 'Continue to dashboard' button", 179 | icon: "🫰🏼", 180 | notify: true, 181 | }); 182 | }} 183 | > 184 | 187 | 188 | )} 189 |
190 |
191 |
192 | ); 193 | }; 194 | 195 | export default Onboarding; 196 | -------------------------------------------------------------------------------- /web/pages/projects/[project].tsx: -------------------------------------------------------------------------------- 1 | import { faker } from "@faker-js/faker"; 2 | import { ArrowDown01Icon, Clock01Icon } from "hugeicons-react"; 3 | import React, { useEffect, useMemo, useState } from "react"; 4 | import { GoDotFill } from "react-icons/go"; 5 | import DonutChart from "@/components/DonutChart"; 6 | import LineChart from "@/components/LineChart"; 7 | import ProjectsLayout from "@/components/layout/ProjectsLayout"; 8 | import Card from "@/components/ui/Card"; 9 | import { getColorForLanguage } from "@/utils/getColorForLanguage"; 10 | import { time_to_human } from "@/utils/time_to_human"; 11 | import DotPattern from "@/components/magicui/dot-pattern"; 12 | import { useRouter } from "next/router"; 13 | import useSWR from "swr"; 14 | import { fetcher } from "@/utils/fetcher"; 15 | import { ProjectData } from "@/types"; 16 | import dayjs from "dayjs"; 17 | import { truncate } from "@/utils/truncate"; 18 | import Warning from "@/components/Warning"; 19 | 20 | const Project = () => { 21 | const router = useRouter(); 22 | const [url, setUrl] = useState(""); 23 | 24 | useEffect(() => { 25 | if (router.asPath) { 26 | const projectId = router.asPath.split("/").pop(); 27 | if (projectId !== "[project]") { 28 | const startDate = dayjs().subtract(6, "days").toISOString(); 29 | const endDate = dayjs().toISOString(); 30 | setUrl( 31 | `/v1/projects/${projectId}?start_date=${startDate}&end_date=${endDate}` 32 | ); 33 | } 34 | } 35 | }, [router.asPath]); 36 | 37 | const { data, error, isLoading } = useSWR(url, fetcher); 38 | 39 | return ( 40 | 41 |
42 | 43 |
44 |
45 |
46 |

Last 7 days

47 | 51 |
52 |

53 | {data?.time_human_readable} 54 |

55 |
56 |
57 |
58 |
59 |
60 |

Branch

61 | 65 |
66 |

67 | all 68 |

69 |
70 |
71 |
72 |
73 |
74 |

Top language

75 |
76 |
77 | 81 |

{data?.top_language}

82 |
83 |
84 |
85 |
86 |
87 |
88 |

All time

89 |
90 |

91 | {data?.all_time} 92 |

93 |
94 |
95 |
96 | 97 | 98 | t.date)!} 100 | data={data?.timeseries.map((t) => t.time)!} 101 | /> 102 | 103 |
104 | 105 |

Top languages

106 |
107 | l.language)!} 109 | data={data?.languages.map((l) => l.time)!} 110 | /> 111 |
112 |
113 |
114 | 115 | 116 |

117 | Lorem ipsum dolor sit amet consectetur adipisicing elit. Dolor 118 | placeat expedita nesciunt molestiae doloribus, vitae accusamus 119 | tenetur ad natus eos pariatur saepe nihil porro, inventore at 120 | ipsa consequuntur voluptate enim id! Distinctio molestias 121 | blanditiis aliquid libero reprehenderit fugit quis alias 122 | explicabo, aperiam animi eveniet quo! Temporibus, expedita 123 | praesentium! Minus tempore nisi soluta ab eum amet inventore, 124 | quam expedita placeat iure, labore laborum impedit eveniet nam 125 | molestias quae sit provident, aperiam quis molestiae similique 126 | aut delectus est? Aperiam cumque deleniti accusantium? Dolor 127 | exercitationem ex ab esse cumque atque. Voluptates nulla id 128 | laudantium, eaque, libero, earum aliquid blanditiis atque 129 | aspernatur maiores dolore. 130 |

131 |
132 |
133 |
134 |
135 | 136 |

Files

137 | {data?.files.map((f, i) => ( 138 |
139 |

{truncate(f.file, 30)}

140 |

141 | {time_to_human(f.time)} 142 | 143 |

144 |
145 | ))} 146 |
147 | 148 |

Branches

149 | {data?.branches.map((b, i) => ( 150 |
151 |

{b.branch}

152 |

153 | {time_to_human(b.time)} 154 | 155 |

156 |
157 | ))} 158 |
159 |
160 |
161 |
162 | ); 163 | }; 164 | 165 | export default Project; 166 | --------------------------------------------------------------------------------