├── .gitignore
├── .npmrc
├── .vscode
└── settings.json
├── README.md
├── TODO.md
├── apps
└── offline-nextjs
│ ├── .eslintrc.json
│ ├── .gitignore
│ ├── CONTRIBUTING.md
│ ├── README.md
│ ├── components.json
│ ├── next.config.ts
│ ├── package.json
│ ├── postcss.config.mjs
│ ├── public
│ ├── file.svg
│ ├── globe.svg
│ ├── next.svg
│ ├── vercel.svg
│ └── window.svg
│ ├── src
│ ├── app
│ │ ├── add
│ │ │ └── page.tsx
│ │ ├── archive
│ │ │ └── page.tsx
│ │ ├── edit
│ │ │ └── [id]
│ │ │ │ └── page.tsx
│ │ ├── favicon.ico
│ │ ├── globals.css
│ │ ├── habit
│ │ │ └── [id]
│ │ │ │ └── page.tsx
│ │ ├── layout.tsx
│ │ ├── page.tsx
│ │ └── settings
│ │ │ └── page.tsx
│ ├── components
│ │ ├── calendar
│ │ │ ├── day.tsx
│ │ │ ├── monthly
│ │ │ │ ├── charts.tsx
│ │ │ │ ├── overview.tsx
│ │ │ │ ├── streaks.tsx
│ │ │ │ └── view.tsx
│ │ │ └── weekly
│ │ │ │ ├── sortable-list.tsx
│ │ │ │ └── view.tsx
│ │ ├── color-picker.tsx
│ │ ├── habit-form.tsx
│ │ ├── misc.tsx
│ │ └── ui
│ │ │ ├── button.tsx
│ │ │ ├── card.tsx
│ │ │ ├── chart.tsx
│ │ │ ├── drawer.tsx
│ │ │ ├── dropdown-menu.tsx
│ │ │ ├── dropdrawer.tsx
│ │ │ ├── form.tsx
│ │ │ ├── input.tsx
│ │ │ ├── label.tsx
│ │ │ ├── popover.tsx
│ │ │ ├── radio-group.tsx
│ │ │ ├── switch.tsx
│ │ │ ├── toggle-group.tsx
│ │ │ ├── toggle.tsx
│ │ │ └── tooltip.tsx
│ ├── constants.ts
│ ├── fonts
│ │ ├── Excalifont-Regular.woff2
│ │ ├── GeistMonoVF.woff
│ │ ├── GeistVF.woff
│ │ ├── Mathlete-Bulky.otf
│ │ ├── Neucha-Regular.ttf
│ │ ├── Pacifico.woff2
│ │ └── fonts.ts
│ ├── hooks
│ │ ├── use-media-query.ts
│ │ └── use-mobile.tsx
│ ├── lib
│ │ ├── completion-rate
│ │ │ ├── index.test.ts
│ │ │ └── index.ts
│ │ ├── day.ts
│ │ ├── streak-ranges
│ │ │ ├── index.test.ts
│ │ │ └── index.ts
│ │ ├── streaks
│ │ │ ├── index.test.ts
│ │ │ └── index.ts
│ │ └── utils.ts
│ ├── providers
│ │ ├── habit-provider.tsx
│ │ ├── monthly-navigation.tsx
│ │ ├── post-hog.tsx
│ │ └── weekly-navigation.tsx
│ ├── state.ts
│ ├── state
│ │ └── settings.ts
│ └── types.ts
│ ├── tailwind.config.ts
│ ├── tsconfig.json
│ └── vitest.config.ts
├── biome.json
├── package.json
├── packages
├── typescript-config
│ ├── base.json
│ ├── nextjs.json
│ ├── package.json
│ └── react-library.json
└── ui
│ ├── package.json
│ ├── src
│ ├── button.tsx
│ ├── card.tsx
│ └── code.tsx
│ ├── tsconfig.json
│ ├── tsconfig.lint.json
│ └── turbo
│ └── generators
│ ├── config.ts
│ └── templates
│ └── component.hbs
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
├── redoit.gif
└── turbo.json
/.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 |
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lmssiehdev/redoit/09871a50208486d9aad53a95a55dc5e3b781f56b/.npmrc
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "eslint.workingDirectories": [
3 | {
4 | "mode": "auto"
5 | }
6 | ],
7 | "editor.selectionClipboard": false
8 | }
9 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # redoit! a cute and minimal habit tracker
2 |
3 | 
4 |
5 | try it at : [https:redoit.app](https:redoit.app)
6 |
--------------------------------------------------------------------------------
/TODO.md:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lmssiehdev/redoit/09871a50208486d9aad53a95a55dc5e3b781f56b/TODO.md
--------------------------------------------------------------------------------
/apps/offline-nextjs/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["next/core-web-vitals", "next/typescript"]
3 | }
4 |
--------------------------------------------------------------------------------
/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/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/README.md:
--------------------------------------------------------------------------------
1 | # redoit! a cute and minimal habit tracker
2 |
3 | 
4 |
5 | try it at : [https:redoit.app](https:redoit.app)
6 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/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 |
--------------------------------------------------------------------------------
/apps/offline-nextjs/public/file.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/apps/offline-nextjs/public/globe.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/apps/offline-nextjs/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/apps/offline-nextjs/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/apps/offline-nextjs/public/window.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/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/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/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/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lmssiehdev/redoit/09871a50208486d9aad53a95a55dc5e3b781f56b/apps/offline-nextjs/src/app/favicon.ico
--------------------------------------------------------------------------------
/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/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/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/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 |
--------------------------------------------------------------------------------
/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/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/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/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/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/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/calendar/weekly/sortable-list.tsx:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lmssiehdev/redoit/09871a50208486d9aad53a95a55dc5e3b781f56b/apps/offline-nextjs/src/components/calendar/weekly/sortable-list.tsx
--------------------------------------------------------------------------------
/apps/offline-nextjs/src/components/calendar/weekly/view.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { DayWithToolTip } from "@/components/calendar/day";
4 | import { Button, buttonVariants } from "@/components/ui/button";
5 | import { DAYS } from "@/constants";
6 | import { HabitProvider } from "@/providers/habit-provider";
7 | import { useHabitsStore } from "@/state";
8 | import {
9 | Archive,
10 | CaretLeft,
11 | CaretRight,
12 | DotsSixVertical,
13 | DotsThreeVertical,
14 | Plus,
15 | StackPlus,
16 | } from "@phosphor-icons/react";
17 | import dayjs from "dayjs";
18 | import { formatDateRange } from "little-date";
19 | import { useEffect } from "react";
20 |
21 | import { HabitColor, RepeatedHeader } from "@/components/misc";
22 | import {
23 | DropDrawer,
24 | DropDrawerContent,
25 | DropDrawerItem,
26 | DropDrawerSeparator,
27 | DropDrawerTrigger,
28 | } from "@/components/ui/dropdrawer";
29 | import { useMediaQuery } from "@/hooks/use-media-query";
30 | import { useIsMobile } from "@/hooks/use-mobile";
31 | import { cn } from "@/lib/utils";
32 | import {
33 | WeeklyDateProvider,
34 | useWeeklyDate,
35 | } from "@/providers/weekly-navigation";
36 | import { Status } from "@/types";
37 | import {
38 | DndContext,
39 | type DragEndEvent,
40 | KeyboardSensor,
41 | PointerSensor,
42 | closestCenter,
43 | useSensor,
44 | useSensors,
45 | } from "@dnd-kit/core";
46 | import {
47 | restrictToVerticalAxis,
48 | restrictToWindowEdges,
49 | } from "@dnd-kit/modifiers";
50 | import {
51 | type AnimateLayoutChanges,
52 | SortableContext,
53 | defaultAnimateLayoutChanges,
54 | sortableKeyboardCoordinates,
55 | useSortable,
56 | verticalListSortingStrategy,
57 | } from "@dnd-kit/sortable";
58 | import { CSS } from "@dnd-kit/utilities";
59 | import Link from "next/link";
60 | import { usePathname } from "next/navigation";
61 |
62 | function WeeklyNavigation() {
63 | const { NEXT_DAY, PREV_DAY, dateArray } = useWeeklyDate();
64 |
65 | return (
66 |
67 |
75 |
76 | {dateArray.map((dateString) => {
77 | const date = new Date(dateString);
78 | return (
79 |
83 |
84 | {DAYS[date.getDay()].substring(0, 2)}
85 | {date.getDate()}
86 |
87 |
88 | );
89 | })}
90 |
91 |
92 | {formatDateRange(
93 | dayjs(dateArray[0]).startOf("day").toDate(),
94 | dayjs(dateArray[dateArray.length - 1])
95 | .startOf("day")
96 | .toDate(),
97 | {
98 | includeTime: false,
99 | },
100 | )}
101 |
102 |
105 |
106 | );
107 | }
108 |
109 | export function SortableHabitRow({ habitId }: { habitId: string }) {
110 | const { dateArray } = useWeeklyDate();
111 | const markHabit = useHabitsStore((state) => state.markHabit);
112 | const deleteHabit = useHabitsStore((state) => state.deleteHabit);
113 | const archiveHabit = useHabitsStore((state) => state.archiveHabit);
114 | const { color, name, isArchived } = useHabitsStore(
115 | (state) => state.data[habitId],
116 | );
117 | const { isMobile } = useMediaQuery();
118 | const animateLayoutChanges: AnimateLayoutChanges = (args) =>
119 | defaultAnimateLayoutChanges({ ...args, wasDragging: true });
120 |
121 | const {
122 | attributes,
123 | listeners,
124 | setNodeRef,
125 | transform,
126 | transition,
127 | setActivatorNodeRef,
128 | isDragging,
129 | } = useSortable({ id: habitId, animateLayoutChanges });
130 |
131 | const style = {
132 | transform: CSS.Transform.toString(transform),
133 | transition,
134 | };
135 |
136 | useEffect(() => {
137 | if (!isDragging) return;
138 | document.body.style.cursor = "grabbing";
139 |
140 | return () => {
141 | document.body.style.cursor = "";
142 | };
143 | }, [isDragging]);
144 |
145 | const Action = (
146 | archiveHabit(habitId)}
149 | deleteFn={() => deleteHabit(habitId)}
150 | isArchived={isArchived}
151 | />
152 | );
153 |
154 | return (
155 | <>
156 |
164 |
165 |
166 | {!isArchived && (
167 |
176 | )}
177 |
178 |
182 | {name}
183 |
184 | {isMobile &&
{Action}
}
185 |
186 |
187 |
188 | {dateArray.map((date) => (
189 | markHabit({ id: habitId, date })}
193 | />
194 | ))}
195 |
196 | {!isMobile && Action}
197 |
198 |
199 |
200 | >
201 | );
202 | }
203 |
204 | export function HabitRow({ archived = false }) {
205 | const habitData = useHabitsStore((state) => state.data);
206 | const orderedHabits = useHabitsStore((state) => state.orderedData);
207 | const reorderHabit = useHabitsStore((state) => state.reorderHabit);
208 | const sensors = useSensors(
209 | useSensor(PointerSensor),
210 | useSensor(KeyboardSensor, {
211 | coordinateGetter: sortableKeyboardCoordinates,
212 | }),
213 | );
214 |
215 | const habitsToRender = orderedHabits.filter(
216 | (habitId) => habitData[habitId].isArchived === archived,
217 | );
218 |
219 | return (
220 | <>
221 |
232 |
236 | {habitsToRender.map((id) => (
237 |
238 | ))}
239 |
240 |
241 | >
242 | );
243 | function handleDragEnd(event: DragEndEvent) {
244 | const { active, over } = event;
245 | const activeId = active.id as string;
246 | const overId = over?.id as string;
247 |
248 | if (activeId !== overId) {
249 | reorderHabit({ activeId, overId });
250 | }
251 | }
252 | }
253 |
254 | export function HabitRowAction({
255 | habitId,
256 | archiveHabit,
257 | deleteFn,
258 | isArchived,
259 | }: {
260 | habitId: string;
261 | archiveHabit: () => void;
262 | deleteFn: () => void;
263 | isArchived: boolean;
264 | }) {
265 | const isMobile = useIsMobile();
266 | return (
267 |
268 |
269 |
277 |
278 |
279 |
280 | Edit
281 |
282 |
283 |
284 | {isArchived ? "Unarchive" : "Archive"}
285 |
286 |
287 | Delete
288 |
289 |
290 |
291 | );
292 | }
293 |
294 | function StreakRow() {
295 | const { dateArray } = useWeeklyDate();
296 | const data = useHabitsStore((state) => state.data);
297 |
298 | function caluculateDateStreak(date: string) {
299 | let count = 0;
300 | for (const { dates } of Object.values(data)) {
301 | if (dates[date] === Status.Completed) {
302 | count++;
303 | }
304 | }
305 | return count;
306 | }
307 |
308 | return (
309 |
310 |
317 |
318 | Add habit
319 |
320 |
321 | {dateArray.map((date) => (
322 |
{caluculateDateStreak(date)}
323 | ))}
324 |
325 |
326 |
327 | );
328 | }
329 |
330 | export function VerticalView() {
331 | const { isMobile } = useMediaQuery();
332 | const pathname = usePathname();
333 | const showArchived = pathname === "/archive";
334 |
335 | const emptyHabits = useHabitsStore(
336 | (state) =>
337 | Object.values(state.data).filter(
338 | ({ isArchived }) => isArchived === showArchived,
339 | ).length === 0,
340 | );
341 |
342 | if (emptyHabits)
343 | return (
344 |
345 | {showArchived ? (
346 | <>
347 |
348 |
349 | You don’t have any archived habits yet!
350 |
351 | >
352 | ) : (
353 | <>
354 |
355 |
Your habit list is empty!
356 |
363 |
364 | Add habit
365 |
366 | >
367 | )}
368 |
369 | );
370 |
371 | return (
372 | <>
373 |
374 |
375 | {showArchived ? (
376 |
377 |
378 |
379 |
380 | ) : (
381 |
382 | )}
383 |
384 |
385 |
386 |
387 |
388 | {!isMobile && }
389 |
390 | >
391 | );
392 | }
393 |
--------------------------------------------------------------------------------
/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/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/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/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/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/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 |