├── .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 | 
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 | 
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 |
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 |
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 |
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 |
92 |
93 |
94 | {tooltipContent}
95 |
96 |
97 | );
98 | }
99 |
100 | function playConfetti(
101 | event: React.MouseEvent,
102 | options?: ConfettiOptions & ConfettiGlobalOptions,
103 | ) {
104 | const rect = event.currentTarget.getBoundingClientRect();
105 | const x = rect.left + rect.width / 2;
106 | const y = rect.top + rect.height / 2;
107 | confetti({
108 | ...options,
109 | disableForReducedMotion: true,
110 | startVelocity: 30,
111 | particleCount: 40,
112 | origin: {
113 | x: x / window.innerWidth,
114 | y: y / window.innerHeight,
115 | },
116 | });
117 | }
118 |
--------------------------------------------------------------------------------
/apps/offline-nextjs/src/state.ts:
--------------------------------------------------------------------------------
1 | import { DEFAULT_HABIT_COLOR } from "@/constants";
2 | import { normalizeDate } from "@/lib/day";
3 | import { generateId } from "@/lib/utils";
4 | import { type HabitData, Status } from "@/types";
5 | import { arrayMove } from "@dnd-kit/sortable";
6 | import { create } from "zustand";
7 | import { persist } from "zustand/middleware";
8 | import { immer } from "zustand/middleware/immer";
9 |
10 | type State = {
11 | data: Record;
12 | orderedData: string[];
13 | };
14 |
15 | export type Actions = {
16 | addHabit: (payload: Pick) => void;
17 | updateHabitData: ({
18 | id,
19 | payload,
20 | }: {
21 | id: string;
22 | payload: Pick;
23 | }) => void;
24 | deleteHabit: (id: string) => void;
25 | markHabit: (payload: {
26 | id: string;
27 | date: string;
28 | status?: Status;
29 | }) => void;
30 | archiveHabit: (id: string, archived?: boolean) => void;
31 | reorderHabit: ({
32 | activeId,
33 | overId,
34 | }: { overId: string; activeId: string }) => void;
35 | };
36 |
37 | export const useHabitsStore = create()(
38 | immer(
39 | persist(
40 | (set) => ({
41 | data: {},
42 | orderedData: [] as string[],
43 | addHabit: (payload) => {
44 | const id = generateId(10);
45 | const newHabit: HabitData = {
46 | id,
47 | createdAt: normalizeDate(new Date()),
48 | isArchived: false,
49 | dates: {},
50 | // @ts-expect-error typescript being annoying
51 | color: DEFAULT_HABIT_COLOR,
52 | // @ts-expect-error typescript being annoying
53 | name: "DEFAULT_NAME",
54 | frequency: Array.from({ length: 7 }, () => true),
55 | ...payload,
56 | };
57 | return set((state) => {
58 | state.data[id] = newHabit;
59 | state.orderedData.push(newHabit.id);
60 | });
61 | },
62 | updateHabitData: ({ id, payload }) => {
63 | set((state) => {
64 | const habit = state.data[id];
65 | Object.assign(habit, payload);
66 | });
67 | },
68 | archiveHabit: (id, archived) => {
69 | set((state) => {
70 | if (archived !== undefined) {
71 | state.data[id].isArchived = archived;
72 | return;
73 | }
74 | state.data[id].isArchived = !state.data[id].isArchived;
75 | });
76 | },
77 | deleteHabit: (id) => {
78 | return set((state) => {
79 | delete state.data[id];
80 | state.orderedData = state.orderedData.filter(
81 | (habitId) => habitId !== id,
82 | );
83 | });
84 | },
85 | markHabit: ({ id, date }) => {
86 | set((state) => {
87 | const habit = state.data[id];
88 | const status = habit.dates[date];
89 |
90 | if (status === undefined) {
91 | habit.dates[date] = Status.Completed;
92 | return;
93 | }
94 |
95 | switch (status) {
96 | case Status.Completed: {
97 | habit.dates[date] = Status.Skipped;
98 | break;
99 | }
100 | case Status.Skipped: {
101 | delete habit.dates[date];
102 | break;
103 | }
104 | }
105 | });
106 | },
107 | reorderHabit: ({ activeId, overId }) =>
108 | set((state) => {
109 | const oldIndex = state.orderedData.indexOf(activeId);
110 | const newIndex = state.orderedData.indexOf(overId);
111 | state.orderedData = arrayMove(
112 | state.orderedData,
113 | oldIndex,
114 | newIndex,
115 | );
116 | }),
117 | }),
118 | {
119 | name: "habits",
120 | },
121 | ),
122 | ),
123 | );
124 |
--------------------------------------------------------------------------------
/apps/offline-nextjs/src/components/calendar/monthly/view.tsx:
--------------------------------------------------------------------------------
1 | import { DayWithToolTip } from "@/components/calendar/day";
2 | import { WeekChart } from "@/components/calendar/monthly/charts";
3 | import { Overview } from "@/components/calendar/monthly/overview";
4 | import { Button } from "@/components/ui/button";
5 | import { DAYS, MONTHS } from "@/constants";
6 | import { type Dayjs, dayjs, normalizeDate } from "@/lib/day";
7 | import { useHabit } from "@/providers/habit-provider";
8 | import { useMonthContext } from "@/providers/monthly-navigation";
9 | import { MonthDateProvider } from "@/providers/monthly-navigation";
10 | import { useHabitsStore } from "@/state";
11 | import { CaretLeft, CaretRight, ChartBar } from "@phosphor-icons/react";
12 | import { Fragment } from "react";
13 |
14 | function MonthlyNavigation() {
15 | const {
16 | currentMonth,
17 | currentYear,
18 | goToNextMonth,
19 | goToPrevMonth,
20 | isCurrentMonth,
21 | } = useMonthContext();
22 |
23 | return (
24 |
25 |
{`${MONTHS[currentMonth]} ${currentYear}`}
26 |
27 |
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 |
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 |
100 | );
101 | });
102 | FormLabel.displayName = "FormLabel";
103 |
104 | const FormControl = React.forwardRef<
105 | React.ElementRef,
106 | React.ComponentPropsWithoutRef
107 | >(({ ...props }, ref) => {
108 | const { error, formItemId, formDescriptionId, formMessageId } =
109 | useFormField();
110 |
111 | return (
112 |
123 | );
124 | });
125 | FormControl.displayName = "FormControl";
126 |
127 | const FormDescription = React.forwardRef<
128 | HTMLParagraphElement,
129 | React.HTMLAttributes
130 | >(({ className, ...props }, ref) => {
131 | const { formDescriptionId } = useFormField();
132 |
133 | return (
134 |
140 | );
141 | });
142 | FormDescription.displayName = "FormDescription";
143 |
144 | const FormMessage = React.forwardRef<
145 | HTMLParagraphElement,
146 | React.HTMLAttributes
147 | >(({ className, children, ...props }, ref) => {
148 | const { error, formMessageId } = useFormField();
149 | const body = error ? String(error?.message) : children;
150 |
151 | if (!body) {
152 | return null;
153 | }
154 |
155 | return (
156 |
162 | {body}
163 |
164 | );
165 | });
166 | FormMessage.displayName = "FormMessage";
167 |
168 | export {
169 | useFormField,
170 | Form,
171 | FormItem,
172 | FormLabel,
173 | FormControl,
174 | FormDescription,
175 | FormMessage,
176 | FormField,
177 | };
178 |
--------------------------------------------------------------------------------
/apps/offline-nextjs/src/components/calendar/monthly/overview.tsx:
--------------------------------------------------------------------------------
1 | import { percentage } from "@/lib/completion-rate";
2 | import { completionRate as completionRateRewrite } from "@/lib/completion-rate";
3 | import { differenceInDays, sortDates } from "@/lib/day";
4 | import { calculateStreaks } from "@/lib/streaks";
5 | import { LightenDarkenColor, convertHex, lightOrDark } from "@/lib/utils";
6 | import { useHabit } from "@/providers/habit-provider";
7 | import { useSettingsStore } from "@/state/settings";
8 | import { Status } from "@/types";
9 | import { CheckFat, Lightning, Percent } from "@phosphor-icons/react";
10 | import { CalendarBlank } from "@phosphor-icons/react/dist/ssr";
11 | import dayjs from "dayjs";
12 | import { useMemo } from "react";
13 |
14 | export function normalizeColor(color: string) {
15 | const isLightColor = lightOrDark(color) === "light";
16 | const opacity = isLightColor ? 0.8 : 0.4;
17 |
18 | const daySkippedColor = convertHex(
19 | LightenDarkenColor(color, isLightColor ? -50 : 60),
20 | opacity,
21 | );
22 | const dayCompletedColor = convertHex(color, isLightColor ? 0.8 : 0.6);
23 |
24 | const chartSkippedColor = convertHex(
25 | LightenDarkenColor(color, isLightColor ? -50 : 40),
26 | isLightColor ? 0.8 : 0.6,
27 | );
28 | return {
29 | daySkippedColor,
30 | dayCompletedColor,
31 | chartCompletedColor: dayCompletedColor,
32 | chartSkippedColor,
33 | };
34 | }
35 |
36 | export function Overview() {
37 | const {
38 | habitData: { dates, color, frequency },
39 | } = useHabit();
40 |
41 | const countSkippedDaysInStreak = useSettingsStore(
42 | (state) => state.countSkippedDaysInStreak,
43 | );
44 |
45 | const { currentStreak, longestStreak } = useMemo(
46 | () =>
47 | calculateStreaks({
48 | dates: Object.keys(dates).map((d) => dayjs(d).toDate()),
49 | shouldSkipDay: ({ tomorrow }) => {
50 | return !frequency[tomorrow.day()];
51 | },
52 | }),
53 | [dates, frequency],
54 | );
55 |
56 | const stats = useMemo(() => {
57 | const successfulDays = Object.values(dates).filter(
58 | (v) => countSkippedDaysInStreak || v === Status.Completed,
59 | ).length;
60 |
61 | const completionRate = () => {
62 | const sortedDates = sortDates(
63 | Object.keys(dates).map((d) => dayjs(d).toDate()),
64 | );
65 |
66 | const firstDate = sortedDates[0];
67 |
68 | const totalDays = Math.max(
69 | differenceInDays(new Date(), firstDate) + 1,
70 | 1,
71 | );
72 |
73 | return percentage(successfulDays, totalDays);
74 | };
75 |
76 | console.log(
77 | Object.entries(dates)
78 | .filter(
79 | ([_, value]) =>
80 | countSkippedDaysInStreak || value === Status.Completed,
81 | )
82 | .map(([key]) => key),
83 | frequency,
84 | );
85 |
86 | console.log({
87 | old: completionRate(),
88 | new: completionRateRewrite(
89 | Object.entries(dates)
90 | .filter(
91 | ([key, value]) =>
92 | countSkippedDaysInStreak || value === Status.Completed,
93 | )
94 | .map(([key]) => key),
95 | frequency,
96 | ),
97 | });
98 | return [
99 | {
100 | name: "Current Streak",
101 | value: currentStreak,
102 | Icon: Lightning,
103 | },
104 | {
105 | name: "Longest Streak",
106 | value: longestStreak,
107 | Icon: CalendarBlank,
108 | },
109 | {
110 | name: "Successful",
111 | value: successfulDays,
112 | Icon: CheckFat,
113 | },
114 | {
115 | name: "Completion Rate",
116 | value: completionRate(),
117 | Icon: Percent,
118 | },
119 | ];
120 | }, [
121 | dates,
122 | currentStreak,
123 | longestStreak,
124 | frequency,
125 | countSkippedDaysInStreak,
126 | ]);
127 |
128 | const styles = useMemo(() => {
129 | const { daySkippedColor } = normalizeColor(color);
130 |
131 | return { color, backgroundColor: daySkippedColor };
132 | }, [color]);
133 |
134 | return (
135 | <>
136 |
137 | {stats.map((data) => {
138 | const { name, value, Icon } = data;
139 | return (
140 |
147 |
148 |
153 |
154 |
155 |
{name}
156 |
157 | {value}
158 |
159 |
160 |
161 |
162 | );
163 | })}
164 |
165 | >
166 | );
167 | }
168 |
--------------------------------------------------------------------------------
/apps/offline-nextjs/src/lib/streaks/index.test.ts:
--------------------------------------------------------------------------------
1 | import dayjs, { type Dayjs } from "dayjs";
2 | import { describe, expect, it } from "vitest";
3 | import { calculateStreaks } from "./index";
4 |
5 | describe("edge cases", () => {
6 | it("should return an empty array when no dates are provided", () => {
7 | const dates: Date[] = [];
8 | const result = calculateStreaks({ dates });
9 | expect(result.streaks).toEqual([]);
10 | expect(result.longestStreak).toEqual(0);
11 | expect(result.currentStreak).toEqual(0);
12 | });
13 | it("should work with sorted and continuous dates", () => {
14 | const dates = [
15 | new Date("2023-01-01"),
16 | new Date("2023-01-02"),
17 | new Date("2023-01-03"),
18 | new Date("2023-01-04"),
19 | new Date("2023-01-05"),
20 | new Date("2023-01-06"),
21 | new Date("2023-01-07"),
22 | new Date("2023-01-08"),
23 | new Date("2023-01-09"),
24 | new Date("2023-01-10"),
25 | ];
26 |
27 | const result = calculateStreaks({ dates });
28 | expect(result.streaks).toEqual([10]);
29 | expect(result.longestStreak).toEqual(10);
30 | expect(result.currentStreak).toEqual(0);
31 | });
32 | it("should work with unsorted dates", () => {
33 | const dates = [
34 | new Date("2023-01-04"),
35 | new Date("2023-01-02"),
36 | new Date("2023-01-01"),
37 | new Date("2023-01-05"),
38 | new Date("2023-01-03"),
39 | ];
40 | const result = calculateStreaks({ dates });
41 | expect(result.streaks).toEqual([5]);
42 | });
43 |
44 | it("should work with one date", () => {
45 | const dates = [new Date("2023-01-04")];
46 | const result = calculateStreaks({ dates });
47 | expect(result.streaks).toEqual([1]);
48 | });
49 |
50 | it("should work with gaps in between dates", () => {
51 | const dates = [
52 | new Date("2023-01-01"),
53 | new Date("2023-01-02"),
54 | new Date("2023-01-03"),
55 | // gap
56 | new Date("2023-01-05"),
57 | new Date("2023-01-06"),
58 | // gap
59 | new Date("2023-01-08"),
60 | new Date("2023-01-09"),
61 | ];
62 | const result = calculateStreaks({ dates });
63 | expect(result.streaks).toEqual([3, 2, 2]);
64 | });
65 |
66 | it("should work with custom skip day", () => {
67 | // a month array where 2 mondays are missing
68 | const dates = generateDates({
69 | count: 30,
70 | daysToNotInclude: (date) => date.day() === 1,
71 | });
72 |
73 | const result = calculateStreaks({
74 | dates,
75 | enableLog: true,
76 | shouldSkipDay: ({ tomorrow }) => {
77 | // we do not lose a new streak on mondays
78 | return dayjs(tomorrow).day() === 1;
79 | },
80 | });
81 | expect(result.streaks).toEqual([26]);
82 | });
83 |
84 | // TODO: think of more edge cases
85 | it("should work with multiple skipped days", () => {
86 | const freq = [true, true, false, false, true, true, true];
87 | const generatedDates = [
88 | ...generateDates({
89 | startDate: dayjs("2025-01-01"),
90 | count: 30,
91 | daysToNotInclude: (date) => !freq[date.day()],
92 | }),
93 | new Date("2025-04-01"),
94 | ...generateDates({
95 | startDate: dayjs("2025-03-01"),
96 | count: 15,
97 | daysToNotInclude: () => false,
98 | }),
99 | ];
100 |
101 | const result = calculateStreaks({
102 | dates: generatedDates,
103 | enableLog: true,
104 | shouldSkipDay: ({ tomorrow }) => {
105 | // we do not break streak on days that are in freq
106 | return !freq[tomorrow.day()];
107 | },
108 | });
109 | console.log({ result });
110 | expect(result.longestStreak).toEqual(21);
111 | expect(result.currentStreak).toEqual(0);
112 | expect(result.streaks).toEqual([21, 15, 1]);
113 | });
114 |
115 | it("returns correct currentStreak with today", () => {
116 | const today = dayjs().startOf("day");
117 | const result = calculateStreaks({
118 | dates: [
119 | today.toDate(),
120 | new Date("08/18/2024"),
121 | new Date("08/17/2024"),
122 | new Date("08/16/2024"),
123 | new Date("08/15/2024"),
124 | ],
125 | });
126 | expect(result.streaks).toEqual([4, 1]);
127 | expect(result.currentStreak).toEqual(1);
128 | expect(result.longestStreak).toEqual(4);
129 | });
130 | });
131 |
132 | /*
133 | helper function to generate random dates
134 | */
135 | function generateDates({
136 | count,
137 | daysToNotInclude,
138 | startDate = dayjs().startOf("day"),
139 | }: {
140 | count: number;
141 | daysToNotInclude: (date: Dayjs) => boolean;
142 | startDate?: Dayjs;
143 | }): Date[] {
144 | const days = Math.min(count, 1000);
145 | const dates: Date[] = [];
146 | for (let i = 0; i < days; i++) {
147 | const date = startDate.add(i, "day");
148 | if (daysToNotInclude(date)) continue;
149 | dates.push(date.toDate());
150 | }
151 | return dates;
152 |
153 | function getRandomDateThisYear() {
154 | const startOfYear = dayjs().startOf("year");
155 | const endOfYear = dayjs().endOf("year");
156 |
157 | const randomTime =
158 | startOfYear.valueOf() +
159 | Math.random() * (endOfYear.valueOf() - startOfYear.valueOf());
160 |
161 | return dayjs(randomTime);
162 | }
163 | }
164 |
--------------------------------------------------------------------------------
/apps/offline-nextjs/src/components/habit-form.tsx:
--------------------------------------------------------------------------------
1 | import { zodResolver } from "@hookform/resolvers/zod";
2 | import { type Control, useController, useForm } from "react-hook-form";
3 | import * as z from "zod";
4 |
5 | import { Button } from "@/components/ui/button";
6 | import {
7 | Form,
8 | FormControl,
9 | FormField,
10 | FormItem,
11 | FormLabel,
12 | FormMessage,
13 | } from "@/components/ui/form";
14 | import { Input } from "@/components/ui/input";
15 |
16 | import { ColorPicker } from "@/components/color-picker";
17 | import { Switch } from "@/components/ui/switch";
18 | import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
19 | import { DAYS, DEFAULT_HABIT_COLOR } from "@/constants";
20 | import type { HabitData } from "@/types";
21 | import { useMemo } from "react";
22 |
23 | const HABIT_NAME_MAX_LENGTH = 30;
24 |
25 | function frequencyBooleanToString(payload?: boolean[]) {
26 | if (!payload) return Array.from({ length: 7 }, (_, i) => String(i));
27 | const frequency: string[] = [];
28 | payload.forEach((isActive, i) => isActive && frequency.push(String(i)));
29 | return frequency;
30 | }
31 |
32 | function frequencyStringToBoolean(payload: string[]) {
33 | const frequency = Array.from({ length: 7 }, () => false);
34 | for (const idx of payload) {
35 | frequency[Number(idx)] = true;
36 | }
37 | return frequency;
38 | }
39 |
40 | const formSchema = z.object({
41 | name: z
42 | .string()
43 | .min(1, {
44 | message: "Please provide a valid name.",
45 | })
46 | .transform((val) => val.trim().slice(0, HABIT_NAME_MAX_LENGTH)),
47 | color: z.string(),
48 | archived: z.boolean(),
49 | frequency: z.string().array(),
50 | });
51 |
52 | export function HabitForm({
53 | onSubmit,
54 | habitData,
55 | }: {
56 | habitData?: Partial;
57 | onSubmit: (
58 | payload: Pick,
59 | ) => void;
60 | }) {
61 | const isEditing = useMemo(() => habitData?.name !== undefined, [habitData]);
62 | const form = useForm>({
63 | resolver: zodResolver(formSchema),
64 | defaultValues: {
65 | name: habitData?.name ?? "",
66 | color: habitData?.color ?? DEFAULT_HABIT_COLOR,
67 | archived: habitData?.isArchived ?? false,
68 | frequency: frequencyBooleanToString(habitData?.frequency),
69 | },
70 | });
71 |
72 | function handleFormSubmit(values: z.infer) {
73 | const { name, color, archived, frequency } = values;
74 |
75 | onSubmit({
76 | name,
77 | color,
78 | isArchived: archived,
79 | frequency: frequencyStringToBoolean(frequency),
80 | });
81 | // posthog.capture(isEditing ? "habit_update" : "habit_create");
82 | // form.setValue("name", "");
83 | }
84 |
85 | return (
86 |
157 |
158 | );
159 | }
160 |
161 | function FrequencyForm({
162 | control,
163 | }: {
164 | control: Control<
165 | {
166 | name: string;
167 | color: string;
168 | archived: boolean;
169 | frequency: string[];
170 | },
171 | // biome-ignore lint/suspicious/noExplicitAny:
172 | any
173 | >;
174 | }) {
175 | const {
176 | field: { value, onChange },
177 | } = useController({ control, name: "frequency" });
178 |
179 | const handleWeekDaysClick = () => {
180 | onChange(
181 | frequencyBooleanToString([false, true, true, true, true, true, false]),
182 | );
183 | };
184 |
185 | const handleEveryDayClick = () => {
186 | onChange(
187 | frequencyBooleanToString([true, true, true, true, true, true, true]),
188 | );
189 | };
190 |
191 | const isWeekDaysSelected =
192 | value.length === 5 && !value.includes("0") && !value.includes("6");
193 | const isEveryDaySelected = value.length === 7;
194 |
195 | return (
196 | (
200 |
201 | Frequency
202 |
203 |
204 | {
207 | if (value.length === 0) return;
208 | field.onChange(value);
209 | }}
210 | className="gap-0"
211 | value={field.value}
212 | >
213 | {DAYS.map((day, index) => (
214 |
220 | {day.substring(0, 3)}
221 |
222 | ))}
223 |
224 |
225 |
226 |
234 |
242 |
243 |
244 |
245 | )}
246 | />
247 | );
248 | }
249 |
--------------------------------------------------------------------------------
/apps/offline-nextjs/src/components/ui/dropdown-menu.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { cn } from "@/lib/utils";
4 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
5 | import {
6 | CheckIcon,
7 | ChevronRightIcon,
8 | DotFilledIcon,
9 | } from "@radix-ui/react-icons";
10 | import * as React from "react";
11 |
12 | const DropdownMenu = DropdownMenuPrimitive.Root;
13 |
14 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
15 |
16 | const DropdownMenuGroup = DropdownMenuPrimitive.Group;
17 |
18 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
19 |
20 | const DropdownMenuSub = DropdownMenuPrimitive.Sub;
21 |
22 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
23 |
24 | const DropdownMenuSubTrigger = React.forwardRef<
25 | React.ElementRef,
26 | React.ComponentPropsWithoutRef & {
27 | inset?: boolean;
28 | }
29 | >(({ className, inset, children, ...props }, ref) => (
30 |
39 | {children}
40 |
41 |
42 | ));
43 | DropdownMenuSubTrigger.displayName =
44 | DropdownMenuPrimitive.SubTrigger.displayName;
45 |
46 | const DropdownMenuSubContent = React.forwardRef<
47 | React.ElementRef,
48 | React.ComponentPropsWithoutRef
49 | >(({ className, ...props }, ref) => (
50 |
58 | ));
59 | DropdownMenuSubContent.displayName =
60 | DropdownMenuPrimitive.SubContent.displayName;
61 |
62 | const DropdownMenuContent = React.forwardRef<
63 | React.ElementRef,
64 | React.ComponentPropsWithoutRef
65 | >(({ className, sideOffset = 4, ...props }, ref) => (
66 |
67 |
77 |
78 | ));
79 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
80 |
81 | const DropdownMenuItem = React.forwardRef<
82 | React.ElementRef,
83 | React.ComponentPropsWithoutRef & {
84 | inset?: boolean;
85 | }
86 | >(({ className, inset, ...props }, ref) => (
87 | svg]:size-4 [&>svg]:shrink-0",
91 | inset && "pl-8",
92 | className,
93 | )}
94 | {...props}
95 | />
96 | ));
97 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
98 |
99 | const DropdownMenuCheckboxItem = React.forwardRef<
100 | React.ElementRef,
101 | React.ComponentPropsWithoutRef
102 | >(({ className, children, checked, ...props }, ref) => (
103 |
112 |
113 |
114 |
115 |
116 |
117 | {children}
118 |
119 | ));
120 | DropdownMenuCheckboxItem.displayName =
121 | DropdownMenuPrimitive.CheckboxItem.displayName;
122 |
123 | const DropdownMenuRadioItem = React.forwardRef<
124 | React.ElementRef,
125 | React.ComponentPropsWithoutRef
126 | >(({ className, children, ...props }, ref) => (
127 |
135 |
136 |
137 |
138 |
139 |
140 | {children}
141 |
142 | ));
143 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
144 |
145 | const DropdownMenuLabel = React.forwardRef<
146 | React.ElementRef,
147 | React.ComponentPropsWithoutRef & {
148 | inset?: boolean;
149 | }
150 | >(({ className, inset, ...props }, ref) => (
151 |
160 | ));
161 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
162 |
163 | const DropdownMenuSeparator = React.forwardRef<
164 | React.ElementRef,
165 | React.ComponentPropsWithoutRef
166 | >(({ className, ...props }, ref) => (
167 |
172 | ));
173 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
174 |
175 | const DropdownMenuShortcut = ({
176 | className,
177 | ...props
178 | }: React.HTMLAttributes) => {
179 | return (
180 |
184 | );
185 | };
186 | DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
187 |
188 | export {
189 | DropdownMenu,
190 | DropdownMenuTrigger,
191 | DropdownMenuContent,
192 | DropdownMenuItem,
193 | DropdownMenuCheckboxItem,
194 | DropdownMenuRadioItem,
195 | DropdownMenuLabel,
196 | DropdownMenuSeparator,
197 | DropdownMenuShortcut,
198 | DropdownMenuGroup,
199 | DropdownMenuPortal,
200 | DropdownMenuSub,
201 | DropdownMenuSubContent,
202 | DropdownMenuSubTrigger,
203 | DropdownMenuRadioGroup,
204 | };
205 |
--------------------------------------------------------------------------------
/apps/offline-nextjs/src/components/ui/chart.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as RechartsPrimitive from "recharts";
5 |
6 | import { cn } from "@/lib/utils";
7 |
8 | // Format: { THEME_NAME: CSS_SELECTOR }
9 | const THEMES = { light: "", dark: ".dark" } as const;
10 |
11 | export type ChartConfig = {
12 | [k in string]: {
13 | label?: React.ReactNode;
14 | icon?: React.ComponentType;
15 | } & (
16 | | { color?: string; theme?: never }
17 | | { color?: never; theme: Record }
18 | );
19 | };
20 |
21 | type ChartContextProps = {
22 | config: ChartConfig;
23 | };
24 |
25 | const ChartContext = React.createContext(null);
26 |
27 | function useChart() {
28 | const context = React.useContext(ChartContext);
29 |
30 | if (!context) {
31 | throw new Error("useChart must be used within a ");
32 | }
33 |
34 | return context;
35 | }
36 |
37 | const ChartContainer = React.forwardRef<
38 | HTMLDivElement,
39 | React.ComponentProps<"div"> & {
40 | config: ChartConfig;
41 | children: React.ComponentProps<
42 | typeof RechartsPrimitive.ResponsiveContainer
43 | >["children"];
44 | }
45 | >(({ id, className, children, config, ...props }, ref) => {
46 | const uniqueId = React.useId();
47 | const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
48 |
49 | return (
50 |
51 |
60 |
61 |
62 | {children}
63 |
64 |
65 |
66 | );
67 | });
68 | ChartContainer.displayName = "Chart";
69 |
70 | const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
71 | const colorConfig = Object.entries(config).filter(
72 | ([, config]) => config.theme || config.color,
73 | );
74 |
75 | if (!colorConfig.length) {
76 | return null;
77 | }
78 |
79 | return (
80 |