├── .npmrc ├── TODO.md ├── apps └── offline-nextjs │ ├── src │ ├── components │ │ ├── calendar │ │ │ ├── weekly │ │ │ │ ├── sortable-list.tsx │ │ │ │ └── view.tsx │ │ │ ├── monthly │ │ │ │ ├── charts.tsx │ │ │ │ ├── streaks.tsx │ │ │ │ ├── view.tsx │ │ │ │ └── overview.tsx │ │ │ └── day.tsx │ │ ├── ui │ │ │ ├── label.tsx │ │ │ ├── input.tsx │ │ │ ├── switch.tsx │ │ │ ├── tooltip.tsx │ │ │ ├── popover.tsx │ │ │ ├── toggle.tsx │ │ │ ├── radio-group.tsx │ │ │ ├── toggle-group.tsx │ │ │ ├── button.tsx │ │ │ ├── card.tsx │ │ │ ├── drawer.tsx │ │ │ ├── form.tsx │ │ │ ├── dropdown-menu.tsx │ │ │ ├── chart.tsx │ │ │ └── dropdrawer.tsx │ │ ├── color-picker.tsx │ │ ├── misc.tsx │ │ └── habit-form.tsx │ ├── app │ │ ├── favicon.ico │ │ ├── archive │ │ │ └── page.tsx │ │ ├── page.tsx │ │ ├── add │ │ │ └── page.tsx │ │ ├── edit │ │ │ └── [id] │ │ │ │ └── page.tsx │ │ ├── habit │ │ │ └── [id] │ │ │ │ └── page.tsx │ │ ├── settings │ │ │ └── page.tsx │ │ ├── layout.tsx │ │ └── globals.css │ ├── fonts │ │ ├── GeistVF.woff │ │ ├── Pacifico.woff2 │ │ ├── GeistMonoVF.woff │ │ ├── Mathlete-Bulky.otf │ │ ├── Neucha-Regular.ttf │ │ ├── Excalifont-Regular.woff2 │ │ └── fonts.ts │ ├── types.ts │ ├── constants.ts │ ├── hooks │ │ ├── use-mobile.tsx │ │ └── use-media-query.ts │ ├── lib │ │ ├── streak-ranges │ │ │ ├── index.test.ts │ │ │ └── index.ts │ │ ├── completion-rate │ │ │ ├── index.test.ts │ │ │ └── index.ts │ │ ├── day.ts │ │ ├── utils.ts │ │ └── streaks │ │ │ ├── index.ts │ │ │ └── index.test.ts │ ├── state │ │ └── settings.ts │ ├── providers │ │ ├── habit-provider.tsx │ │ ├── post-hog.tsx │ │ ├── monthly-navigation.tsx │ │ └── weekly-navigation.tsx │ └── state.ts │ ├── .eslintrc.json │ ├── public │ ├── vercel.svg │ ├── window.svg │ ├── file.svg │ ├── globe.svg │ └── next.svg │ ├── postcss.config.mjs │ ├── README.md │ ├── vitest.config.ts │ ├── components.json │ ├── .gitignore │ ├── tsconfig.json │ ├── next.config.ts │ ├── CONTRIBUTING.md │ ├── tailwind.config.ts │ └── package.json ├── pnpm-workspace.yaml ├── redoit.gif ├── .vscode └── settings.json ├── packages ├── typescript-config │ ├── react-library.json │ ├── package.json │ ├── nextjs.json │ └── base.json └── ui │ ├── tsconfig.json │ ├── tsconfig.lint.json │ ├── src │ ├── code.tsx │ ├── button.tsx │ └── card.tsx │ ├── turbo │ └── generators │ │ ├── templates │ │ └── component.hbs │ │ └── config.ts │ └── package.json ├── README.md ├── turbo.json ├── package.json ├── .gitignore └── biome.json /.npmrc: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/offline-nextjs/src/components/calendar/weekly/sortable-list.tsx: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - "apps/*" 3 | - "packages/*" 4 | -------------------------------------------------------------------------------- /redoit.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lmssiehdev/redoit/HEAD/redoit.gif -------------------------------------------------------------------------------- /apps/offline-nextjs/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["next/core-web-vitals", "next/typescript"] 3 | } 4 | -------------------------------------------------------------------------------- /apps/offline-nextjs/src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lmssiehdev/redoit/HEAD/apps/offline-nextjs/src/app/favicon.ico -------------------------------------------------------------------------------- /apps/offline-nextjs/src/fonts/GeistVF.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lmssiehdev/redoit/HEAD/apps/offline-nextjs/src/fonts/GeistVF.woff -------------------------------------------------------------------------------- /apps/offline-nextjs/src/fonts/Pacifico.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lmssiehdev/redoit/HEAD/apps/offline-nextjs/src/fonts/Pacifico.woff2 -------------------------------------------------------------------------------- /apps/offline-nextjs/src/fonts/GeistMonoVF.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lmssiehdev/redoit/HEAD/apps/offline-nextjs/src/fonts/GeistMonoVF.woff -------------------------------------------------------------------------------- /apps/offline-nextjs/src/fonts/Mathlete-Bulky.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lmssiehdev/redoit/HEAD/apps/offline-nextjs/src/fonts/Mathlete-Bulky.otf -------------------------------------------------------------------------------- /apps/offline-nextjs/src/fonts/Neucha-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lmssiehdev/redoit/HEAD/apps/offline-nextjs/src/fonts/Neucha-Regular.ttf -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.workingDirectories": [ 3 | { 4 | "mode": "auto" 5 | } 6 | ], 7 | "editor.selectionClipboard": false 8 | } 9 | -------------------------------------------------------------------------------- /apps/offline-nextjs/src/fonts/Excalifont-Regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lmssiehdev/redoit/HEAD/apps/offline-nextjs/src/fonts/Excalifont-Regular.woff2 -------------------------------------------------------------------------------- /apps/offline-nextjs/public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/offline-nextjs/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 | -------------------------------------------------------------------------------- /packages/typescript-config/react-library.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "extends": "./base.json", 4 | "compilerOptions": { 5 | "jsx": "react-jsx" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # redoit! a cute and minimal habit tracker 2 | 3 | ![preview](https://raw.githubusercontent.com/lmssiehdev/redoit/refs/heads/main/redoit.gif) 4 | 5 | try it at : [https:redoit.app](https:redoit.app) 6 | -------------------------------------------------------------------------------- /packages/ui/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@repo/typescript-config/react-library.json", 3 | "compilerOptions": { 4 | "outDir": "dist" 5 | }, 6 | "include": ["src"], 7 | "exclude": ["node_modules", "dist"] 8 | } 9 | -------------------------------------------------------------------------------- /packages/typescript-config/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@repo/typescript-config", 3 | "version": "0.0.0", 4 | "private": true, 5 | "license": "MIT", 6 | "publishConfig": { 7 | "access": "public" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /apps/offline-nextjs/README.md: -------------------------------------------------------------------------------- 1 | # redoit! a cute and minimal habit tracker 2 | 3 | ![preview](https://raw.githubusercontent.com/lmssiehdev/redoit/refs/heads/main/redoit.gif) 4 | 5 | try it at : [https:redoit.app](https:redoit.app) 6 | -------------------------------------------------------------------------------- /packages/ui/tsconfig.lint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@repo/typescript-config/react-library.json", 3 | "compilerOptions": { 4 | "outDir": "dist" 5 | }, 6 | "include": ["src", "turbo"], 7 | "exclude": ["node_modules", "dist"] 8 | } 9 | -------------------------------------------------------------------------------- /packages/ui/src/code.tsx: -------------------------------------------------------------------------------- 1 | export function Code({ 2 | children, 3 | className, 4 | }: { 5 | children: React.ReactNode; 6 | className?: string; 7 | }): JSX.Element { 8 | return {children}; 9 | } 10 | -------------------------------------------------------------------------------- /packages/ui/turbo/generators/templates/component.hbs: -------------------------------------------------------------------------------- 1 | export const {{ pascalCase name }} = ({ children }: { children: React.ReactNode }) => { 2 | return ( 3 |
4 |

{{ pascalCase name }} Component

5 | {children} 6 |
7 | ); 8 | }; 9 | -------------------------------------------------------------------------------- /apps/offline-nextjs/src/app/archive/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { VerticalView } from "@/components/calendar/weekly/view"; 4 | 5 | export default function Page() { 6 | return ( 7 |
8 | 9 |
10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /apps/offline-nextjs/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import path from "node:path"; 2 | import { defineConfig } from "vitest/config"; 3 | 4 | export default defineConfig({ 5 | test: { 6 | globals: true, 7 | }, 8 | resolve: { 9 | alias: { 10 | "@": path.resolve(__dirname, "./src"), 11 | }, 12 | }, 13 | }); 14 | -------------------------------------------------------------------------------- /apps/offline-nextjs/src/types.ts: -------------------------------------------------------------------------------- 1 | export enum Status { 2 | Completed = 0, 3 | Skipped = 1, 4 | } 5 | export type HabitData = { 6 | id: string; 7 | name: string; 8 | color: string; 9 | createdAt: string; 10 | isArchived: boolean; 11 | frequency: boolean[]; 12 | dates: Record; 13 | }; 14 | -------------------------------------------------------------------------------- /packages/typescript-config/nextjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "extends": "./base.json", 4 | "compilerOptions": { 5 | "plugins": [{ "name": "next" }], 6 | "module": "ESNext", 7 | "moduleResolution": "Bundler", 8 | "allowJs": true, 9 | "jsx": "preserve", 10 | "noEmit": true 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turbo.build/schema.json", 3 | "ui": "tui", 4 | "tasks": { 5 | "build": { 6 | "dependsOn": ["^build"], 7 | "inputs": ["$TURBO_DEFAULT$", ".env*"], 8 | "outputs": [".next/**", "!.next/cache/**"] 9 | }, 10 | "lint": { 11 | "dependsOn": ["^lint"] 12 | }, 13 | "dev": { 14 | "cache": false, 15 | "persistent": true 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /apps/offline-nextjs/public/window.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/offline-nextjs/public/file.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/offline-nextjs/src/app/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { VerticalView } from "@/components/calendar/weekly/view"; 4 | 5 | export default function Home() { 6 | if (typeof window === "undefined") { 7 | return ( 8 |
9 |
{null}
10 |
11 | ); 12 | } 13 | return ( 14 |
15 | 16 |
17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /packages/ui/src/button.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import type { ReactNode } from "react"; 4 | 5 | interface ButtonProps { 6 | children: ReactNode; 7 | className?: string; 8 | appName: string; 9 | } 10 | 11 | export const Button = ({ children, className, appName }: ButtonProps) => { 12 | return ( 13 | 20 | ); 21 | }; 22 | -------------------------------------------------------------------------------- /apps/offline-nextjs/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "src/app/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /packages/ui/src/card.tsx: -------------------------------------------------------------------------------- 1 | export function Card({ 2 | className, 3 | title, 4 | children, 5 | href, 6 | }: { 7 | className?: string; 8 | title: string; 9 | children: React.ReactNode; 10 | href: string; 11 | }): JSX.Element { 12 | return ( 13 | 19 |

20 | {title} -> 21 |

22 |

{children}

23 |
24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redoit-monorepo", 3 | "private": true, 4 | "scripts": { 5 | "build": "turbo build", 6 | "dev": "turbo dev", 7 | "lint": "biome check --write", 8 | "clean-packages": "pnpm -r exec rm -rf node_modules" 9 | }, 10 | "devDependencies": { 11 | "@biomejs/biome": "1.9.3", 12 | "turbo": "^2.1.3", 13 | "typescript": "^5.4.5" 14 | }, 15 | "packageManager": "pnpm@8.15.6", 16 | "engines": { 17 | "node": ">=18" 18 | }, 19 | "pnpm": { 20 | "overrides": { 21 | "react-is": "^19.0.0-beta-26f2496093-20240514" 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # Dependencies 4 | node_modules 5 | .pnp 6 | .pnp.js 7 | 8 | # Local env files 9 | .env 10 | .env.local 11 | .env.development.local 12 | .env.test.local 13 | .env.production.local 14 | 15 | # Testing 16 | coverage 17 | 18 | # Turbo 19 | .turbo 20 | 21 | # Vercel 22 | .vercel 23 | 24 | # Build Outputs 25 | .next/ 26 | out/ 27 | build 28 | dist 29 | 30 | 31 | # Debug 32 | npm-debug.log* 33 | yarn-debug.log* 34 | yarn-error.log* 35 | 36 | # Misc 37 | .DS_Store 38 | *.pem 39 | -------------------------------------------------------------------------------- /packages/typescript-config/base.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "compilerOptions": { 4 | "declaration": true, 5 | "declarationMap": true, 6 | "esModuleInterop": true, 7 | "incremental": false, 8 | "isolatedModules": true, 9 | "lib": ["es2022", "DOM", "DOM.Iterable"], 10 | "module": "NodeNext", 11 | "moduleDetection": "force", 12 | "moduleResolution": "NodeNext", 13 | "noUncheckedIndexedAccess": true, 14 | "resolveJsonModule": true, 15 | "skipLibCheck": true, 16 | "strict": true, 17 | "target": "ES2022" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /apps/offline-nextjs/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | 32 | # env files (can opt-in for commiting if needed) 33 | .env* 34 | 35 | # vercel 36 | .vercel 37 | 38 | # typescript 39 | *.tsbuildinfo 40 | next-env.d.ts 41 | -------------------------------------------------------------------------------- /apps/offline-nextjs/src/constants.ts: -------------------------------------------------------------------------------- 1 | export const colors = [ 2 | "#16a34a", 3 | "#1d4ed8", 4 | "#ea580c", 5 | "#dc2626", 6 | "#65a30d", 7 | "#059669", 8 | "#0891b2", 9 | "#db2777", 10 | "#c026d3", 11 | "#0d9488", 12 | ]; 13 | 14 | export const DEFAULT_HABIT_COLOR = colors[0]; 15 | 16 | export const DAYS = [ 17 | "Sunday", 18 | "Monday", 19 | "Tuesday", 20 | "Wednesday", 21 | "Thursday", 22 | "Friday", 23 | "Saturday", 24 | ]; 25 | export const MONTHS = [ 26 | "January", 27 | "February", 28 | "March", 29 | "April", 30 | "May", 31 | "June", 32 | "July", 33 | "August", 34 | "September", 35 | "October", 36 | "November", 37 | "December", 38 | ]; 39 | -------------------------------------------------------------------------------- /apps/offline-nextjs/src/fonts/fonts.ts: -------------------------------------------------------------------------------- 1 | import localFont from "next/font/local"; 2 | 3 | export const pacifico = localFont({ 4 | src: "../fonts/Pacifico.woff2", 5 | variable: "--font-pacifico", 6 | display: "swap", 7 | }); 8 | 9 | export const neucha = localFont({ 10 | src: "../fonts/Neucha-Regular.ttf", 11 | variable: "--font-neucha", 12 | display: "swap", 13 | }); 14 | 15 | export const mathlete = localFont({ 16 | src: "../fonts/Mathlete-Bulky.otf", 17 | variable: "--font-mathlete", 18 | display: "swap", 19 | }); 20 | 21 | export const excalifont = localFont({ 22 | src: "../fonts/Excalifont-Regular.woff2", 23 | variable: "--font-excalifont", 24 | display: "swap", 25 | }); 26 | -------------------------------------------------------------------------------- /apps/offline-nextjs/src/hooks/use-mobile.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | const MOBILE_BREAKPOINT = 768; 4 | 5 | export function useIsMobile() { 6 | const [isMobile, setIsMobile] = React.useState( 7 | undefined, 8 | ); 9 | 10 | React.useEffect(() => { 11 | const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`); 12 | const onChange = () => { 13 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); 14 | }; 15 | mql.addEventListener("change", onChange); 16 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); 17 | return () => mql.removeEventListener("change", onChange); 18 | }, []); 19 | 20 | return !!isMobile; 21 | } 22 | -------------------------------------------------------------------------------- /apps/offline-nextjs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": 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 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./src/*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /packages/ui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@repo/ui", 3 | "version": "0.0.0", 4 | "private": true, 5 | "exports": { 6 | "./button": "./src/button.tsx", 7 | "./card": "./src/card.tsx", 8 | "./code": "./src/code.tsx" 9 | }, 10 | "scripts": { 11 | "lint": "eslint . --max-warnings 0", 12 | "generate:component": "turbo gen react-component" 13 | }, 14 | "devDependencies": { 15 | "@repo/typescript-config": "workspace:*", 16 | "@turbo/gen": "^1.12.4", 17 | "@types/node": "^20.11.24", 18 | "@types/eslint": "^8.56.5", 19 | "@types/react": "^18.2.61", 20 | "@types/react-dom": "^18.2.19", 21 | "eslint": "^8.57.0", 22 | "typescript": "^5.3.3" 23 | }, 24 | "dependencies": { 25 | "react": "^18.2.0" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.9.3/schema.json", 3 | "vcs": { 4 | "enabled": false, 5 | "clientKind": "git", 6 | "useIgnoreFile": false 7 | }, 8 | "files": { 9 | "ignoreUnknown": false, 10 | "ignore": ["dist/*", "*.gen.ts", ".next"] 11 | }, 12 | "formatter": { 13 | "enabled": true, 14 | "indentStyle": "tab" 15 | }, 16 | "organizeImports": { 17 | "enabled": true 18 | }, 19 | "linter": { 20 | "enabled": true, 21 | "rules": { 22 | "recommended": true, 23 | "correctness": { 24 | "noUnusedImports": "warn" 25 | }, 26 | "complexity": { 27 | "noForEach": "off" 28 | } 29 | } 30 | }, 31 | "javascript": { 32 | "formatter": { 33 | "quoteStyle": "double" 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /apps/offline-nextjs/src/app/add/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { HabitForm } from "@/components/habit-form"; 4 | import { GoToMainPageButton } from "@/components/misc"; 5 | import { useHabitsStore } from "@/state"; 6 | import { useRouter } from "next/navigation"; 7 | 8 | export default function AddHabit() { 9 | const addHabit = useHabitsStore((state) => state.addHabit); 10 | const { back } = useRouter(); 11 | 12 | return ( 13 |
14 |
15 | 16 |

Add Habit

17 |
18 | { 20 | addHabit(payload); 21 | back(); 22 | }} 23 | /> 24 |
25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /apps/offline-nextjs/src/lib/streak-ranges/index.test.ts: -------------------------------------------------------------------------------- 1 | import streakRanges from "@/lib/streak-ranges/index"; 2 | import { describe, expect, it } from "vitest"; 3 | 4 | describe("Streak Ranges", () => { 5 | it("should report ranges of streaks", () => { 6 | const result = streakRanges([ 7 | new Date("01/01/2018"), 8 | new Date("01/02/2018"), 9 | new Date("01/04/2018"), 10 | ]); 11 | 12 | expect(result[0].start.getTime()).to.equal( 13 | new Date("01/04/2018").getTime(), 14 | ); 15 | expect(result[0].end).to.equal(null); 16 | expect(result[0].duration).to.equal(1); 17 | expect(result[1].start.getTime()).to.equal( 18 | new Date("01/01/2018").getTime(), 19 | ); 20 | expect(result[1].end?.getTime()).to.equal(new Date("01/02/2018").getTime()); 21 | expect(result[1].duration).to.equal(2); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /apps/offline-nextjs/next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from "next"; 2 | 3 | const nextConfig: NextConfig = { 4 | eslint: { 5 | ignoreDuringBuilds: true, 6 | }, 7 | async redirects() { 8 | return [ 9 | { 10 | source: "/feedback", 11 | destination: "https://tally.so/r/wAbG7B", 12 | statusCode: 302, 13 | }, 14 | ]; 15 | }, 16 | async rewrites() { 17 | return [ 18 | { 19 | source: "/ingest/static/:path*", 20 | destination: "https://us-assets.i.posthog.com/static/:path*", 21 | }, 22 | { 23 | source: "/ingest/:path*", 24 | destination: "https://us.i.posthog.com/:path*", 25 | }, 26 | { 27 | source: "/ingest/decide", 28 | destination: "https://us.i.posthog.com/decide", 29 | }, 30 | ]; 31 | }, 32 | skipTrailingSlashRedirect: true, 33 | }; 34 | 35 | export default nextConfig; 36 | -------------------------------------------------------------------------------- /apps/offline-nextjs/src/components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | import * as LabelPrimitive from "@radix-ui/react-label"; 2 | import { type VariantProps, cva } from "class-variance-authority"; 3 | import * as React from "react"; 4 | 5 | import { cn } from "@/lib/utils"; 6 | 7 | const labelVariants = cva( 8 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70", 9 | ); 10 | 11 | const Label = React.forwardRef< 12 | React.ElementRef, 13 | React.ComponentPropsWithoutRef & 14 | VariantProps 15 | >(({ className, ...props }, ref) => ( 16 | 21 | )); 22 | Label.displayName = LabelPrimitive.Root.displayName; 23 | 24 | export { Label }; 25 | -------------------------------------------------------------------------------- /apps/offline-nextjs/src/state/settings.ts: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | import { persist } from "zustand/middleware"; 3 | import { immer } from "zustand/middleware/immer"; 4 | 5 | type State = { 6 | confettiEnabled: boolean; 7 | countSkippedDaysInStreak: boolean; 8 | }; 9 | 10 | type Actions = { 11 | toggleConfetti: (value: boolean) => void; 12 | setCountSkippedDaysInStreak: (value: boolean) => void; 13 | }; 14 | 15 | export const useSettingsStore = create()( 16 | immer( 17 | persist( 18 | (set) => ({ 19 | confettiEnabled: true, 20 | countSkippedDaysInStreak: false, 21 | toggleConfetti: (value) => 22 | set((state) => { 23 | state.confettiEnabled = value; 24 | }), 25 | setCountSkippedDaysInStreak: (value) => 26 | set((state) => { 27 | state.countSkippedDaysInStreak = value; 28 | }), 29 | }), 30 | { 31 | name: "settings", 32 | }, 33 | ), 34 | ), 35 | ); 36 | -------------------------------------------------------------------------------- /packages/ui/turbo/generators/config.ts: -------------------------------------------------------------------------------- 1 | import type { PlopTypes } from "@turbo/gen"; 2 | 3 | // Learn more about Turborepo Generators at https://turbo.build/repo/docs/core-concepts/monorepos/code-generation 4 | 5 | export default function generator(plop: PlopTypes.NodePlopAPI): void { 6 | // A simple generator to add a new React component to the internal UI library 7 | plop.setGenerator("react-component", { 8 | description: "Adds a new react component", 9 | prompts: [ 10 | { 11 | type: "input", 12 | name: "name", 13 | message: "What is the name of the component?", 14 | }, 15 | ], 16 | actions: [ 17 | { 18 | type: "add", 19 | path: "src/{{kebabCase name}}.tsx", 20 | templateFile: "templates/component.hbs", 21 | }, 22 | { 23 | type: "append", 24 | path: "package.json", 25 | pattern: /"exports": {(?)/g, 26 | template: ' "./{{kebabCase name}}": "./src/{{kebabCase name}}.tsx",', 27 | }, 28 | ], 29 | }); 30 | } 31 | -------------------------------------------------------------------------------- /apps/offline-nextjs/src/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | 5 | // eslint-disable-next-line @typescript-eslint/no-empty-object-type 6 | export interface InputProps 7 | extends React.InputHTMLAttributes {} 8 | 9 | const Input = React.forwardRef( 10 | ({ className, type, ...props }, ref) => { 11 | return ( 12 | 21 | ); 22 | }, 23 | ); 24 | Input.displayName = "Input"; 25 | 26 | export { Input }; 27 | -------------------------------------------------------------------------------- /apps/offline-nextjs/public/globe.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/offline-nextjs/src/components/color-picker.tsx: -------------------------------------------------------------------------------- 1 | import { Popover, PopoverContent } from "@/components/ui/popover"; 2 | import { colors } from "@/constants"; 3 | import { PopoverTrigger } from "@radix-ui/react-popover"; 4 | 5 | export function ColorPicker({ 6 | value, 7 | onChange, 8 | }: { 9 | value: string; 10 | onChange: (newColor: string) => void; 11 | }) { 12 | return ( 13 | 14 | 15 |
19 | 20 | 21 | {/* */} 22 |
23 | {colors.map((color) => ( 24 |
33 |
34 | 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /apps/offline-nextjs/src/app/edit/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { HabitForm } from "@/components/habit-form"; 4 | import { GoToMainPageButton } from "@/components/misc"; 5 | import { useHabitsStore } from "@/state"; 6 | import { useRouter } from "next/navigation"; 7 | import { use } from "react"; 8 | 9 | type Params = Promise<{ id: string }>; 10 | 11 | export default function Page(props: { params: Params }) { 12 | const params = use(props.params); 13 | const { id: habitId } = params; 14 | const updateHabitData = useHabitsStore((state) => state.updateHabitData); 15 | const habitData = useHabitsStore((state) => state.data[habitId]); 16 | const router = useRouter(); 17 | 18 | return ( 19 |
20 |
21 | 22 |

Edit Habit

23 |
24 | { 27 | router.back(); 28 | updateHabitData({ id: habitData.id, payload }); 29 | }} 30 | /> 31 |
32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /apps/offline-nextjs/src/lib/completion-rate/index.test.ts: -------------------------------------------------------------------------------- 1 | import { completionRate } from "@/lib/completion-rate"; 2 | import { describe, expect, it } from "vitest"; 3 | 4 | // ! TODO: redo the whole test; 5 | describe("completion rate", () => { 6 | it("test", () => { 7 | const dates = [ 8 | "2024-11-01", 9 | "2024-11-06", 10 | "2024-11-05", 11 | "2024-11-04", 12 | "2024-10-31", 13 | "2024-10-30", 14 | "2024-10-29", 15 | "2024-10-28", 16 | ]; 17 | const frequency = [false, true, true, true, true, true, false]; 18 | expect(completionRate(dates, frequency)).toBe("100%"); 19 | }); 20 | it("should return 0% for no dates", () => { 21 | expect(completionRate([])).toBe("0%"); 22 | }); 23 | 24 | // it("should handle when all dates are inactive", () => { 25 | // const today = dayjs().startOf("day"); 26 | // const dates = [ 27 | // today, 28 | // today.subtract(1, "day"), 29 | // today.subtract(3, "day"), 30 | // today.subtract(4, "day"), 31 | // ]; 32 | // expect( 33 | // completionRate(dates, [true, true, true, true, true, true, true], true), 34 | // ).toBe("80%"); 35 | // }); 36 | }); 37 | -------------------------------------------------------------------------------- /apps/offline-nextjs/src/lib/day.ts: -------------------------------------------------------------------------------- 1 | import dayjs, { type Dayjs } from "dayjs"; 2 | 3 | import isSameOrAfter from "dayjs/plugin/isSameOrAfter"; 4 | import isSameOrBefore from "dayjs/plugin/isSameOrBefore"; 5 | import localizedFormat from "dayjs/plugin/localizedFormat"; 6 | import quarterOfYear from "dayjs/plugin/quarterOfYear"; 7 | 8 | dayjs.extend(localizedFormat); 9 | dayjs.extend(isSameOrBefore); 10 | dayjs.extend(isSameOrAfter); 11 | dayjs.extend(quarterOfYear); 12 | 13 | export { dayjs, type Dayjs }; 14 | 15 | type DateInput = string | number | Date | Dayjs | null | undefined; 16 | 17 | /** 18 | * Normalizes a given date input into a standardized "YYYY-MM-DD" string format. 19 | */ 20 | export function normalizeDate(date: DateInput) { 21 | return dayjs(date).format("YYYY-MM-DD"); 22 | } 23 | 24 | export const startOfDay = (date: Date | Dayjs) => 25 | dayjs(date).startOf("day").toDate(); 26 | 27 | export const sortDates = (dates: Date[]) => 28 | dates.sort((a, b) => startOfDay(a).getTime() - startOfDay(b).getTime()); 29 | 30 | export const differenceInDays = (later: Date, earlier: Date) => { 31 | const date = dayjs(later); 32 | return date.diff(earlier, "day"); 33 | }; 34 | -------------------------------------------------------------------------------- /apps/offline-nextjs/src/providers/habit-provider.tsx: -------------------------------------------------------------------------------- 1 | import { useHabitsStore } from "@/state"; 2 | import type { HabitData } from "@/types"; 3 | import { type ReactNode, createContext, useContext, useMemo } from "react"; 4 | 5 | type HabitContextValue = { 6 | habitData: HabitData; 7 | }; 8 | 9 | const HabitContext = createContext(null); 10 | 11 | export function useHabit(): HabitContextValue { 12 | const context = useContext(HabitContext); 13 | 14 | if (!context) { 15 | throw new Error("useHabit must be used within a HabitProvider"); 16 | } 17 | 18 | return context; 19 | } 20 | 21 | type HabitProviderProps = { 22 | children: ReactNode; 23 | habitId: string; 24 | }; 25 | 26 | export function HabitProvider({ 27 | children, 28 | habitId, 29 | }: HabitProviderProps): JSX.Element { 30 | const habitData = useHabitsStore((state) => state.data[habitId]); 31 | 32 | const contextValue = useMemo(() => ({ habitData }), [habitData]); 33 | 34 | if (!habitData) { 35 | return
Habit Doesn't Exist!
; 36 | } 37 | 38 | return ( 39 | 40 | {children} 41 | 42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /apps/offline-nextjs/src/components/ui/switch.tsx: -------------------------------------------------------------------------------- 1 | import * as SwitchPrimitives from "@radix-ui/react-switch"; 2 | import * as React from "react"; 3 | 4 | import { cn } from "@/lib/utils"; 5 | 6 | const Switch = React.forwardRef< 7 | React.ElementRef, 8 | React.ComponentPropsWithoutRef 9 | >(({ className, ...props }, ref) => ( 10 | 18 | 23 | 24 | )); 25 | Switch.displayName = SwitchPrimitives.Root.displayName; 26 | 27 | export { Switch }; 28 | -------------------------------------------------------------------------------- /apps/offline-nextjs/src/components/ui/tooltip.tsx: -------------------------------------------------------------------------------- 1 | import * as TooltipPrimitive from "@radix-ui/react-tooltip"; 2 | import * as React from "react"; 3 | 4 | import { cn } from "@/lib/utils"; 5 | 6 | const TooltipProvider = TooltipPrimitive.Provider; 7 | 8 | const Tooltip = TooltipPrimitive.Root; 9 | 10 | const TooltipTrigger = TooltipPrimitive.Trigger; 11 | 12 | const TooltipContent = React.forwardRef< 13 | React.ElementRef, 14 | React.ComponentPropsWithoutRef 15 | >(({ className, sideOffset = 4, ...props }, ref) => ( 16 | 25 | )); 26 | TooltipContent.displayName = TooltipPrimitive.Content.displayName; 27 | 28 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }; 29 | -------------------------------------------------------------------------------- /apps/offline-nextjs/src/lib/streak-ranges/index.ts: -------------------------------------------------------------------------------- 1 | import { sortDates, calculateStreaks as summary } from "@/lib/streaks/index"; 2 | import dayjs from "dayjs"; 3 | 4 | type StreakRange = { 5 | start: Date; 6 | end: Date | null; 7 | duration: number; 8 | }; 9 | 10 | export function streakRanges(dates: (string | Date)[]) { 11 | if (dates.length === 0) { 12 | return []; 13 | } 14 | 15 | const { streaks } = summary({ 16 | dates: dates.map((date) => dayjs(date).toDate()), 17 | }); 18 | const allDates = [...sortDates(dates.map((date) => dayjs(date)))]; 19 | 20 | const result = streaks.reduce((acc, streak) => { 21 | let start: Date; 22 | let end: Date | null; 23 | const days = allDates.slice(0, streak); 24 | allDates.splice(0, streak); 25 | 26 | if (days && days.length > 1) { 27 | start = days[0].toDate(); 28 | end = days[days.length - 1].toDate(); 29 | } else { 30 | start = days[0].toDate(); 31 | end = null; 32 | } 33 | 34 | return [ 35 | // biome-ignore lint/performance/noAccumulatingSpread: should be okay 36 | ...acc, 37 | { 38 | start, 39 | end, 40 | duration: streak, 41 | }, 42 | ] satisfies StreakRange[]; 43 | }, [] as StreakRange[]); 44 | return result.reverse(); 45 | } 46 | 47 | export default streakRanges; 48 | -------------------------------------------------------------------------------- /apps/offline-nextjs/public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/offline-nextjs/src/providers/post-hog.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { usePathname, useSearchParams } from "next/navigation"; 3 | import posthog from "posthog-js"; 4 | import { PostHogProvider } from "posthog-js/react"; 5 | import { usePostHog } from "posthog-js/react"; 6 | import { useEffect } from "react"; 7 | 8 | export function PostHogPageView(): null { 9 | const pathname = usePathname(); 10 | const searchParams = useSearchParams(); 11 | const posthog = usePostHog(); 12 | useEffect(() => { 13 | // Track pageviews 14 | if (pathname && posthog) { 15 | let url = window.origin + pathname; 16 | if (searchParams.toString()) { 17 | url = `${url}?${searchParams.toString()}`; 18 | } 19 | posthog.capture("$pageview", { 20 | $current_url: url, 21 | }); 22 | } 23 | }, [pathname, searchParams, posthog]); 24 | 25 | return null; 26 | } 27 | 28 | export function PostHogProviderWrapper({ 29 | children, 30 | }: { 31 | children: React.ReactNode; 32 | }) { 33 | useEffect(() => { 34 | // biome-ignore lint/style/noNonNullAssertion: 35 | posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY!, { 36 | api_host: "/ingest", 37 | person_profiles: "identified_only", 38 | // Disable automatic pageview capture, as we capture manually 39 | capture_pageview: false, 40 | }); 41 | }, []); 42 | 43 | return {children}; 44 | } 45 | -------------------------------------------------------------------------------- /apps/offline-nextjs/src/components/ui/popover.tsx: -------------------------------------------------------------------------------- 1 | import * as PopoverPrimitive from "@radix-ui/react-popover"; 2 | import * as React from "react"; 3 | 4 | import { cn } from "@/lib/utils"; 5 | 6 | const Popover = PopoverPrimitive.Root; 7 | 8 | const PopoverTrigger = PopoverPrimitive.Trigger; 9 | 10 | const PopoverAnchor = PopoverPrimitive.Anchor; 11 | 12 | const PopoverContent = React.forwardRef< 13 | React.ElementRef, 14 | React.ComponentPropsWithoutRef 15 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( 16 | 17 | 27 | 28 | )); 29 | PopoverContent.displayName = PopoverPrimitive.Content.displayName; 30 | 31 | export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }; 32 | -------------------------------------------------------------------------------- /apps/offline-nextjs/src/hooks/use-media-query.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | function getDevice(): "mobile" | "tablet" | "desktop" | null { 4 | if (typeof window === "undefined") return null; 5 | 6 | return window.matchMedia("(min-width: 1024px)").matches 7 | ? "desktop" 8 | : window.matchMedia("(min-width: 640px)").matches 9 | ? "tablet" 10 | : "mobile"; 11 | } 12 | 13 | function getDimensions() { 14 | if (typeof window === "undefined") return null; 15 | 16 | return { width: window.innerWidth, height: window.innerHeight }; 17 | } 18 | 19 | export function useMediaQuery() { 20 | const [device, setDevice] = useState<"mobile" | "tablet" | "desktop" | null>( 21 | getDevice(), 22 | ); 23 | const [dimensions, setDimensions] = useState<{ 24 | width: number; 25 | height: number; 26 | } | null>(getDimensions()); 27 | 28 | useEffect(() => { 29 | const checkDevice = () => { 30 | setDevice(getDevice()); 31 | setDimensions(getDimensions()); 32 | }; 33 | 34 | // Initial detection 35 | checkDevice(); 36 | 37 | // Listener for windows resize 38 | window.addEventListener("resize", checkDevice); 39 | 40 | // Cleanup listener 41 | return () => { 42 | window.removeEventListener("resize", checkDevice); 43 | }; 44 | }, []); 45 | 46 | return { 47 | device, 48 | width: dimensions?.width, 49 | height: dimensions?.height, 50 | isMobile: device === "mobile", 51 | isTablet: device === "tablet", 52 | isDesktop: device === "desktop", 53 | }; 54 | } 55 | -------------------------------------------------------------------------------- /apps/offline-nextjs/src/components/ui/toggle.tsx: -------------------------------------------------------------------------------- 1 | import * as TogglePrimitive from "@radix-ui/react-toggle"; 2 | import { type VariantProps, cva } from "class-variance-authority"; 3 | import * as React from "react"; 4 | 5 | import { cn } from "@/lib/utils"; 6 | 7 | const toggleVariants = cva( 8 | "inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground", 9 | { 10 | variants: { 11 | variant: { 12 | default: "bg-transparent", 13 | outline: 14 | "border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground", 15 | }, 16 | size: { 17 | default: "h-9 px-3", 18 | sm: "h-8 px-2", 19 | lg: "h-10 px-3", 20 | }, 21 | }, 22 | defaultVariants: { 23 | variant: "default", 24 | size: "default", 25 | }, 26 | }, 27 | ); 28 | 29 | const Toggle = React.forwardRef< 30 | React.ElementRef, 31 | React.ComponentPropsWithoutRef & 32 | VariantProps 33 | >(({ className, variant, size, ...props }, ref) => ( 34 | 39 | )); 40 | 41 | Toggle.displayName = TogglePrimitive.Root.displayName; 42 | 43 | export { Toggle, toggleVariants }; 44 | -------------------------------------------------------------------------------- /apps/offline-nextjs/src/components/ui/radio-group.tsx: -------------------------------------------------------------------------------- 1 | import { CheckIcon } from "@radix-ui/react-icons"; 2 | import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"; 3 | import * as React from "react"; 4 | 5 | import { cn } from "@/lib/utils"; 6 | 7 | const RadioGroup = React.forwardRef< 8 | React.ElementRef, 9 | React.ComponentPropsWithoutRef 10 | >(({ className, ...props }, ref) => { 11 | return ( 12 | 17 | ); 18 | }); 19 | RadioGroup.displayName = RadioGroupPrimitive.Root.displayName; 20 | 21 | const RadioGroupItem = React.forwardRef< 22 | React.ElementRef, 23 | React.ComponentPropsWithoutRef 24 | >(({ className, ...props }, ref) => { 25 | return ( 26 | 34 | 35 | 36 | 37 | 38 | ); 39 | }); 40 | RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName; 41 | 42 | export { RadioGroup, RadioGroupItem }; 43 | -------------------------------------------------------------------------------- /apps/offline-nextjs/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/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 `app/page.tsx`. The page auto-updates as you edit the file. 20 | 21 | This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. 22 | 23 | ## Learn More 24 | 25 | To learn more about Next.js, take a look at the following resources: 26 | 27 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 28 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 29 | 30 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! 31 | 32 | ## Deploy on Vercel 33 | 34 | 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. 35 | 36 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. 37 | -------------------------------------------------------------------------------- /apps/offline-nextjs/src/components/misc.tsx: -------------------------------------------------------------------------------- 1 | import { normalizeColor } from "@/components/calendar/monthly/overview"; 2 | import { Button } from "@/components/ui/button"; 3 | import { cn } from "@/lib/utils"; 4 | import { ArrowLeft } from "@phosphor-icons/react"; 5 | import { useRouter } from "next/navigation"; 6 | 7 | export function GoToMainPageButton() { 8 | const { back } = useRouter(); 9 | 10 | return ( 11 | 14 | ); 15 | } 16 | 17 | export function RepeatedHeader({ 18 | word, 19 | }: { word: "habits" | "archived" | "stats" }) { 20 | // @HACKY: use tailwind safelist 21 | const content = { 22 | habits: "after:content-['habits']", 23 | archived: "after:content-['archived']", 24 | stats: "after:content-['stats']", 25 | }; 26 | return ( 27 | 33 | {word} 34 | 35 | ); 36 | } 37 | 38 | export function HabitColor({ 39 | color, 40 | className, 41 | }: { 42 | color: string; 43 | className?: string; 44 | }) { 45 | const { dayCompletedColor } = normalizeColor(color); 46 | return ( 47 |
53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /apps/offline-nextjs/src/app/habit/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { HabitStats, MonthlyView } from "@/components/calendar/monthly/view"; 4 | import { GoToMainPageButton, HabitColor } from "@/components/misc"; 5 | import { buttonVariants } from "@/components/ui/button"; 6 | import { HabitProvider, useHabit } from "@/providers/habit-provider"; 7 | import { Archive, PencilSimple } from "@phosphor-icons/react"; 8 | import Link from "next/link"; 9 | import { use } from "react"; 10 | 11 | type Params = Promise<{ id: string }>; 12 | 13 | export default function HabitView(props: { params: Params }) { 14 | const params = use(props.params); 15 | const { id: habitId } = params; 16 | return ( 17 |
18 | 19 |
20 | 21 | 22 |
23 |
24 | 25 |
26 |
27 |
28 | ); 29 | } 30 | 31 | function HabitInfo() { 32 | const { 33 | habitData: { name, color, isArchived, id: habitId }, 34 | } = useHabit(); 35 | 36 | return ( 37 |
38 | 39 |
40 |
41 | {isArchived && } 42 | 43 |
{name}
44 |
45 | 49 | 50 | 51 |
52 |
53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /apps/offline-nextjs/src/components/ui/toggle-group.tsx: -------------------------------------------------------------------------------- 1 | import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"; 2 | import type { VariantProps } from "class-variance-authority"; 3 | import * as React from "react"; 4 | 5 | import { toggleVariants } from "@/components/ui/toggle"; 6 | import { cn } from "@/lib/utils"; 7 | 8 | const ToggleGroupContext = React.createContext< 9 | VariantProps 10 | >({ 11 | size: "default", 12 | variant: "default", 13 | }); 14 | 15 | const ToggleGroup = React.forwardRef< 16 | React.ElementRef, 17 | React.ComponentPropsWithoutRef & 18 | VariantProps 19 | >(({ className, variant, size, children, ...props }, ref) => ( 20 | 25 | 26 | {children} 27 | 28 | 29 | )); 30 | 31 | ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName; 32 | 33 | const ToggleGroupItem = React.forwardRef< 34 | React.ElementRef, 35 | React.ComponentPropsWithoutRef & 36 | VariantProps 37 | >(({ className, children, variant, size, ...props }, ref) => { 38 | const context = React.useContext(ToggleGroupContext); 39 | 40 | return ( 41 | 52 | {children} 53 | 54 | ); 55 | }); 56 | 57 | ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName; 58 | 59 | export { ToggleGroup, ToggleGroupItem }; 60 | -------------------------------------------------------------------------------- /apps/offline-nextjs/tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | 3 | const config: Config = { 4 | darkMode: ["class"], 5 | content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"], 6 | theme: { 7 | fontFamily: { 8 | display: ["var(--font-excalifont)"], 9 | mathlete: ["var(--font-mathlete)"], 10 | neucha: ["var(--font-neucha)"], 11 | pacifico: ["var(--font-pacifico)"], 12 | }, 13 | extend: { 14 | borderRadius: { 15 | lg: "var(--radius)", 16 | md: "calc(var(--radius) - 2px)", 17 | sm: "calc(var(--radius) - 4px)", 18 | }, 19 | colors: { 20 | background: "hsl(var(--background))", 21 | foreground: "hsl(var(--foreground))", 22 | card: { 23 | DEFAULT: "hsl(var(--card))", 24 | foreground: "hsl(var(--card-foreground))", 25 | }, 26 | popover: { 27 | DEFAULT: "hsl(var(--popover))", 28 | foreground: "hsl(var(--popover-foreground))", 29 | }, 30 | primary: { 31 | DEFAULT: "hsl(var(--primary))", 32 | foreground: "hsl(var(--primary-foreground))", 33 | }, 34 | secondary: { 35 | DEFAULT: "hsl(var(--secondary))", 36 | foreground: "hsl(var(--secondary-foreground))", 37 | }, 38 | muted: { 39 | DEFAULT: "hsl(var(--muted))", 40 | foreground: "hsl(var(--muted-foreground))", 41 | }, 42 | accent: { 43 | DEFAULT: "hsl(var(--accent))", 44 | foreground: "hsl(var(--accent-foreground))", 45 | }, 46 | destructive: { 47 | DEFAULT: "hsl(var(--destructive))", 48 | foreground: "hsl(var(--destructive-foreground))", 49 | }, 50 | border: "hsl(var(--border))", 51 | input: "hsl(var(--input))", 52 | ring: "hsl(var(--ring))", 53 | chart: { 54 | "1": "hsl(var(--chart-1))", 55 | "2": "hsl(var(--chart-2))", 56 | "3": "hsl(var(--chart-3))", 57 | "4": "hsl(var(--chart-4))", 58 | "5": "hsl(var(--chart-5))", 59 | }, 60 | }, 61 | }, 62 | }, 63 | plugins: [require("tailwindcss-animate")], 64 | }; 65 | export default config; 66 | -------------------------------------------------------------------------------- /apps/offline-nextjs/src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import { Slot } from "@radix-ui/react-slot"; 2 | import { type VariantProps, cva } from "class-variance-authority"; 3 | import * as React from "react"; 4 | 5 | import { cn } from "@/lib/utils"; 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground shadow hover:bg-primary/90", 14 | destructive: 15 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", 16 | outline: 17 | "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", 20 | ghost: "hover:bg-accent hover:text-accent-foreground", 21 | link: "text-primary underline-offset-4 hover:underline", 22 | }, 23 | size: { 24 | default: "h-9 px-4 py-2", 25 | xs: "h-6 rounded-sm px-2.5 text-xs", 26 | sm: "h-8 rounded-md px-3 text-xs", 27 | lg: "h-10 rounded-md px-8", 28 | icon: "h-9 w-9", 29 | }, 30 | }, 31 | defaultVariants: { 32 | variant: "default", 33 | size: "default", 34 | }, 35 | }, 36 | ); 37 | 38 | export interface ButtonProps 39 | extends React.ButtonHTMLAttributes, 40 | VariantProps { 41 | asChild?: boolean; 42 | } 43 | 44 | const Button = React.forwardRef( 45 | ({ className, variant, size, asChild = false, ...props }, ref) => { 46 | const Comp = asChild ? Slot : "button"; 47 | return ( 48 | 53 | ); 54 | }, 55 | ); 56 | Button.displayName = "Button"; 57 | 58 | export { Button, buttonVariants }; 59 | -------------------------------------------------------------------------------- /apps/offline-nextjs/src/providers/monthly-navigation.tsx: -------------------------------------------------------------------------------- 1 | import { dayjs } from "@/lib/day"; 2 | import { createContext, useCallback, useContext } from "react"; 3 | import { useImmerReducer } from "use-immer"; 4 | 5 | type State = { 6 | date: dayjs.Dayjs; 7 | }; 8 | 9 | type Action = { type: "NEXT_MONTH" } | { type: "PREV_MONTH" }; 10 | 11 | const initialState: State = { 12 | date: dayjs(), 13 | }; 14 | 15 | function monthReducer(state: State, action: Action): State { 16 | switch (action.type) { 17 | case "PREV_MONTH": { 18 | state.date = state.date.subtract(1, "month"); 19 | break; 20 | } 21 | case "NEXT_MONTH": { 22 | const isAfter = dayjs().isAfter(state.date, "month"); 23 | if (!isAfter) return state; 24 | state.date = state.date.add(1, "month"); 25 | break; 26 | } 27 | } 28 | return state; 29 | } 30 | 31 | export function useMonth() { 32 | const [state, dispatch] = useImmerReducer(monthReducer, initialState); 33 | 34 | const goToPrevMonth = useCallback( 35 | () => dispatch({ type: "PREV_MONTH" }), 36 | [dispatch], 37 | ); 38 | const goToNextMonth = useCallback( 39 | () => dispatch({ type: "NEXT_MONTH" }), 40 | [dispatch], 41 | ); 42 | 43 | return { 44 | currentYear: state.date.year(), 45 | currentMonth: state.date.month(), 46 | daysInMonth: state.date.daysInMonth(), 47 | startOffset: state.date.startOf("month").day(), 48 | isCurrentMonth: dayjs().isSame(state.date, "month"), 49 | goToPrevMonth, 50 | goToNextMonth, 51 | }; 52 | } 53 | 54 | type ContextValue = ReturnType; 55 | 56 | const DateContext = createContext(null); 57 | 58 | export function useMonthContext() { 59 | const context = useContext(DateContext); 60 | if (!context) { 61 | throw new Error("useMonthContext must be used within a MonthDateProvider."); 62 | } 63 | return context; 64 | } 65 | 66 | export function MonthDateProvider({ children }: { children: React.ReactNode }) { 67 | const value = useMonth(); 68 | return {children}; 69 | } 70 | -------------------------------------------------------------------------------- /apps/offline-nextjs/src/lib/completion-rate/index.ts: -------------------------------------------------------------------------------- 1 | import { sortDates } from "@/lib/day"; 2 | import dayjs, { type Dayjs } from "dayjs"; 3 | 4 | export function completionRate( 5 | dates: (string | Date | Dayjs)[], 6 | frequency?: boolean[], 7 | debug?: boolean, 8 | ) { 9 | const sortedDates = sortDates(dates.map((d) => dayjs(d).toDate())); 10 | 11 | if (sortedDates.length === 0) return "0%"; 12 | 13 | // let firstDate = sortedDates[0]; 14 | 15 | // if ( frequency ) { 16 | // const activeDates = sortedDates.filter(date => frequency[date.getDay()]); 17 | // if (activeDates.length === 0) return "100%"; 18 | 19 | // firstDate = activeDates[0]; 20 | // } 21 | 22 | let completedDays = sortedDates.length; 23 | 24 | for (let i = 0; i < sortedDates.length; i++) { 25 | const currentDate = dayjs(sortedDates[i]); 26 | const nextDate = sortedDates[i + 1] ? sortedDates[i + 1] : currentDate; 27 | const diff = dayjs(nextDate).diff(currentDate, "day"); 28 | 29 | if (diff < 1) { 30 | continue; 31 | } 32 | 33 | // diff is more than a week, or no frequency was provided 34 | if (diff > 7 || !frequency) { 35 | completedDays--; 36 | if (debug) console.log("subtracted from here"); 37 | continue; 38 | } 39 | 40 | const daysTillNextDate = Array.from({ length: diff }, (_, j) => 41 | currentDate.add(j, "day"), 42 | ); 43 | const inactiveDays = daysTillNextDate 44 | .map((date) => frequency?.[date.day()] ?? true) 45 | .filter((isActive) => !isActive).length; 46 | 47 | if (inactiveDays + 1 === diff) { 48 | continue; 49 | } 50 | if (debug) { 51 | console.log( 52 | inactiveDays + 1, 53 | diff, 54 | currentDate.format("ll"), 55 | dayjs(nextDate).format("ll"), 56 | "reached", 57 | ); 58 | } 59 | completedDays--; 60 | } 61 | 62 | if (debug) console.log({ completedDays, total: sortedDates.length }); 63 | return percentage(completedDays, sortedDates.length); 64 | } 65 | 66 | export function percentage(partialValue: number, totalValue: number) { 67 | return `${((100 * partialValue) / totalValue).toFixed()}%`; 68 | } 69 | -------------------------------------------------------------------------------- /apps/offline-nextjs/src/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | 5 | const Card = React.forwardRef< 6 | HTMLDivElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
17 | )); 18 | Card.displayName = "Card"; 19 | 20 | const CardHeader = React.forwardRef< 21 | HTMLDivElement, 22 | React.HTMLAttributes 23 | >(({ className, ...props }, ref) => ( 24 |
29 | )); 30 | CardHeader.displayName = "CardHeader"; 31 | 32 | const CardTitle = React.forwardRef< 33 | HTMLParagraphElement, 34 | React.HTMLAttributes 35 | >(({ className, ...props }, ref) => ( 36 |

41 | )); 42 | CardTitle.displayName = "CardTitle"; 43 | 44 | const CardDescription = React.forwardRef< 45 | HTMLParagraphElement, 46 | React.HTMLAttributes 47 | >(({ className, ...props }, ref) => ( 48 |

53 | )); 54 | CardDescription.displayName = "CardDescription"; 55 | 56 | const CardContent = React.forwardRef< 57 | HTMLDivElement, 58 | React.HTMLAttributes 59 | >(({ className, ...props }, ref) => ( 60 |

61 | )); 62 | CardContent.displayName = "CardContent"; 63 | 64 | const CardFooter = React.forwardRef< 65 | HTMLDivElement, 66 | React.HTMLAttributes 67 | >(({ className, ...props }, ref) => ( 68 |
73 | )); 74 | CardFooter.displayName = "CardFooter"; 75 | 76 | export { 77 | Card, 78 | CardHeader, 79 | CardFooter, 80 | CardTitle, 81 | CardDescription, 82 | CardContent, 83 | }; 84 | -------------------------------------------------------------------------------- /apps/offline-nextjs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "offline-nextjs", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev --turbopack", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "test": "vitest", 11 | "typecheck": "tsc --noEmit" 12 | }, 13 | "dependencies": { 14 | "@ctrl/tinycolor": "^4.1.0", 15 | "@dnd-kit/core": "^6.3.1", 16 | "@dnd-kit/modifiers": "^7.0.0", 17 | "@dnd-kit/sortable": "^8.0.0", 18 | "@dnd-kit/utilities": "^3.2.2", 19 | "@hookform/resolvers": "^3.10.0", 20 | "@instantdb/react": "^0.17.33", 21 | "@phosphor-icons/react": "^2.1.10", 22 | "@radix-ui/react-dialog": "^1.1.14", 23 | "@radix-ui/react-dropdown-menu": "^2.1.15", 24 | "@radix-ui/react-icons": "^1.3.2", 25 | "@radix-ui/react-label": "^2.1.7", 26 | "@radix-ui/react-popover": "^1.1.14", 27 | "@radix-ui/react-radio-group": "^1.3.7", 28 | "@radix-ui/react-slot": "^1.2.3", 29 | "@radix-ui/react-switch": "^1.2.5", 30 | "@radix-ui/react-toggle": "^1.1.9", 31 | "@radix-ui/react-toggle-group": "^1.1.10", 32 | "@radix-ui/react-tooltip": "^1.2.7", 33 | "canvas-confetti": "^1.9.3", 34 | "class-variance-authority": "^0.7.1", 35 | "clsx": "^2.1.1", 36 | "dayjs": "^1.11.13", 37 | "framer-motion": "^12.16.0", 38 | "html2canvas": "^1.4.1", 39 | "little-date": "^1.0.0", 40 | "lucide-react": "^0.451.0", 41 | "nanoid": "^5.1.5", 42 | "next": "15.0.1", 43 | "posthog-js": "^1.249.4", 44 | "react": "19.0.0-rc-69d4b800-20241021", 45 | "react-colorful": "^5.6.1", 46 | "react-dom": "19.0.0-rc-69d4b800-20241021", 47 | "react-hook-form": "^7.57.0", 48 | "recharts": "^2.15.3", 49 | "tailwind-merge": "^2.6.0", 50 | "tailwindcss-animate": "^1.0.7", 51 | "use-immer": "^0.10.0", 52 | "use-react-screenshot": "^4.0.0", 53 | "vaul": "^1.1.2", 54 | "zod": "^3.25.55", 55 | "zustand": "5.0.0-rc.2" 56 | }, 57 | "devDependencies": { 58 | "@types/canvas-confetti": "^1.9.0", 59 | "@types/node": "^20.19.0", 60 | "@types/react": "^18.3.23", 61 | "@types/react-dom": "^18.3.7", 62 | "eslint": "^8.57.1", 63 | "eslint-config-next": "15.0.1", 64 | "postcss": "^8.5.4", 65 | "tailwindcss": "^3.4.17", 66 | "typescript": "^5.8.3", 67 | "vitest": "^2.1.9" 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /apps/offline-nextjs/src/app/settings/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { 4 | Card, 5 | CardContent, 6 | CardDescription, 7 | CardHeader, 8 | CardTitle, 9 | } from "@/components/ui/card"; 10 | import { Switch } from "@/components/ui/switch"; 11 | import { useSettingsStore } from "@/state/settings"; 12 | 13 | export default function Settings() { 14 | const { toggleConfetti, confettiEnabled } = useSettingsStore( 15 | (state) => state, 16 | ); 17 | 18 | return ( 19 |
20 |
21 |
22 |

Settings

23 |
24 |
25 | 26 | 27 | Customization 28 | {} 29 | 30 | 31 |
32 | {/*
33 | 39 | 40 |
*/} 41 |
42 | 48 | 53 |
54 |
55 |
56 | {/* 57 | 58 | */} 59 |
60 |
61 |
62 |
63 | ); 64 | } 65 | -------------------------------------------------------------------------------- /apps/offline-nextjs/src/providers/weekly-navigation.tsx: -------------------------------------------------------------------------------- 1 | import { normalizeDate } from "@/lib/day"; 2 | import dayjs, { type Dayjs } from "dayjs"; 3 | import { createContext, useContext, useMemo } from "react"; 4 | import { useImmerReducer } from "use-immer"; 5 | 6 | type Action = { type: "PREV_DAY" } | { type: "NEXT_DAY" }; 7 | 8 | const initialState = { 9 | date: dayjs(), 10 | }; 11 | 12 | function dateReducer(state: typeof initialState, action: Action) { 13 | switch (action.type) { 14 | case "PREV_DAY": { 15 | state.date = state.date.subtract(1, "day"); 16 | return state; 17 | } 18 | case "NEXT_DAY": { 19 | const isFutureDate = dayjs().isAfter(state.date, "day"); 20 | if (!isFutureDate) { 21 | return state; 22 | } 23 | state.date = state.date.add(1, "day"); 24 | return state; 25 | } 26 | default: 27 | throw new Error(`Unhandled action type: ${(action as Action).type}`); 28 | } 29 | } 30 | 31 | export function useDateNavigator() { 32 | const [state, dispatch] = useImmerReducer(dateReducer, initialState); 33 | 34 | const dateArray = useMemo(() => { 35 | const tempArray: string[] = []; 36 | for (let i = 0; i < 7; i++) { 37 | tempArray.push(normalizeDate(state.date.subtract(i, "day"))); 38 | } 39 | return tempArray.reverse(); 40 | }, [state.date]); 41 | 42 | return { 43 | dateArray, 44 | currDate: state.date, 45 | NEXT_DAY: () => dispatch({ type: "NEXT_DAY" }), 46 | PREV_DAY: () => dispatch({ type: "PREV_DAY" }), 47 | }; 48 | } 49 | 50 | type ContextValue = { 51 | PREV_DAY: () => void; 52 | NEXT_DAY: () => void; 53 | currDate: Dayjs; 54 | dateArray: string[]; 55 | }; 56 | 57 | const DateContext = createContext(null); 58 | 59 | export function useWeeklyDate() { 60 | const context = useContext(DateContext); 61 | if (!context) 62 | throw new Error("useWeeklyDate must be used within a WeeklyDateProvider."); 63 | return context; 64 | } 65 | 66 | export function WeeklyDateProvider({ 67 | children, 68 | }: { 69 | children: React.ReactNode; 70 | }) { 71 | const { NEXT_DAY, PREV_DAY, currDate, dateArray } = useDateNavigator(); 72 | 73 | const value = useMemo( 74 | () => ({ NEXT_DAY, PREV_DAY, currDate, dateArray }), 75 | [dateArray, currDate, NEXT_DAY, PREV_DAY], 76 | ); 77 | 78 | return {children}; 79 | } 80 | -------------------------------------------------------------------------------- /apps/offline-nextjs/src/components/calendar/monthly/charts.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { normalizeColor } from "@/components/calendar/monthly/overview"; 4 | import type { ChartConfig } from "@/components/ui/chart"; 5 | import { 6 | ChartContainer, 7 | ChartTooltip, 8 | ChartTooltipContent, 9 | } from "@/components/ui/chart"; 10 | import { DAYS } from "@/constants"; 11 | import { useHabit } from "@/providers/habit-provider"; 12 | import { type HabitData, Status } from "@/types"; 13 | import dayjs from "dayjs"; 14 | import { Bar, BarChart, XAxis } from "recharts"; 15 | 16 | const chartData = (dates: HabitData["dates"]) => { 17 | const rate = Array.from({ length: 7 }, () => ({ 18 | [Status.Skipped]: 0, 19 | [Status.Completed]: 0, 20 | })); 21 | 22 | Object.entries(dates).map( 23 | ([date, status]) => rate[dayjs(date).day()][status]++, 24 | ); 25 | 26 | return DAYS.map((date, index) => ({ 27 | date, 28 | completed: rate[index][Status.Completed], 29 | skipped: rate[index][Status.Skipped], 30 | })); 31 | }; 32 | 33 | export const description = "A stacked bar chart with a legend"; 34 | 35 | const chartConfig = (color: string) => { 36 | const { chartCompletedColor, chartSkippedColor } = normalizeColor(color); 37 | 38 | return { 39 | activities: { 40 | label: "Activities", 41 | }, 42 | completed: { 43 | label: "Completed", 44 | color: chartCompletedColor, 45 | }, 46 | skipped: { 47 | label: "Skipped", 48 | color: chartSkippedColor, 49 | }, 50 | } satisfies ChartConfig; 51 | }; 52 | 53 | export function WeekChart() { 54 | const { 55 | habitData: { color, dates }, 56 | } = useHabit(); 57 | 58 | return ( 59 | <> 60 | 61 | 62 | value.substring(0, 3)} 68 | /> 69 | 75 | 81 | 84 | } 85 | cursor={false} 86 | // defaultIndex={1} 87 | /> 88 | 89 | 90 | 91 | ); 92 | } 93 | -------------------------------------------------------------------------------- /apps/offline-nextjs/src/components/calendar/monthly/streaks.tsx: -------------------------------------------------------------------------------- 1 | import streakRanges from "@/lib/streak-ranges"; 2 | import { LightenDarkenColor, cn, convertHex, lightOrDark } from "@/lib/utils"; 3 | import { useHabit } from "@/providers/habit-provider"; 4 | import dayjs from "dayjs"; 5 | import { useMemo } from "react"; 6 | 7 | const getTop5RangesPercentages = ( 8 | ranges: ReturnType, 9 | ): number[] => { 10 | const totalSum = ranges.reduce((sum, range) => sum + range.duration, 0); 11 | return ranges.map((range) => (range.duration / totalSum) * 100); 12 | }; 13 | 14 | const getTop5Ranges = (ranges: ReturnType) => { 15 | const sortedRanges = ranges.sort((a, b) => b.duration - a.duration); 16 | return sortedRanges.slice(0, 5); 17 | }; 18 | 19 | export function Streaks() { 20 | const { 21 | habitData: { dates, color }, 22 | } = useHabit(); 23 | // TODO: needs to ignore inactive days 24 | const ranges = useMemo(() => { 25 | const streakRangesData = streakRanges(Object.keys(dates)); 26 | const top5Ranges = getTop5Ranges(streakRangesData); 27 | const top5RangesPercentages = getTop5RangesPercentages(top5Ranges); 28 | 29 | return top5Ranges.map((range, index) => ({ 30 | ...range, 31 | percentage: top5RangesPercentages[index], 32 | })); 33 | }, [dates]); 34 | 35 | const isLightColor = lightOrDark(color) === "light"; 36 | 37 | return ( 38 |
39 | {ranges.map((item, index) => ( 40 | // biome-ignore lint/suspicious/noArrayIndexKey: 41 |
42 | 43 | {dayjs(new Date(item.start)).format("MMM DD")} 44 | 45 |
57 | {item.duration} 58 |
59 | 60 | {dayjs(new Date(item.end ? item.end : item.start)).format("MMM DD")} 61 | 62 |
63 | ))} 64 |
65 | ); 66 | } 67 | -------------------------------------------------------------------------------- /apps/offline-nextjs/src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { TinyColor } from "@ctrl/tinycolor"; 2 | import { type ClassValue, clsx } from "clsx"; 3 | import { customAlphabet } from "nanoid"; 4 | import { twMerge } from "tailwind-merge"; 5 | 6 | export function cn(...inputs: ClassValue[]) { 7 | return twMerge(clsx(inputs)); 8 | } 9 | 10 | export function generateId(length: number): string { 11 | const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; 12 | const nanoid = customAlphabet(alphabet, length); 13 | return nanoid(); 14 | } 15 | 16 | export function convertHexToRGBA(hexCode: string, o = 1) { 17 | let opacity = o; 18 | let hex = hexCode.replace("#", ""); 19 | 20 | if (hex.length === 3) { 21 | hex = `${hex[0]}${hex[0]}${hex[1]}${hex[1]}${hex[2]}${hex[2]}`; 22 | } 23 | 24 | const r = Number.parseInt(hex.substring(0, 2), 16); 25 | const g = Number.parseInt(hex.substring(2, 4), 16); 26 | const b = Number.parseInt(hex.substring(4, 6), 16); 27 | 28 | /* Backward compatibility for whole number based opacity values. */ 29 | if (opacity > 1 && opacity <= 100) { 30 | opacity = opacity / 100; 31 | } 32 | // `rgba(${r},${g},${b},${opacity})` 33 | const rgbString = `${r},${g},${b}`; 34 | 35 | const highlight = `linear-gradient(to right, rgba(${rgbString}, 0.3) 0%, rgba(${rgbString}, 0.4) 60%, rgba(${rgbString}, 0.4) 60%, rgba(${rgbString}, 0.6) 85%, rgba(${rgbString}, 0.8) 100%)`; 36 | return highlight; 37 | } 38 | 39 | export function LightenDarkenColor(c: string, percent: number) { 40 | let usePound = false; 41 | 42 | let color = c; 43 | 44 | if (color[0] === "#") { 45 | color = color.slice(1); 46 | usePound = true; 47 | } 48 | 49 | const num = Number.parseInt(color, 16); 50 | 51 | let r = (num >> 16) + percent; 52 | 53 | if (r > 255) r = 255; 54 | else if (r < 0) r = 0; 55 | 56 | let b = ((num >> 8) & 0x00ff) + percent; 57 | 58 | if (b > 255) b = 255; 59 | else if (b < 0) b = 0; 60 | 61 | let g = (num & 0x0000ff) + percent; 62 | 63 | if (g > 255) g = 255; 64 | else if (g < 0) g = 0; 65 | 66 | return (usePound ? "#" : "") + (g | (b << 8) | (r << 16)).toString(16); 67 | } 68 | // TODO: use the helper from tinycolor 69 | export function lightOrDark(color: string) { 70 | const { r, g, b } = new TinyColor(color).toRgb(); 71 | 72 | // HSP (Highly Sensitive Poo) equation from http://alienryderflex.com/hsp.html 73 | const hsp = Math.sqrt(0.299 * (r * r) + 0.587 * (g * g) + 0.114 * (b * b)); 74 | 75 | // Using the HSP value, determine whether the color is light or dark 76 | return hsp > 190 ? "light" : "dark"; 77 | } 78 | export function convertHex(hexCode: string, opacity = 1) { 79 | const rgba = new TinyColor(hexCode); 80 | rgba.toRgbString(); 81 | rgba.setAlpha(opacity); 82 | return rgba.toRgbString(); 83 | } 84 | -------------------------------------------------------------------------------- /apps/offline-nextjs/src/lib/streaks/index.ts: -------------------------------------------------------------------------------- 1 | import dayjs, { type Dayjs } from "dayjs"; 2 | 3 | type ValideDate = Date; 4 | 5 | type ShouldSkipDayParams = { 6 | // currentDate: Dayjs, 7 | // nextDate: Dayjs, 8 | // yesterday: Dayjs, 9 | tomorrow: Dayjs; 10 | }; 11 | 12 | export function calculateStreaks({ 13 | dates, 14 | enableLog = false, 15 | shouldSkipDay, 16 | }: { 17 | dates: ValideDate[]; 18 | enableLog?: boolean; 19 | shouldSkipDay?: (args: ShouldSkipDayParams) => boolean; 20 | }): { 21 | currentStreak: number; 22 | longestStreak: number; 23 | streaks: number[]; 24 | } { 25 | if (!dates.length) 26 | return { 27 | streaks: [], 28 | longestStreak: 0, 29 | currentStreak: 0, 30 | }; 31 | const validDates = dates 32 | .filter((date) => dayjs(date).isValid()) 33 | .map((date) => dayjs(date)); 34 | const sortedDates = sortDates(validDates); 35 | const streaks: number[] = [1]; 36 | 37 | let isToday = false; 38 | let isYesterday = false; 39 | let isInFuture = false; 40 | 41 | for (let i = 0; i < sortedDates.length; i++) { 42 | const currentDate = sortedDates[i]; 43 | const nextDate = sortedDates[i + 1] ?? currentDate; 44 | 45 | const diff = nextDate.diff(currentDate, "day"); 46 | 47 | const { today, yesterday } = relativeDates(); 48 | 49 | isToday = isToday || currentDate.diff(today) === 0; 50 | isYesterday = isYesterday || currentDate.diff(yesterday) === 0; 51 | isInFuture = isInFuture || today.diff(currentDate) < 0; 52 | 53 | if (diff === 0) { 54 | } else { 55 | if (diff === 1) { 56 | ++streaks[streaks.length - 1]; 57 | } else { 58 | const tomorrow = currentDate.add(1, "day"); 59 | if ( 60 | typeof shouldSkipDay === "function" && 61 | shouldSkipDay({ tomorrow }) 62 | ) { 63 | streaks[streaks.length - 1]++; 64 | continue; 65 | } 66 | streaks.push(1); 67 | } 68 | } 69 | } 70 | 71 | return { 72 | streaks, 73 | longestStreak: Math.max(...streaks), 74 | currentStreak: isToday || isYesterday ? streaks[streaks.length - 1] : 0, 75 | }; 76 | 77 | function log(...args: unknown[]) { 78 | enableLog && console.log(...args); 79 | } 80 | } 81 | 82 | export function sortDates(dates: Dayjs[]): Dayjs[] { 83 | // TODO: remove duplicates; 84 | return dates.sort((a, b) => a.diff(b)); 85 | } 86 | 87 | /* 88 | ! for debug only; 89 | */ 90 | function formatDate(date: Dayjs) { 91 | return [date.format("dddd"), date.format("YYYY-MM-DD")].join(" | "); 92 | } 93 | 94 | function relativeDates() { 95 | return { 96 | today: dayjs().startOf("day"), 97 | yesterday: dayjs().subtract(1, "day").startOf("day"), 98 | tomorrow: dayjs().add(1, "day").startOf("day"), 99 | }; 100 | } 101 | -------------------------------------------------------------------------------- /apps/offline-nextjs/src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Button, buttonVariants } from "@/components/ui/button"; 2 | import { excalifont, mathlete, neucha, pacifico } from "@/fonts/fonts"; 3 | import { cn } from "@/lib/utils"; 4 | import { PostHogProviderWrapper } from "@/providers/post-hog"; 5 | import type { Metadata } from "next"; 6 | import Link from "next/link"; 7 | import "./globals.css"; 8 | 9 | export const metadata: Metadata = { 10 | title: "redoit!", 11 | description: "Your radically easy-to-use habit tracker", 12 | }; 13 | 14 | export default function RootLayout({ 15 | children, 16 | }: Readonly<{ 17 | children: React.ReactNode; 18 | }>) { 19 | return ( 20 | 21 | 22 | 33 |
34 | 78 |
{children}
79 |
80 | 🌱 built with care, one day at a time{" "} 81 | {" • "} 82 | 87 | send feedback ↗ 88 | 89 |
90 |
91 | 92 |
93 | 94 | ); 95 | } 96 | -------------------------------------------------------------------------------- /apps/offline-nextjs/src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @layer base { 3 | img { 4 | @apply inline-block; 5 | } 6 | } 7 | @tailwind components; 8 | @tailwind utilities; 9 | 10 | @layer base { 11 | :root { 12 | --background: 57 70% 95%; 13 | --foreground: 0 0% 3.9%; 14 | --card: 0 0% 100%; 15 | --card-foreground: 0 0% 3.9%; 16 | --popover: 0 0% 100%; 17 | --popover-foreground: 0 0% 3.9%; 18 | --primary: 0 0% 9%; 19 | --primary-foreground: 0 0% 98%; 20 | --secondary: 0 0% 96.1%; 21 | --secondary-foreground: 0 0% 9%; 22 | --muted: 0 0% 96.1%; 23 | --muted-foreground: 0 0% 45.1%; 24 | --accent: 0 0% 96.1%; 25 | --accent-foreground: 0 0% 9%; 26 | --destructive: 0 84.2% 60.2%; 27 | --destructive-foreground: 0 0% 98%; 28 | --border: 0 0% 89.8%; 29 | --input: 0 0% 89.8%; 30 | --ring: 0 0% 3.9%; 31 | --radius: 0rem; 32 | --chart-1: 12 76% 61%; 33 | --chart-2: 173 58% 39%; 34 | --chart-3: 197 37% 24%; 35 | --chart-4: 43 74% 66%; 36 | --chart-5: 27 87% 67%; 37 | } 38 | 39 | .dark { 40 | --background: 0 0% 3.9%; 41 | --foreground: 0 0% 98%; 42 | --card: 0 0% 3.9%; 43 | --card-foreground: 0 0% 98%; 44 | --popover: 0 0% 3.9%; 45 | --popover-foreground: 0 0% 98%; 46 | --primary: 0 0% 98%; 47 | --primary-foreground: 0 0% 9%; 48 | --secondary: 0 0% 14.9%; 49 | --secondary-foreground: 0 0% 98%; 50 | --muted: 0 0% 14.9%; 51 | --muted-foreground: 0 0% 63.9%; 52 | --accent: 0 0% 14.9%; 53 | --accent-foreground: 0 0% 98%; 54 | --destructive: 0 62.8% 30.6%; 55 | --destructive-foreground: 0 0% 98%; 56 | --border: 0 0% 14.9%; 57 | --input: 0 0% 14.9%; 58 | --ring: 0 0% 83.1%; 59 | --chart-1: 220 70% 50%; 60 | --chart-2: 160 60% 45%; 61 | --chart-3: 30 80% 55%; 62 | --chart-4: 280 65% 60%; 63 | --chart-5: 340 75% 55%; 64 | } 65 | } 66 | @layer base { 67 | * { 68 | @apply border-border; 69 | } 70 | body { 71 | @apply bg-background text-foreground; 72 | } 73 | } 74 | 75 | .polkadot { 76 | background-color: rgba(252, 139, 147, 0.3); 77 | background-image: radial-gradient(circle, #fc8b93 20%, transparent 20%), 78 | radial-gradient(circle, #fc8b93 20%, transparent 20%); 79 | background-size: 10px 10px, 10px 10px; 80 | background-position: 0 0, 5px 5px; 81 | } 82 | 83 | .polkadot-svg { 84 | /* background-color: rgba(252, 139, 147, 0.3); 85 | background-image: url("data:image/svg+xml,%3Csvg width='11' height='11' viewBox='0 0 11 11' xmlns='http://www.w3.org/2000/svg'%3E%3Ccircle cx='2' cy='2' r='1.4' fill='%23fc8b93'/%3E%3Ccircle cx='7' cy='7' r='1.4' fill='%23fc8b93'/%3E%3C/svg%3E"); */ 86 | background-image: url("data:image/svg+xml,%3Csvg width='11' height='11' viewBox='0 0 11 11' xmlns='http://www.w3.org/2000/svg'%3E%3Crect width='11' height='11' fill='%23965784'/%3E%3Ccircle cx='2' cy='2' r='1.4' fill='%23fff'/%3E%3Ccircle cx='7' cy='7' r='1.4' fill='%23fff'/%3E%3C/svg%3E"); 87 | background-size: 10px 10px; 88 | } 89 | -------------------------------------------------------------------------------- /apps/offline-nextjs/src/components/ui/drawer.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import { Drawer as DrawerPrimitive } from "vaul"; 5 | 6 | import { cn } from "@/lib/utils"; 7 | 8 | const Drawer = ({ 9 | shouldScaleBackground = true, 10 | ...props 11 | }: React.ComponentProps) => ( 12 | 16 | ); 17 | Drawer.displayName = "Drawer"; 18 | 19 | const DrawerTrigger = DrawerPrimitive.Trigger; 20 | 21 | const DrawerPortal = DrawerPrimitive.Portal; 22 | 23 | const DrawerClose = DrawerPrimitive.Close; 24 | 25 | const DrawerOverlay = React.forwardRef< 26 | React.ElementRef, 27 | React.ComponentPropsWithoutRef 28 | >(({ className, ...props }, ref) => ( 29 | 34 | )); 35 | DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName; 36 | 37 | const DrawerContent = React.forwardRef< 38 | React.ElementRef, 39 | React.ComponentPropsWithoutRef 40 | >(({ className, children, ...props }, ref) => ( 41 | 42 | 43 | 51 |
52 | {children} 53 | 54 | 55 | )); 56 | DrawerContent.displayName = "DrawerContent"; 57 | 58 | const DrawerHeader = ({ 59 | className, 60 | ...props 61 | }: React.HTMLAttributes) => ( 62 |
66 | ); 67 | DrawerHeader.displayName = "DrawerHeader"; 68 | 69 | const DrawerFooter = ({ 70 | className, 71 | ...props 72 | }: React.HTMLAttributes) => ( 73 |
77 | ); 78 | DrawerFooter.displayName = "DrawerFooter"; 79 | 80 | const DrawerTitle = React.forwardRef< 81 | React.ElementRef, 82 | React.ComponentPropsWithoutRef 83 | >(({ className, ...props }, ref) => ( 84 | 92 | )); 93 | DrawerTitle.displayName = DrawerPrimitive.Title.displayName; 94 | 95 | const DrawerDescription = React.forwardRef< 96 | React.ElementRef, 97 | React.ComponentPropsWithoutRef 98 | >(({ className, ...props }, ref) => ( 99 | 104 | )); 105 | DrawerDescription.displayName = DrawerPrimitive.Description.displayName; 106 | 107 | export { 108 | Drawer, 109 | DrawerPortal, 110 | DrawerOverlay, 111 | DrawerTrigger, 112 | DrawerClose, 113 | DrawerContent, 114 | DrawerHeader, 115 | DrawerFooter, 116 | DrawerTitle, 117 | DrawerDescription, 118 | }; 119 | -------------------------------------------------------------------------------- /apps/offline-nextjs/src/components/calendar/day.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { normalizeColor } from "@/components/calendar/monthly/overview"; 3 | import { 4 | Tooltip, 5 | TooltipContent, 6 | TooltipProvider, 7 | TooltipTrigger, 8 | } from "@/components/ui/tooltip"; 9 | import { type Dayjs, dayjs, normalizeDate } from "@/lib/day"; 10 | import { cn } from "@/lib/utils"; 11 | import { useHabit } from "@/providers/habit-provider"; 12 | import { useSettingsStore } from "@/state/settings"; 13 | import { Status } from "@/types"; 14 | 15 | import { 16 | type GlobalOptions as ConfettiGlobalOptions, 17 | type Options as ConfettiOptions, 18 | default as confetti, 19 | } from "canvas-confetti"; 20 | import type { MouseEvent } from "react"; 21 | 22 | interface DayWithToolTipProps { 23 | date: Dayjs | string; 24 | markDate: (date: string) => void; 25 | options?: ConfettiOptions & 26 | ConfettiGlobalOptions & { canvas?: HTMLCanvasElement }; 27 | } 28 | 29 | export function DayWithToolTip({ 30 | date, 31 | markDate, 32 | options, 33 | }: DayWithToolTipProps) { 34 | const { 35 | habitData: { isArchived, color, dates, frequency }, 36 | } = useHabit(); 37 | const formatedDate = normalizeDate(date); 38 | const isFuture = dayjs(date).isAfter(new Date()); 39 | const isActive = frequency[dayjs(date).day()]; 40 | const confettiEnabled = useSettingsStore((state) => state.confettiEnabled); 41 | 42 | const tooltipContent = isArchived ? ( 43 | `${dayjs(formatedDate).format("ll")} • archived` 44 | ) : ( 45 |

46 | {dayjs(formatedDate).format("ll")} 47 | {isActive ? ( 48 | <> 49 | {dates[formatedDate] !== undefined 50 | ? ` • ${ 51 | dates[formatedDate] === Status.Skipped ? "skipped" : "completed" 52 | }` 53 | : ""} 54 | 55 | ) : ( 56 | " • not tracked" 57 | )} 58 |

59 | ); 60 | 61 | const { dayCompletedColor, daySkippedColor } = normalizeColor(color); 62 | const backgroundColor = !isActive 63 | ? "rgba(0, 0, 0, 0.1)" 64 | : dates[formatedDate] !== undefined 65 | ? dates[formatedDate] === Status.Completed 66 | ? dayCompletedColor 67 | : daySkippedColor 68 | : ""; 69 | 70 | function handleDayClick(event: MouseEvent) { 71 | if (isFuture) return; 72 | if (dates[formatedDate] === undefined && confettiEnabled) { 73 | playConfetti(event, options); 74 | } 75 | markDate(formatedDate); 76 | } 77 | return ( 78 | 79 | 80 | 81 | 30 | 38 |
39 |
40 | ); 41 | } 42 | 43 | function MonthlyCalendar() { 44 | const { daysInMonth, startOffset, currentMonth, currentYear } = 45 | useMonthContext(); 46 | const markHabit = useHabitsStore((state) => state.markHabit); 47 | const { habitData } = useHabit(); 48 | 49 | return ( 50 | <> 51 |
52 | {DAYS.map((day) => ( 53 |
54 | {day.substring(0, 2)} 55 |
56 | ))} 57 | {Array.from({ length: startOffset }, (_, i) => ( 58 | // biome-ignore lint/suspicious/noArrayIndexKey: 59 |
60 | ))} 61 | {Array.from({ length: daysInMonth }, (_, i) => { 62 | const date: Dayjs = dayjs( 63 | `${currentMonth + 1}-${i + 1}-${currentYear}`, 64 | ); 65 | return ( 66 | // biome-ignore lint/suspicious/noArrayIndexKey: 67 | 68 | { 71 | markHabit({ id: habitData.id, date: normalizeDate(date) }); 72 | }} 73 | /> 74 | 75 | ); 76 | })} 77 |
78 | 79 | ); 80 | } 81 | 82 | export function MonthlyView() { 83 | return ( 84 | 85 |
86 |
87 | 88 |
89 | 90 |
91 |
92 | ); 93 | } 94 | 95 | export function HabitStats() { 96 | const { 97 | habitData: { dates }, 98 | } = useHabit(); 99 | 100 | if (Object.keys(dates).length === 0) { 101 | return ( 102 |
103 | 104 |

No Stats Available

105 |

Start tracking your habits to see your progress!

106 |
107 | ); 108 | } 109 | 110 | return ( 111 | <> 112 |
113 |

114 | OVERVIEW 115 |

116 | 117 |
118 |
119 |

120 | WEEKLY ACTIVITY 121 |

122 | 123 |
124 | {/*
125 |

126 | Streaks 127 |

128 | 129 |
*/} 130 | 131 | ); 132 | } 133 | -------------------------------------------------------------------------------- /apps/offline-nextjs/src/components/ui/form.tsx: -------------------------------------------------------------------------------- 1 | import type * as LabelPrimitive from "@radix-ui/react-label"; 2 | import { Slot } from "@radix-ui/react-slot"; 3 | import * as React from "react"; 4 | import { 5 | Controller, 6 | type ControllerProps, 7 | type FieldPath, 8 | type FieldValues, 9 | FormProvider, 10 | useFormContext, 11 | } from "react-hook-form"; 12 | 13 | import { Label } from "@/components/ui/label"; 14 | import { cn } from "@/lib/utils"; 15 | 16 | const Form = FormProvider; 17 | 18 | type FormFieldContextValue< 19 | TFieldValues extends FieldValues = FieldValues, 20 | TName extends FieldPath = FieldPath, 21 | > = { 22 | name: TName; 23 | }; 24 | 25 | const FormFieldContext = React.createContext( 26 | {} as FormFieldContextValue, 27 | ); 28 | 29 | const FormField = < 30 | TFieldValues extends FieldValues = FieldValues, 31 | TName extends FieldPath = FieldPath, 32 | >({ 33 | ...props 34 | }: ControllerProps) => { 35 | return ( 36 | 37 | 38 | 39 | ); 40 | }; 41 | 42 | const useFormField = () => { 43 | const fieldContext = React.useContext(FormFieldContext); 44 | const itemContext = React.useContext(FormItemContext); 45 | const { getFieldState, formState } = useFormContext(); 46 | 47 | const fieldState = getFieldState(fieldContext.name, formState); 48 | 49 | if (!fieldContext) { 50 | throw new Error("useFormField should be used within "); 51 | } 52 | 53 | const { id } = itemContext; 54 | 55 | return { 56 | id, 57 | name: fieldContext.name, 58 | formItemId: `${id}-form-item`, 59 | formDescriptionId: `${id}-form-item-description`, 60 | formMessageId: `${id}-form-item-message`, 61 | ...fieldState, 62 | }; 63 | }; 64 | 65 | type FormItemContextValue = { 66 | id: string; 67 | }; 68 | 69 | const FormItemContext = React.createContext( 70 | {} as FormItemContextValue, 71 | ); 72 | 73 | const FormItem = React.forwardRef< 74 | HTMLDivElement, 75 | React.HTMLAttributes 76 | >(({ className, ...props }, ref) => { 77 | const id = React.useId(); 78 | 79 | return ( 80 | 81 |
82 | 83 | ); 84 | }); 85 | FormItem.displayName = "FormItem"; 86 | 87 | const FormLabel = React.forwardRef< 88 | React.ElementRef, 89 | React.ComponentPropsWithoutRef 90 | >(({ className, ...props }, ref) => { 91 | const { error, formItemId } = useFormField(); 92 | 93 | return ( 94 |