mirror of
https://github.com/nagaoo0/HabbitGrid.git
synced 2026-01-11 23:44:55 +00:00
Compare commits
32 Commits
syncedData
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 839252e3ef | |||
| a902062726 | |||
| e330346d86 | |||
|
|
b91f94a388 | ||
|
|
99a61b5112 | ||
|
|
87cbfa54f7 | ||
| 2c7d136a33 | |||
| 29bb669f60 | |||
| 85c8aea7d3 | |||
| 4ae00cac87 | |||
| b7388c2ccc | |||
| c89c667304 | |||
| 831edbef49 | |||
| 39f7bbd96f | |||
| 3dd34f4f17 | |||
| caf31bd391 | |||
| 158e3ae342 | |||
|
|
9a216a658a | ||
| 85db9f5efa | |||
| 08b04b8399 | |||
| 9675f42ffc | |||
| 4d82d4c4b7 | |||
| 76fcc64125 | |||
| 2b0d8a4a73 | |||
| 0c5e75f726 | |||
| 863e932ec2 | |||
| 29fdff55a3 | |||
| 95c6de37e9 | |||
| 8c1bd0426f | |||
| 0fe6d3b87a | |||
| cf3ab8ed3e | |||
| 08f4616c55 |
38
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
38
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
---
|
||||||
|
name: Bug report
|
||||||
|
about: Create a report to help us improve
|
||||||
|
title: ''
|
||||||
|
labels: ''
|
||||||
|
assignees: ''
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Describe the bug**
|
||||||
|
A clear and concise description of what the bug is.
|
||||||
|
|
||||||
|
**To Reproduce**
|
||||||
|
Steps to reproduce the behavior:
|
||||||
|
1. Go to '...'
|
||||||
|
2. Click on '....'
|
||||||
|
3. Scroll down to '....'
|
||||||
|
4. See error
|
||||||
|
|
||||||
|
**Expected behavior**
|
||||||
|
A clear and concise description of what you expected to happen.
|
||||||
|
|
||||||
|
**Screenshots**
|
||||||
|
If applicable, add screenshots to help explain your problem.
|
||||||
|
|
||||||
|
**Desktop (please complete the following information):**
|
||||||
|
- OS: [e.g. iOS]
|
||||||
|
- Browser [e.g. chrome, safari]
|
||||||
|
- Version [e.g. 22]
|
||||||
|
|
||||||
|
**Smartphone (please complete the following information):**
|
||||||
|
- Device: [e.g. iPhone6]
|
||||||
|
- OS: [e.g. iOS8.1]
|
||||||
|
- Browser [e.g. stock browser, safari]
|
||||||
|
- Version [e.g. 22]
|
||||||
|
|
||||||
|
**Additional context**
|
||||||
|
Add any other context about the problem here.
|
||||||
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
---
|
||||||
|
name: Feature request
|
||||||
|
about: Suggest an idea for this project
|
||||||
|
title: ''
|
||||||
|
labels: ''
|
||||||
|
assignees: ''
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Is your feature request related to a problem? Please describe.**
|
||||||
|
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||||
|
|
||||||
|
**Describe the solution you'd like**
|
||||||
|
A clear and concise description of what you want to happen.
|
||||||
|
|
||||||
|
**Describe alternatives you've considered**
|
||||||
|
A clear and concise description of any alternative solutions or features you've considered.
|
||||||
|
|
||||||
|
**Additional context**
|
||||||
|
Add any other context or screenshots about the feature request here.
|
||||||
99
.github/workflows/codeql.yml
vendored
Normal file
99
.github/workflows/codeql.yml
vendored
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
# For most projects, this workflow file will not need changing; you simply need
|
||||||
|
# to commit it to your repository.
|
||||||
|
#
|
||||||
|
# You may wish to alter this file to override the set of languages analyzed,
|
||||||
|
# or to provide custom queries or build logic.
|
||||||
|
#
|
||||||
|
# ******** NOTE ********
|
||||||
|
# We have attempted to detect the languages in your repository. Please check
|
||||||
|
# the `language` matrix defined below to confirm you have the correct set of
|
||||||
|
# supported CodeQL languages.
|
||||||
|
#
|
||||||
|
name: "CodeQL Advanced"
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ "main" ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ "main" ]
|
||||||
|
schedule:
|
||||||
|
- cron: '27 4 * * 6'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
analyze:
|
||||||
|
name: Analyze (${{ matrix.language }})
|
||||||
|
# Runner size impacts CodeQL analysis time. To learn more, please see:
|
||||||
|
# - https://gh.io/recommended-hardware-resources-for-running-codeql
|
||||||
|
# - https://gh.io/supported-runners-and-hardware-resources
|
||||||
|
# - https://gh.io/using-larger-runners (GitHub.com only)
|
||||||
|
# Consider using larger runners or machines with greater resources for possible analysis time improvements.
|
||||||
|
runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
|
||||||
|
permissions:
|
||||||
|
# required for all workflows
|
||||||
|
security-events: write
|
||||||
|
|
||||||
|
# required to fetch internal or private CodeQL packs
|
||||||
|
packages: read
|
||||||
|
|
||||||
|
# only required for workflows in private repositories
|
||||||
|
actions: read
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- language: javascript-typescript
|
||||||
|
build-mode: none
|
||||||
|
# CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'rust', 'swift'
|
||||||
|
# Use `c-cpp` to analyze code written in C, C++ or both
|
||||||
|
# Use 'java-kotlin' to analyze code written in Java, Kotlin or both
|
||||||
|
# Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both
|
||||||
|
# To learn more about changing the languages that are analyzed or customizing the build mode for your analysis,
|
||||||
|
# see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning.
|
||||||
|
# If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how
|
||||||
|
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# Add any setup steps before running the `github/codeql-action/init` action.
|
||||||
|
# This includes steps like installing compilers or runtimes (`actions/setup-node`
|
||||||
|
# or others). This is typically only required for manual builds.
|
||||||
|
# - name: Setup runtime (example)
|
||||||
|
# uses: actions/setup-example@v1
|
||||||
|
|
||||||
|
# Initializes the CodeQL tools for scanning.
|
||||||
|
- name: Initialize CodeQL
|
||||||
|
uses: github/codeql-action/init@v4
|
||||||
|
with:
|
||||||
|
languages: ${{ matrix.language }}
|
||||||
|
build-mode: ${{ matrix.build-mode }}
|
||||||
|
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||||
|
# By default, queries listed here will override any specified in a config file.
|
||||||
|
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||||
|
|
||||||
|
# For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
|
||||||
|
# queries: security-extended,security-and-quality
|
||||||
|
|
||||||
|
# If the analyze step fails for one of the languages you are analyzing with
|
||||||
|
# "We were unable to automatically build your code", modify the matrix above
|
||||||
|
# to set the build mode to "manual" for that language. Then modify this step
|
||||||
|
# to build your code.
|
||||||
|
# ℹ️ Command-line programs to run using the OS shell.
|
||||||
|
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||||
|
- name: Run manual build steps
|
||||||
|
if: matrix.build-mode == 'manual'
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
echo 'If you are using a "manual" build mode for one or more of the' \
|
||||||
|
'languages you are analyzing, replace this with the commands to build' \
|
||||||
|
'your code, for example:'
|
||||||
|
echo ' make bootstrap'
|
||||||
|
echo ' make release'
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
- name: Perform CodeQL Analysis
|
||||||
|
uses: github/codeql-action/analyze@v4
|
||||||
|
with:
|
||||||
|
category: "/language:${{matrix.language}}"
|
||||||
128
CODE_OF_CONDUCT.md
Normal file
128
CODE_OF_CONDUCT.md
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
# Contributor Covenant Code of Conduct
|
||||||
|
|
||||||
|
## Our Pledge
|
||||||
|
|
||||||
|
We as members, contributors, and leaders pledge to make participation in our
|
||||||
|
community a harassment-free experience for everyone, regardless of age, body
|
||||||
|
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||||
|
identity and expression, level of experience, education, socio-economic status,
|
||||||
|
nationality, personal appearance, race, religion, or sexual identity
|
||||||
|
and orientation.
|
||||||
|
|
||||||
|
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||||
|
diverse, inclusive, and healthy community.
|
||||||
|
|
||||||
|
## Our Standards
|
||||||
|
|
||||||
|
Examples of behavior that contributes to a positive environment for our
|
||||||
|
community include:
|
||||||
|
|
||||||
|
* Demonstrating empathy and kindness toward other people
|
||||||
|
* Being respectful of differing opinions, viewpoints, and experiences
|
||||||
|
* Giving and gracefully accepting constructive feedback
|
||||||
|
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||||
|
and learning from the experience
|
||||||
|
* Focusing on what is best not just for us as individuals, but for the
|
||||||
|
overall community
|
||||||
|
|
||||||
|
Examples of unacceptable behavior include:
|
||||||
|
|
||||||
|
* The use of sexualized language or imagery, and sexual attention or
|
||||||
|
advances of any kind
|
||||||
|
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||||
|
* Public or private harassment
|
||||||
|
* Publishing others' private information, such as a physical or email
|
||||||
|
address, without their explicit permission
|
||||||
|
* Other conduct which could reasonably be considered inappropriate in a
|
||||||
|
professional setting
|
||||||
|
|
||||||
|
## Enforcement Responsibilities
|
||||||
|
|
||||||
|
Community leaders are responsible for clarifying and enforcing our standards of
|
||||||
|
acceptable behavior and will take appropriate and fair corrective action in
|
||||||
|
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||||
|
or harmful.
|
||||||
|
|
||||||
|
Community leaders have the right and responsibility to remove, edit, or reject
|
||||||
|
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||||
|
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||||
|
decisions when appropriate.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This Code of Conduct applies within all community spaces, and also applies when
|
||||||
|
an individual is officially representing the community in public spaces.
|
||||||
|
Examples of representing our community include using an official e-mail address,
|
||||||
|
posting via an official social media account, or acting as an appointed
|
||||||
|
representative at an online or offline event.
|
||||||
|
|
||||||
|
## Enforcement
|
||||||
|
|
||||||
|
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||||
|
reported to the community leaders responsible for enforcement at
|
||||||
|
mixaciric000@gmail.com.
|
||||||
|
All complaints will be reviewed and investigated promptly and fairly.
|
||||||
|
|
||||||
|
All community leaders are obligated to respect the privacy and security of the
|
||||||
|
reporter of any incident.
|
||||||
|
|
||||||
|
## Enforcement Guidelines
|
||||||
|
|
||||||
|
Community leaders will follow these Community Impact Guidelines in determining
|
||||||
|
the consequences for any action they deem in violation of this Code of Conduct:
|
||||||
|
|
||||||
|
### 1. Correction
|
||||||
|
|
||||||
|
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||||
|
unprofessional or unwelcome in the community.
|
||||||
|
|
||||||
|
**Consequence**: A private, written warning from community leaders, providing
|
||||||
|
clarity around the nature of the violation and an explanation of why the
|
||||||
|
behavior was inappropriate. A public apology may be requested.
|
||||||
|
|
||||||
|
### 2. Warning
|
||||||
|
|
||||||
|
**Community Impact**: A violation through a single incident or series
|
||||||
|
of actions.
|
||||||
|
|
||||||
|
**Consequence**: A warning with consequences for continued behavior. No
|
||||||
|
interaction with the people involved, including unsolicited interaction with
|
||||||
|
those enforcing the Code of Conduct, for a specified period of time. This
|
||||||
|
includes avoiding interactions in community spaces as well as external channels
|
||||||
|
like social media. Violating these terms may lead to a temporary or
|
||||||
|
permanent ban.
|
||||||
|
|
||||||
|
### 3. Temporary Ban
|
||||||
|
|
||||||
|
**Community Impact**: A serious violation of community standards, including
|
||||||
|
sustained inappropriate behavior.
|
||||||
|
|
||||||
|
**Consequence**: A temporary ban from any sort of interaction or public
|
||||||
|
communication with the community for a specified period of time. No public or
|
||||||
|
private interaction with the people involved, including unsolicited interaction
|
||||||
|
with those enforcing the Code of Conduct, is allowed during this period.
|
||||||
|
Violating these terms may lead to a permanent ban.
|
||||||
|
|
||||||
|
### 4. Permanent Ban
|
||||||
|
|
||||||
|
**Community Impact**: Demonstrating a pattern of violation of community
|
||||||
|
standards, including sustained inappropriate behavior, harassment of an
|
||||||
|
individual, or aggression toward or disparagement of classes of individuals.
|
||||||
|
|
||||||
|
**Consequence**: A permanent ban from any sort of public interaction within
|
||||||
|
the community.
|
||||||
|
|
||||||
|
## Attribution
|
||||||
|
|
||||||
|
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||||
|
version 2.0, available at
|
||||||
|
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
||||||
|
|
||||||
|
Community Impact Guidelines were inspired by [Mozilla's code of conduct
|
||||||
|
enforcement ladder](https://github.com/mozilla/diversity).
|
||||||
|
|
||||||
|
[homepage]: https://www.contributor-covenant.org
|
||||||
|
|
||||||
|
For answers to common questions about this code of conduct, see the FAQ at
|
||||||
|
https://www.contributor-covenant.org/faq. Translations are available at
|
||||||
|
https://www.contributor-covenant.org/translations.
|
||||||
42
README.md
42
README.md
@@ -19,6 +19,7 @@ A modern, grid-based habit tracker app inspired by GitHub contribution graphs. T
|
|||||||
- [Self-hosted Mirror (Gitea)](https://git.mihajlociric.com/count0/HabitGrid)
|
- [Self-hosted Mirror (Gitea)](https://git.mihajlociric.com/count0/HabitGrid)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
- GitHub-style habit grid (calendar view)
|
- GitHub-style habit grid (calendar view)
|
||||||
- Streak tracking and personal bests
|
- Streak tracking and personal bests
|
||||||
@@ -26,10 +27,19 @@ A modern, grid-based habit tracker app inspired by GitHub contribution graphs. T
|
|||||||
- Dark mode and light mode
|
- Dark mode and light mode
|
||||||
- Data export/import (JSON backup)
|
- Data export/import (JSON backup)
|
||||||
- Responsive design (desktop & mobile)
|
- Responsive design (desktop & mobile)
|
||||||
|
- **Cross-device sync with Supabase (cloud save)**
|
||||||
|
- **Offline-first:** works fully without login, syncs local habits to cloud on login
|
||||||
- Built with React, Vite, Tailwind CSS, Radix UI, and Framer Motion
|
- Built with React, Vite, Tailwind CSS, Radix UI, and Framer Motion
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## How Sync Works
|
||||||
|
|
||||||
|
- By default, all habits are stored locally and work offline.
|
||||||
|
- When you log in (via the call to action button), your local habits are synced to Supabase and available on all devices.
|
||||||
|
- Any changes (including categories, order, completions) are automatically pushed to the cloud when logged in.
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
@@ -69,7 +79,29 @@ src/
|
|||||||
pages/
|
pages/
|
||||||
```
|
```
|
||||||
|
|
||||||
## Deployment Tip
|
|
||||||
|
## Cloud Sync Setup
|
||||||
|
|
||||||
|
To enable cross-device sync, you need a free [Supabase](https://supabase.com/) account:
|
||||||
|
|
||||||
|
1. Create a Supabase project and set up a `habits` table with the following schema:
|
||||||
|
```sql
|
||||||
|
create table habits (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
user_id uuid not null,
|
||||||
|
name text not null,
|
||||||
|
color text,
|
||||||
|
category text,
|
||||||
|
completions jsonb default '[]'::jsonb,
|
||||||
|
current_streak int default 0,
|
||||||
|
longest_streak int default 0,
|
||||||
|
sort_order int default 0,
|
||||||
|
created_at timestamptz default now(),
|
||||||
|
updated_at timestamptz default now()
|
||||||
|
);
|
||||||
|
```
|
||||||
|
2. Add your Supabase project URL and anon key to the app's environment/config.
|
||||||
|
3. Deploy as usual (see below).
|
||||||
|
|
||||||
You can easily deploy your own instance of HabitGrid using [Cloudflare Pages](https://pages.cloudflare.com/):
|
You can easily deploy your own instance of HabitGrid using [Cloudflare Pages](https://pages.cloudflare.com/):
|
||||||
|
|
||||||
@@ -86,9 +118,11 @@ You can easily deploy your own instance of HabitGrid using [Cloudflare Pages](ht
|
|||||||
```
|
```
|
||||||
5. Deploy and enjoy your own habit tracker online!
|
5. Deploy and enjoy your own habit tracker online!
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
MIT
|
## Offline-First Guarantee
|
||||||
|
|
||||||
|
- You can use HabitGrid without ever logging in everything works locally.
|
||||||
|
- If you decide to log in later, all your local habits (including categories and order) will be synced to the cloud and available on all devices.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -100,6 +134,4 @@ MIT
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*Built with ❤️ by [Mihajlo Ciric](https://mihajlociric.com/)*
|
*Built with ❤️ by [Mihajlo Ciric](https://mihajlociric.com/)*
|
||||||
|
|||||||
35
index.html
35
index.html
@@ -5,6 +5,41 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Habit Tracker App</title>
|
<title>Habit Tracker App</title>
|
||||||
<meta name="description" content="Track your habits and visualize your progress with HabitGrid." />
|
<meta name="description" content="Track your habits and visualize your progress with HabitGrid." />
|
||||||
|
<meta name="keywords" content="habit tracker, productivity, goals, progress, HabitGrid, daily habits, motivation" />
|
||||||
|
<meta name="author" content="Mihajlo Ciric" />
|
||||||
|
<meta name="theme-color" content="#2563eb" />
|
||||||
|
<!-- Open Graph Meta Tags -->
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "WebApplication",
|
||||||
|
"name": "HabitGrid",
|
||||||
|
"url": "https://myhabitgrid.com",
|
||||||
|
"description": "HabitGrid is a habit tracker app that helps users build and maintain daily habits using a GitHub-style contribution grid. Visualize your progress, build streaks, and stay motivated.",
|
||||||
|
"applicationCategory": "Productivity",
|
||||||
|
"operatingSystem": "All",
|
||||||
|
"creator": {
|
||||||
|
"@type": "Person",
|
||||||
|
"name": "Mihajlo Ciric"
|
||||||
|
},
|
||||||
|
"keywords": ["habit tracker", "productivity", "goals", "progress", "daily habits", "motivation", "HabitGrid"],
|
||||||
|
"offers": {
|
||||||
|
"@type": "Offer",
|
||||||
|
"price": "0",
|
||||||
|
"priceCurrency": "USD"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<meta property="og:title" content="Habit Tracker App" />
|
||||||
|
<meta property="og:description" content="Track your habits and visualize your progress with HabitGrid." />
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:url" content="https://myhabitgrid.com" />
|
||||||
|
<meta property="og:image" content="/assets/fav.png" />
|
||||||
|
<!-- Twitter Card Meta Tags -->
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<meta name="twitter:title" content="Habit Tracker App" />
|
||||||
|
<meta name="twitter:description" content="Track your habits and visualize your progress with HabitGrid." />
|
||||||
|
<meta name="twitter:image" content="/assets/fav.png" />
|
||||||
<link rel="icon" type="image/png" href="/assets/fav.png" />
|
<link rel="icon" type="image/png" href="/assets/fav.png" />
|
||||||
<link rel="stylesheet" href="/src/index.css" />
|
<link rel="stylesheet" href="/src/index.css" />
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -1,102 +1,209 @@
|
|||||||
[
|
[
|
||||||
"Great job! Keep going!",
|
"You did it! 🎉",
|
||||||
"You're on fire! 🔥",
|
"High five! ✋",
|
||||||
"Consistency is key!",
|
"You’re awesome!",
|
||||||
"Amazing streak!",
|
"Keep rocking!",
|
||||||
"You crushed it today!",
|
"Level up! 🚀",
|
||||||
"Small steps, big results!",
|
"You’re a legend!",
|
||||||
"Habit hero!",
|
"That’s how it’s done!",
|
||||||
"Progress, not perfection!",
|
"You’re crushing it!",
|
||||||
"Every dot counts!",
|
"Go you!",
|
||||||
"Keep up the momentum!",
|
|
||||||
"You’re building something awesome!",
|
|
||||||
"One step closer to your goal!",
|
|
||||||
"You’re unstoppable!",
|
"You’re unstoppable!",
|
||||||
"Keep the streak alive!",
|
"Boom! Achievement unlocked!",
|
||||||
"You’re making it happen!",
|
"You’re a superstar!",
|
||||||
"Your effort is inspiring!",
|
"You make it look easy!",
|
||||||
"You’re a streak superstar!",
|
"You’re a force of nature!",
|
||||||
"Every day matters!",
|
"You’re a wizard! 🧙♂️",
|
||||||
"You’re a habit legend!",
|
"You’re a ninja! 🥷",
|
||||||
"You’re doing fantastic!",
|
"You’re a rockstar! 🎸",
|
||||||
"Keep shining!",
|
"You’re a champion! 🏆",
|
||||||
"You’re a role model!",
|
"You’re a hero! 🦸♂️",
|
||||||
"You’re a champion!",
|
"You’re a genius!",
|
||||||
"You’re making progress!",
|
"You’re a boss!",
|
||||||
|
"You’re a machine!",
|
||||||
|
"You’re a powerhouse!",
|
||||||
|
"You’re a trailblazer!",
|
||||||
|
"You’re a game changer!",
|
||||||
"You’re a winner!",
|
"You’re a winner!",
|
||||||
"You’re a streak master!",
|
"You’re a motivator!",
|
||||||
"You’re a habit machine!",
|
"You’re a creator!",
|
||||||
"You’re a streak builder!",
|
"You’re a dreamer!",
|
||||||
"You’re a streak star!",
|
"You’re a doer!",
|
||||||
"You’re a streak hero!",
|
"You’re a finisher!",
|
||||||
"You’re a streak ninja!",
|
"You’re a Firestarter!",
|
||||||
"You’re a streak wizard!",
|
"You’re a closer!",
|
||||||
"You’re a streak warrior!",
|
"You’re a believer!",
|
||||||
"You’re a streak explorer!",
|
"You’re a planner!",
|
||||||
"You’re a streak adventurer!",
|
"You’re an organizer!",
|
||||||
"You’re a streak conqueror!",
|
"You’re a strategist!",
|
||||||
"You’re a streak champion!",
|
"You’re a visionary!",
|
||||||
"You’re a streak genius!",
|
"You’re an optimist!",
|
||||||
"You’re a streak guru!",
|
"You’re an enthusiast!",
|
||||||
"You’re a streak expert!",
|
"You’re a player!",
|
||||||
"You’re a streak pro!",
|
"You’re a contender!",
|
||||||
"You’re a streak veteran!",
|
"You’re a competitor!",
|
||||||
"You’re a streak rookie!",
|
"You’re a challenger!",
|
||||||
"You’re a streak all-star!",
|
"You’re a victor!",
|
||||||
"You’re a streak MVP!",
|
"You’re a survivor!",
|
||||||
"You’re a streak superstar!",
|
"You’re a thriver!",
|
||||||
"You’re a streak rockstar!",
|
"You’re an overcomer!",
|
||||||
"You’re a streak dynamo!",
|
"You’re a high achiever!",
|
||||||
"You’re a streak powerhouse!",
|
"Not all those who wander are lost. —J.R.R. Tolkien",
|
||||||
"You’re a streak inspiration!",
|
"So it goes. —Kurt Vonnegut",
|
||||||
"You’re a streak motivator!",
|
"The only way out is through. —Robert Frost",
|
||||||
"You’re a streak leader!",
|
"You have brains in your head. You have feet in your shoes. You can steer yourself any direction you choose. —Dr. Seuss",
|
||||||
"You’re a streak innovator!",
|
"Courage is found in unlikely places. —J.R.R. Tolkien",
|
||||||
"You’re a streak creator!",
|
"Do or do not. There is no try. —Yoda",
|
||||||
"You’re a streak builder!",
|
"It’s dangerous to go alone! Take this. —The Legend of Zelda",
|
||||||
"You’re a streak achiever!",
|
"The game is afoot! —Sherlock Holmes",
|
||||||
"You’re a streak doer!",
|
"To the stars who listen and the dreams that are answered. —Sarah J. Maas",
|
||||||
"You’re a streak finisher!",
|
"All we have to decide is what to do with the time that is given us. —Gandalf",
|
||||||
"You’re a streak starter!",
|
"Even the smallest person can change the course of the future. —Galadriel",
|
||||||
"You’re a streak closer!",
|
"Winter passed and the world grew up. —C.S. Lewis",
|
||||||
"You’re a streak winner!",
|
"The secret of getting ahead is getting started. —Mark Twain",
|
||||||
"You’re a streak believer!",
|
"Not all heroes wear capes.",
|
||||||
"You’re a streak dreamer!",
|
"Achievement unlocked!",
|
||||||
"You’re a streak thinker!",
|
"You rolled a natural 20!",
|
||||||
"You’re a streak planner!",
|
"The odds are ever in your favor.",
|
||||||
"You’re a streak organizer!",
|
"You found the last Horcrux!",
|
||||||
"You’re a streak strategist!",
|
"You solved the puzzle before Watson!",
|
||||||
"You’re a streak tactician!",
|
"You’re the plot twist everyone needed.",
|
||||||
"You’re a streak visionary!",
|
"You’re the missing page in the story.",
|
||||||
"You’re a streak optimist!",
|
"You’re the last piece of the puzzle.",
|
||||||
"You’re a streak realist!",
|
"You’re the answer to the riddle.",
|
||||||
"You’re a streak enthusiast!",
|
"You’re the light at the end of the tunnel.",
|
||||||
"You’re a streak supporter!",
|
"You’re the hero of your own epic.",
|
||||||
"You’re a streak encourager!",
|
"You’re the author of your next chapter.",
|
||||||
"You’re a streak helper!",
|
"You’re the main character energy!",
|
||||||
"You’re a streak friend!",
|
"You’re the chosen one (but in a good way).",
|
||||||
"You’re a streak teammate!",
|
"You’re the unexpected ending everyone loves.",
|
||||||
"You’re a streak partner!",
|
"You’re the plot armor in a tough scene.",
|
||||||
"You’re a streak ally!",
|
"You’re the magic in the mundane.",
|
||||||
"You’re a streak companion!",
|
"You’re the hope in the dystopia.",
|
||||||
"You’re a streak buddy!",
|
"You’re the clever twist in the mystery.",
|
||||||
"You’re a streak pal!",
|
"You’re the spark in the revolution.",
|
||||||
"You’re a streak mate!",
|
"You’re the prophecy fulfilled.",
|
||||||
"You’re a streak peer!",
|
"You’re the legend in the making.",
|
||||||
"You’re a streak colleague!",
|
"You’re the muse for tomorrow’s story.",
|
||||||
"You’re a streak associate!",
|
"You’re the ink in the pen of progress.",
|
||||||
"You’re a streak collaborator!",
|
"You’re the page-turner in a slow chapter.",
|
||||||
"You’re a streak contributor!",
|
"You’re the secret passage in the labyrinth.",
|
||||||
"You’re a streak participant!",
|
"You’re the dragon’s gold at the end of the quest.",
|
||||||
"You’re a streak member!",
|
"You’re the ring-bearer on the journey.",
|
||||||
"You’re a streak player!",
|
"You’re the phoenix rising from the ashes.",
|
||||||
"You’re a streak contender!",
|
"You’re the sword in the stone.",
|
||||||
"You’re a streak competitor!",
|
"You’re the magic bean that grew the beanstalk.",
|
||||||
"You’re a streak challenger!",
|
"You’re the time traveler who fixed the timeline.",
|
||||||
"You’re a streak rival!",
|
"You’re the detective who cracked the case.",
|
||||||
"You’re a streak victor!",
|
"You’re the rebel with a cause.",
|
||||||
"You’re a streak survivor!",
|
"You’re the wizard who remembered the spell.",
|
||||||
"You’re a streak thriver!",
|
"You’re the hobbit who left the Shire.",
|
||||||
"You’re a streak overcomer!",
|
"You’re the poet who found the rhyme.",
|
||||||
"You’re a streak achiever!"
|
"You’re the knight who saved the day.",
|
||||||
|
"You’re the bard who inspired the crowd.",
|
||||||
|
"You’re the scientist who made the breakthrough.",
|
||||||
|
"You’re the explorer who found the map.",
|
||||||
|
"You’re the inventor of your own future.",
|
||||||
|
"You’re the captain of your own starship.",
|
||||||
|
"You’re the one who knocks (success).",
|
||||||
|
"You’re the last Jedi in the room.",
|
||||||
|
"You’re the answer to the ultimate question of life, the universe, and everything.",
|
||||||
|
"You’re the one who remembered the towel. —Hitchhiker’s Guide",
|
||||||
|
"You’re the spark that started the fire.",
|
||||||
|
"You’re the wind beneath the wings.",
|
||||||
|
"You’re the punchline to the cosmic joke.",
|
||||||
|
"You’re the plot device that saves the day.",
|
||||||
|
"You’re the deus ex machina of your own story.",
|
||||||
|
"You’re the twist ending everyone talks about.",
|
||||||
|
"You’re the secret ingredient in the recipe for success.",
|
||||||
|
"You’re the magic feather that lets Dumbo fly.",
|
||||||
|
"You’re the golden ticket in the chocolate bar.",
|
||||||
|
"You’re the red pill in the Matrix.",
|
||||||
|
"You’re the portal to the next adventure.",
|
||||||
|
"You’re the key to the locked door.",
|
||||||
|
"You’re the map to the hidden treasure.",
|
||||||
|
"You’re the light saber in the darkness.",
|
||||||
|
"You’re the spell that works every time.",
|
||||||
|
"You’re the last page in the book—and it’s a happy ending!",
|
||||||
|
"You’re the chosen one, Neo!",
|
||||||
|
"Live long and prosper! 🖖",
|
||||||
|
"To infinity and beyond!",
|
||||||
|
"You’re a wizard, Harry!",
|
||||||
|
"Winter is NOT coming—you’re winning!",
|
||||||
|
"You’ve got the power of Grayskull!",
|
||||||
|
"You’re the hero Gotham deserves!",
|
||||||
|
"Victory tastes sweeter than lembas bread.",
|
||||||
|
"The Sorting Hat would put you in Gryffindor today.",
|
||||||
|
"The cake is not a lie—success is real!",
|
||||||
|
"The odds are ever in your favor.",
|
||||||
|
"Achievement unlocked!",
|
||||||
|
"The Force is strong with this one.",
|
||||||
|
"Elementary, my dear Watson!",
|
||||||
|
"The journey is the reward.",
|
||||||
|
"Allons-y!",
|
||||||
|
"The adventure begins anew.",
|
||||||
|
"The stars look very different today.",
|
||||||
|
"The game is afoot!",
|
||||||
|
"The world is your oyster.",
|
||||||
|
"The pen is mightier—and you’re writing history.",
|
||||||
|
"The quest was perilous, but you prevailed.",
|
||||||
|
"The answer is 42.",
|
||||||
|
"The road goes ever on and on.",
|
||||||
|
"The magic is in the doing.",
|
||||||
|
"The universe just gave you a thumbs up.",
|
||||||
|
"The next chapter is looking epic.",
|
||||||
|
"The finish line is just the beginning.",
|
||||||
|
"The secret passage opened!",
|
||||||
|
"The plot thickens—in your favor.",
|
||||||
|
"The prophecy has been fulfilled.",
|
||||||
|
"The spell worked perfectly.",
|
||||||
|
"The treasure chest is open!",
|
||||||
|
"The portal to greatness is unlocked.",
|
||||||
|
"The time machine landed on success.",
|
||||||
|
"The dragon has been tamed.",
|
||||||
|
"The labyrinth has been solved.",
|
||||||
|
"The riddle is cracked.",
|
||||||
|
"The beacon is lit—Gondor calls for aid!",
|
||||||
|
"The ring is destroyed—peace returns to Middle-earth.",
|
||||||
|
"The Millennium Falcon made the jump to lightspeed.",
|
||||||
|
"The Bat-Signal is shining bright.",
|
||||||
|
"The TARDIS is ready for the next adventure.",
|
||||||
|
"The wand chose the wizard.",
|
||||||
|
"The shield held strong.",
|
||||||
|
"The sword gleams with victory.",
|
||||||
|
"The map led to treasure.",
|
||||||
|
"The spellbook is open to the right page.",
|
||||||
|
"The dice rolled in your favor.",
|
||||||
|
"The stars aligned for you.",
|
||||||
|
"The quest log is complete.",
|
||||||
|
"The story ends happily ever after.",
|
||||||
|
"The legend grows.",
|
||||||
|
"The adventure continues.",
|
||||||
|
"The world just got a little brighter.",
|
||||||
|
"The muse is smiling.",
|
||||||
|
"The universe applauds your effort.",
|
||||||
|
"The crowd goes wild!",
|
||||||
|
"The confetti is falling!",
|
||||||
|
"The fireworks are for you!",
|
||||||
|
"The applause is thunderous!",
|
||||||
|
"The curtain rises on your next act.",
|
||||||
|
"The credits roll—and you’re the star!",
|
||||||
|
"Foundation built, success inevitable. —Isaac Asimov",
|
||||||
|
"The robots would approve your logic. —Isaac Asimov",
|
||||||
|
"Hari Seldon predicted this win! —Isaac Asimov",
|
||||||
|
"You navigated the Sprawl like Case. —William Gibson",
|
||||||
|
"The future is now—cyberspace conquered. —William Gibson",
|
||||||
|
"You’re jacked in and winning. —William Gibson",
|
||||||
|
"The spice must flow—and so does your progress. —Frank Herbert",
|
||||||
|
"Fear is the mind-killer, but you crushed it. —Frank Herbert",
|
||||||
|
"You walked without rhythm and avoided the sandworm. —Frank Herbert",
|
||||||
|
"You claimed the Iron Throne of achievement. —George R.R. Martin",
|
||||||
|
"A mind needs books as a sword needs a whetstone. —George R.R. Martin",
|
||||||
|
"You survived the game of habits—winter is not coming! —George R.R. Martin",
|
||||||
|
"Chaos isn’t a pit, it’s a ladder—and you climbed it. —George R.R. Martin",
|
||||||
|
"Progress is coming. —George R.R. Martin",
|
||||||
|
"You bent the arc of history—psychohistory style. —Isaac Asimov",
|
||||||
|
"You hacked the matrix and rewrote the code. —William Gibson",
|
||||||
|
"You rode the worm to victory. —Frank Herbert",
|
||||||
|
"You played the game of thrones and won. —George R.R. Martin"
|
||||||
]
|
]
|
||||||
|
|||||||
3
public/robots.txt
Normal file
3
public/robots.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
|
Sitemap: https://myhabitgrid.com/sitemap.xml
|
||||||
15
public/sitemap.xml
Normal file
15
public/sitemap.xml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
|
<url>
|
||||||
|
<loc>https://myhabitgrid.com/</loc>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://myhabitgrid.com/add</loc>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://myhabitgrid.com/settings</loc>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://myhabitgrid.com/login-providers</loc>
|
||||||
|
</url>
|
||||||
|
</urlset>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useMemo, useEffect } from 'react';
|
import React, { useMemo, useEffect } from 'react';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { getColorIntensity, isToday, formatDate, getWeekdayLabel, getFrozenDays } from '../lib/utils-habit';
|
import { getColorIntensity, isToday, formatDate, getWeekdayLabel, getFrozenDays, calculateStreaks } from '../lib/utils-habit';
|
||||||
import { toggleCompletion } from '../lib/datastore';
|
import { toggleCompletion, getAuthUser } from '../lib/datastore';
|
||||||
|
|
||||||
const HabitGrid = ({ habit, onUpdate, fullView = false }) => {
|
const HabitGrid = ({ habit, onUpdate, fullView = false }) => {
|
||||||
const frozenDays = getFrozenDays(habit.completions);
|
const frozenDays = getFrozenDays(habit.completions);
|
||||||
@@ -38,20 +38,33 @@ const HabitGrid = ({ habit, onUpdate, fullView = false }) => {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleCellClick = (date) => {
|
const handleCellClick = async (date) => {
|
||||||
const dateStr = formatDate(date);
|
const dateStr = formatDate(date);
|
||||||
// Optimistic local update
|
const user = await getAuthUser();
|
||||||
const habits = JSON.parse(localStorage.getItem('habitgrid_data') || '[]');
|
if (user) {
|
||||||
const idx = habits.findIndex(h => h.id === habit.id);
|
// Optimistically update completions for instant UI
|
||||||
if (idx !== -1) {
|
const habits = JSON.parse(localStorage.getItem('habitgrid_data') || '[]');
|
||||||
const completions = Array.isArray(habits[idx].completions) ? [...habits[idx].completions] : [];
|
const idx = habits.findIndex(h => h.id === habit.id);
|
||||||
const cidx = completions.indexOf(dateStr);
|
if (idx !== -1) {
|
||||||
if (cidx > -1) completions.splice(cidx, 1); else completions.push(dateStr);
|
const completions = Array.isArray(habits[idx].completions) ? [...habits[idx].completions] : [];
|
||||||
habits[idx].completions = completions;
|
const cidx = completions.indexOf(dateStr);
|
||||||
localStorage.setItem('habitgrid_data', JSON.stringify(habits));
|
if (cidx > -1) completions.splice(cidx, 1); else completions.push(dateStr);
|
||||||
|
habits[idx].completions = completions;
|
||||||
|
// Recalculate streaks so counters reflect immediately
|
||||||
|
const { currentStreak, longestStreak } = calculateStreaks(completions);
|
||||||
|
const prevRecord = habits[idx].longestStreak || 0;
|
||||||
|
habits[idx].currentStreak = currentStreak;
|
||||||
|
habits[idx].longestStreak = Math.max(longestStreak, prevRecord);
|
||||||
|
localStorage.setItem('habitgrid_data', JSON.stringify(habits));
|
||||||
|
}
|
||||||
|
onUpdate();
|
||||||
|
// Sync in background
|
||||||
|
toggleCompletion(habit.id, dateStr);
|
||||||
|
} else {
|
||||||
|
// Local-only: just call toggleCompletion, then update UI
|
||||||
|
await toggleCompletion(habit.id, dateStr);
|
||||||
|
onUpdate();
|
||||||
}
|
}
|
||||||
toggleCompletion(habit.id, dateStr); // background sync
|
|
||||||
onUpdate();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -38,9 +38,9 @@ function getFreezeIcon() {
|
|||||||
return icon || '❄️';
|
return icon || '❄️';
|
||||||
}
|
}
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { getColorIntensity, isToday, formatDate } from '../lib/utils-habit';
|
import { getColorIntensity, isToday, formatDate, calculateStreaks } from '../lib/utils-habit';
|
||||||
import { getFrozenDays } from '../lib/utils-habit';
|
import { getFrozenDays } from '../lib/utils-habit';
|
||||||
import { toggleCompletion } from '../lib/datastore';
|
import { toggleCompletion, getAuthUser } from '../lib/datastore';
|
||||||
import { toast } from './ui/use-toast';
|
import { toast } from './ui/use-toast';
|
||||||
|
|
||||||
const MiniGrid = ({ habit, onUpdate }) => {
|
const MiniGrid = ({ habit, onUpdate }) => {
|
||||||
@@ -69,18 +69,31 @@ const MiniGrid = ({ habit, onUpdate }) => {
|
|||||||
const dateStr = formatDate(date);
|
const dateStr = formatDate(date);
|
||||||
const isTodayCell = isToday(date);
|
const isTodayCell = isToday(date);
|
||||||
const wasCompleted = habit.completions.includes(dateStr);
|
const wasCompleted = habit.completions.includes(dateStr);
|
||||||
// Optimistic local update
|
const user = await getAuthUser();
|
||||||
const habits = JSON.parse(localStorage.getItem('habitgrid_data') || '[]');
|
if (user) {
|
||||||
const idx = habits.findIndex(h => h.id === habit.id);
|
// Optimistically update completions for instant UI
|
||||||
if (idx !== -1) {
|
const habits = JSON.parse(localStorage.getItem('habitgrid_data') || '[]');
|
||||||
const completions = Array.isArray(habits[idx].completions) ? [...habits[idx].completions] : [];
|
const idx = habits.findIndex(h => h.id === habit.id);
|
||||||
const cidx = completions.indexOf(dateStr);
|
if (idx !== -1) {
|
||||||
if (cidx > -1) completions.splice(cidx, 1); else completions.push(dateStr);
|
const completions = Array.isArray(habits[idx].completions) ? [...habits[idx].completions] : [];
|
||||||
habits[idx].completions = completions;
|
const cidx = completions.indexOf(dateStr);
|
||||||
localStorage.setItem('habitgrid_data', JSON.stringify(habits));
|
if (cidx > -1) completions.splice(cidx, 1); else completions.push(dateStr);
|
||||||
|
habits[idx].completions = completions;
|
||||||
|
// Recalculate streaks locally so counters update immediately
|
||||||
|
const { currentStreak, longestStreak } = calculateStreaks(completions);
|
||||||
|
const prevRecord = habits[idx].longestStreak || 0;
|
||||||
|
habits[idx].currentStreak = currentStreak;
|
||||||
|
habits[idx].longestStreak = Math.max(longestStreak, prevRecord);
|
||||||
|
localStorage.setItem('habitgrid_data', JSON.stringify(habits));
|
||||||
|
}
|
||||||
|
onUpdate();
|
||||||
|
// Sync in background
|
||||||
|
toggleCompletion(habit.id, dateStr);
|
||||||
|
} else {
|
||||||
|
// Local-only: just call toggleCompletion, then update UI
|
||||||
|
await toggleCompletion(habit.id, dateStr);
|
||||||
|
onUpdate();
|
||||||
}
|
}
|
||||||
toggleCompletion(habit.id, dateStr); // background sync
|
|
||||||
onUpdate();
|
|
||||||
// Only show encouragement toast if validating (adding) today's dot
|
// Only show encouragement toast if validating (adding) today's dot
|
||||||
if (isTodayCell && !wasCompleted) {
|
if (isTodayCell && !wasCompleted) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
html, body {
|
||||||
|
background-color: #fff;
|
||||||
|
}
|
||||||
|
html.dark, body.dark {
|
||||||
|
background-color: #020617;
|
||||||
|
}
|
||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|||||||
@@ -1,4 +1,29 @@
|
|||||||
|
// UUID v4 generator (browser safe)
|
||||||
|
function generateUUID() {
|
||||||
|
if (window.crypto && window.crypto.randomUUID) return window.crypto.randomUUID();
|
||||||
|
// Fallback for older browsers
|
||||||
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
|
||||||
|
const r = Math.random() * 16 | 0, v = c === 'x' ? r : (r & 0x3 | 0x8);
|
||||||
|
return v.toString(16);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// UUID v4 validator
|
||||||
|
function isValidUUID(id) {
|
||||||
|
return /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure all habits in an array have valid UUIDs (returns new array)
|
||||||
|
function ensureUUIDs(habits) {
|
||||||
|
return habits.map(h => {
|
||||||
|
if (!h.id || !isValidUUID(h.id)) {
|
||||||
|
return { ...h, id: generateUUID() };
|
||||||
|
}
|
||||||
|
return h;
|
||||||
|
});
|
||||||
|
}
|
||||||
import { supabase, isSupabaseConfigured } from './supabase';
|
import { supabase, isSupabaseConfigured } from './supabase';
|
||||||
|
import { calculateStreaks } from './utils-habit';
|
||||||
import * as local from './storage';
|
import * as local from './storage';
|
||||||
|
|
||||||
const SYNC_FLAG = 'habitgrid_remote_synced_at';
|
const SYNC_FLAG = 'habitgrid_remote_synced_at';
|
||||||
@@ -6,7 +31,23 @@ const SYNC_FLAG = 'habitgrid_remote_synced_at';
|
|||||||
export const getAuthUser = async () => {
|
export const getAuthUser = async () => {
|
||||||
if (!isSupabaseConfigured()) return null;
|
if (!isSupabaseConfigured()) return null;
|
||||||
const { data } = await supabase.auth.getUser();
|
const { data } = await supabase.auth.getUser();
|
||||||
return data?.user ?? null;
|
const user = data?.user ?? null;
|
||||||
|
// Mark that the user has logged in at least once so we can prompt later if they're logged out
|
||||||
|
try {
|
||||||
|
if (user) localStorage.setItem('habitgrid_ever_logged_in', '1');
|
||||||
|
} catch (e) {
|
||||||
|
// ignore localStorage errors in restrictive environments
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper to check whether the user has ever logged in from this browser
|
||||||
|
export const hasEverLoggedIn = () => {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem('habitgrid_ever_logged_in') === '1';
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isLoggedIn = async () => Boolean(await getAuthUser());
|
export const isLoggedIn = async () => Boolean(await getAuthUser());
|
||||||
@@ -61,7 +102,10 @@ export async function saveHabit(habit) {
|
|||||||
if (!(await isLoggedIn())) return local.saveHabit(habit);
|
if (!(await isLoggedIn())) return local.saveHabit(habit);
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const { data: auth } = await supabase.auth.getUser();
|
const { data: auth } = await supabase.auth.getUser();
|
||||||
|
// Ensure UUID for new habit
|
||||||
|
const id = habit.id && isValidUUID(habit.id) ? habit.id : generateUUID();
|
||||||
const insert = {
|
const insert = {
|
||||||
|
id,
|
||||||
user_id: auth?.user?.id,
|
user_id: auth?.user?.id,
|
||||||
name: habit.name,
|
name: habit.name,
|
||||||
color: habit.color,
|
color: habit.color,
|
||||||
@@ -73,10 +117,14 @@ export async function saveHabit(habit) {
|
|||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
};
|
};
|
||||||
const { data, error } = await supabase.from('habits').insert(insert).select('*').single();
|
const { data, error } = await supabase
|
||||||
|
.from('habits')
|
||||||
|
.upsert(insert, { onConflict: 'id' })
|
||||||
|
.select('*')
|
||||||
|
.single();
|
||||||
if (error) {
|
if (error) {
|
||||||
console.warn('Supabase saveHabit error, writing local:', error.message);
|
console.warn('Supabase saveHabit error, writing local:', error.message);
|
||||||
return local.saveHabit(habit);
|
return local.saveHabit({ ...habit, id });
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
id: data.id,
|
id: data.id,
|
||||||
@@ -103,6 +151,8 @@ export async function updateHabit(id, updates) {
|
|||||||
console.warn('Supabase updateHabit error, writing local:', error.message);
|
console.warn('Supabase updateHabit error, writing local:', error.message);
|
||||||
return local.updateHabit(id, updates);
|
return local.updateHabit(id, updates);
|
||||||
}
|
}
|
||||||
|
// After any update, trigger a sync to ensure all local changes (including categories) are pushed to remote
|
||||||
|
await syncLocalToRemoteIfNeeded();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteHabit(id) {
|
export async function deleteHabit(id) {
|
||||||
@@ -123,18 +173,48 @@ export async function toggleCompletion(habitId, dateStr) {
|
|||||||
const completions = Array.isArray(target.completions) ? [...target.completions] : [];
|
const completions = Array.isArray(target.completions) ? [...target.completions] : [];
|
||||||
const idx = completions.indexOf(dateStr);
|
const idx = completions.indexOf(dateStr);
|
||||||
if (idx > -1) completions.splice(idx, 1); else completions.push(dateStr);
|
if (idx > -1) completions.splice(idx, 1); else completions.push(dateStr);
|
||||||
return updateHabit(habitId, { completions });
|
// Calculate streaks and preserve personal record (do not decrease longest)
|
||||||
|
const { currentStreak, longestStreak } = calculateStreaks(completions);
|
||||||
|
const nextLongest = Math.max(longestStreak, target.longestStreak || 0);
|
||||||
|
return updateHabit(habitId, { completions, currentStreak, longestStreak: nextLongest });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export async function exportData() {
|
export async function exportData() {
|
||||||
// Always export from local snapshot for portability
|
// Always export from local snapshot for portability
|
||||||
const habits = await getHabits();
|
let habits = JSON.parse(localStorage.getItem('habitgrid_data') || '[]');
|
||||||
|
habits = ensureUUIDs(habits);
|
||||||
|
// If logged in, merge with remote and upsert remote
|
||||||
|
if (await isLoggedIn()) {
|
||||||
|
const remote = await getHabits();
|
||||||
|
let merged = mergeHabits(habits, remote);
|
||||||
|
merged = ensureUUIDs(merged);
|
||||||
|
await supabase.from('habits').upsert(merged, { onConflict: 'id' });
|
||||||
|
return JSON.stringify(merged, null, 2);
|
||||||
|
}
|
||||||
return JSON.stringify(habits, null, 2);
|
return JSON.stringify(habits, null, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export async function importData(jsonString) {
|
export async function importData(jsonString) {
|
||||||
// Always import to local; remote sync will push on login
|
// Import to local
|
||||||
return local.importData(jsonString);
|
let imported = local.importData(jsonString);
|
||||||
|
// Always ensure UUIDs for imported data
|
||||||
|
let importedArr = Array.isArray(imported) ? imported : JSON.parse(jsonString);
|
||||||
|
importedArr = ensureUUIDs(importedArr);
|
||||||
|
// If logged in, merge with remote and upsert
|
||||||
|
if (await isLoggedIn()) {
|
||||||
|
const user = await getAuthUser();
|
||||||
|
const remote = await getHabits();
|
||||||
|
let merged = mergeHabits(importedArr, remote);
|
||||||
|
merged = ensureUUIDs(merged);
|
||||||
|
localStorage.setItem('habitgrid_data', JSON.stringify(merged));
|
||||||
|
await supabase.from('habits').upsert(merged, { onConflict: 'id' });
|
||||||
|
return merged;
|
||||||
|
} else {
|
||||||
|
localStorage.setItem('habitgrid_data', JSON.stringify(importedArr));
|
||||||
|
return importedArr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function clearAllData() {
|
export async function clearAllData() {
|
||||||
@@ -148,37 +228,85 @@ export async function syncLocalToRemoteIfNeeded() {
|
|||||||
const user = await getAuthUser();
|
const user = await getAuthUser();
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
|
|
||||||
const already = localStorage.getItem(SYNC_FLAG);
|
// Always upsert all local habits to Supabase after login
|
||||||
const { data: remote, error } = await supabase.from('habits').select('id').limit(1);
|
let habits = local.getHabits();
|
||||||
if (error) return;
|
if (habits.length === 0) return localStorage.setItem(SYNC_FLAG, new Date().toISOString());
|
||||||
|
habits = ensureUUIDs(habits);
|
||||||
if (!already || (remote || []).length === 0) {
|
localStorage.setItem('habitgrid_data', JSON.stringify(habits));
|
||||||
const habits = local.getHabits();
|
const rows = habits.map(h => ({
|
||||||
if (habits.length === 0) return localStorage.setItem(SYNC_FLAG, new Date().toISOString());
|
id: h.id,
|
||||||
const rows = habits.map(h => ({
|
user_id: user.id,
|
||||||
id: h.id && h.id.length > 0 ? h.id : undefined,
|
name: h.name,
|
||||||
user_id: user.id,
|
color: h.color,
|
||||||
name: h.name,
|
category: h.category || '',
|
||||||
color: h.color,
|
completions: h.completions || [],
|
||||||
category: h.category || '',
|
current_streak: h.currentStreak ?? 0,
|
||||||
completions: h.completions || [],
|
longest_streak: h.longestStreak ?? 0,
|
||||||
current_streak: h.currentStreak ?? 0,
|
sort_order: h.sortOrder ?? 0,
|
||||||
longest_streak: h.longestStreak ?? 0,
|
created_at: h.createdAt || new Date().toISOString(),
|
||||||
sort_order: h.sortOrder ?? 0,
|
updated_at: h.updatedAt || new Date().toISOString(),
|
||||||
created_at: h.createdAt || new Date().toISOString(),
|
}));
|
||||||
updated_at: h.updatedAt || new Date().toISOString(),
|
await supabase.from('habits').upsert(rows, { onConflict: 'id' });
|
||||||
}));
|
localStorage.setItem(SYNC_FLAG, new Date().toISOString());
|
||||||
await supabase.from('habits').upsert(rows, { onConflict: 'id' });
|
|
||||||
localStorage.setItem(SYNC_FLAG, new Date().toISOString());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Helper: Download JSON backup of local habits
|
||||||
|
function backupLocalHabits() {
|
||||||
|
const habits = JSON.parse(localStorage.getItem('habitgrid_data') || '[]');
|
||||||
|
if (!habits.length) return;
|
||||||
|
const blob = new Blob([JSON.stringify(habits, null, 2)], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `habitgrid-backup-${new Date().toISOString().split('T')[0]}.json`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: Merge two habit arrays by id, prefer latest updatedAt
|
||||||
|
function mergeHabits(localHabits, remoteHabits) {
|
||||||
|
const map = new Map();
|
||||||
|
[...localHabits, ...remoteHabits].forEach(h => {
|
||||||
|
if (!map.has(h.id)) {
|
||||||
|
map.set(h.id, h);
|
||||||
|
} else {
|
||||||
|
// Prefer latest updatedAt
|
||||||
|
const existing = map.get(h.id);
|
||||||
|
map.set(h.id, (new Date(h.updatedAt || 0) > new Date(existing.updatedAt || 0)) ? h : existing);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return Array.from(map.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export async function syncRemoteToLocal() {
|
export async function syncRemoteToLocal() {
|
||||||
const user = await getAuthUser();
|
const user = await getAuthUser();
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
const remote = await getHabits();
|
const remote = await getHabits();
|
||||||
// write to local in the app's expected format
|
const localHabits = JSON.parse(localStorage.getItem('habitgrid_data') || '[]');
|
||||||
localStorage.setItem('habitgrid_data', JSON.stringify(remote));
|
|
||||||
// Notify UI to reload if listening
|
// Only backup on first login sync (not every refresh)
|
||||||
|
const backupFlag = 'habitgrid_backup_done';
|
||||||
|
if (!localStorage.getItem(backupFlag)) {
|
||||||
|
backupLocalHabits();
|
||||||
|
localStorage.setItem(backupFlag, '1');
|
||||||
|
}
|
||||||
|
|
||||||
|
// If both local and remote have data, merge and update both
|
||||||
|
if (localHabits.length && remote.length) {
|
||||||
|
let merged = mergeHabits(localHabits, remote);
|
||||||
|
merged = ensureUUIDs(merged);
|
||||||
|
localStorage.setItem('habitgrid_data', JSON.stringify(merged));
|
||||||
|
await supabase.from('habits').upsert(merged, { onConflict: 'id' });
|
||||||
|
} else if (!remote.length && localHabits.length) {
|
||||||
|
let ensured = ensureUUIDs(localHabits);
|
||||||
|
await supabase.from('habits').upsert(ensured, { onConflict: 'id' });
|
||||||
|
localStorage.setItem('habitgrid_data', JSON.stringify(ensured));
|
||||||
|
} else if (remote.length && !localHabits.length) {
|
||||||
|
let ensured = ensureUUIDs(remote);
|
||||||
|
localStorage.setItem('habitgrid_data', JSON.stringify(ensured));
|
||||||
|
}
|
||||||
|
|
||||||
window.dispatchEvent(new CustomEvent('habitgrid-sync-updated'));
|
window.dispatchEvent(new CustomEvent('habitgrid-sync-updated'));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -180,6 +180,7 @@ async function fetchGitHubEvents({ baseUrl = 'https://api.github.com', username,
|
|||||||
return counts;
|
return counts;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//This bullshit is still not working, but at least it is not crashing everything
|
||||||
async function fetchGiteaLike({ baseUrl, username, token }, days = 365) {
|
async function fetchGiteaLike({ baseUrl, username, token }, days = 365) {
|
||||||
let authMode = token ? 'token' : null; // 'token' | 'bearer' | null
|
let authMode = token ? 'token' : null; // 'token' | 'bearer' | null
|
||||||
const counts = {};
|
const counts = {};
|
||||||
@@ -228,6 +229,7 @@ async function fetchGiteaLike({ baseUrl, username, token }, days = 365) {
|
|||||||
return counts;
|
return counts;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//gitlab fetch should work now
|
||||||
async function fetchGitLabEvents({ baseUrl = 'https://gitlab.com', token }, days = 365) {
|
async function fetchGitLabEvents({ baseUrl = 'https://gitlab.com', token }, days = 365) {
|
||||||
const headers = { 'Accept': 'application/json', 'PRIVATE-TOKEN': token };
|
const headers = { 'Accept': 'application/json', 'PRIVATE-TOKEN': token };
|
||||||
const counts = {};
|
const counts = {};
|
||||||
|
|||||||
@@ -2,6 +2,15 @@
|
|||||||
import { supabase } from './supabase';
|
import { supabase } from './supabase';
|
||||||
const STORAGE_KEY = 'habitgrid_data';
|
const STORAGE_KEY = 'habitgrid_data';
|
||||||
|
|
||||||
|
// UUID v4 generator for local/offline usage
|
||||||
|
function generateUUID() {
|
||||||
|
if (typeof window !== 'undefined' && window.crypto?.randomUUID) return window.crypto.randomUUID();
|
||||||
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
|
||||||
|
const r = Math.random() * 16 | 0, v = c === 'x' ? r : (r & 0x3 | 0x8);
|
||||||
|
return v.toString(16);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export const getHabits = () => {
|
export const getHabits = () => {
|
||||||
try {
|
try {
|
||||||
const data = localStorage.getItem(STORAGE_KEY);
|
const data = localStorage.getItem(STORAGE_KEY);
|
||||||
@@ -56,14 +65,21 @@ const remoteMirrorDelete = async (id) => {
|
|||||||
|
|
||||||
export const saveHabit = (habit) => {
|
export const saveHabit = (habit) => {
|
||||||
const habits = getHabits();
|
const habits = getHabits();
|
||||||
|
// Respect provided id (e.g., UUID from AddEdit or datastore). Generate only if missing.
|
||||||
|
const id = habit.id || generateUUID();
|
||||||
|
const existingIndex = habits.findIndex(h => h.id === id);
|
||||||
const newHabit = {
|
const newHabit = {
|
||||||
...habit,
|
...habit,
|
||||||
id: Date.now().toString(),
|
id,
|
||||||
sortOrder: habits.length,
|
sortOrder: habit.sortOrder ?? habits.length,
|
||||||
createdAt: nowIso(),
|
createdAt: habit.createdAt || nowIso(),
|
||||||
updatedAt: nowIso(),
|
updatedAt: nowIso(),
|
||||||
};
|
};
|
||||||
habits.push(newHabit);
|
if (existingIndex >= 0) {
|
||||||
|
habits[existingIndex] = newHabit;
|
||||||
|
} else {
|
||||||
|
habits.push(newHabit);
|
||||||
|
}
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(habits));
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(habits));
|
||||||
remoteMirrorUpsert(newHabit);
|
remoteMirrorUpsert(newHabit);
|
||||||
return newHabit;
|
return newHabit;
|
||||||
@@ -110,65 +126,7 @@ export const toggleCompletion = (habitId, dateStr) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
import { getFrozenDays } from './utils-habit.js';
|
import { calculateStreaks } from './utils-habit.js';
|
||||||
const calculateStreaks = (completions) => {
|
|
||||||
if (completions.length === 0) {
|
|
||||||
return { currentStreak: 0, longestStreak: 0 };
|
|
||||||
}
|
|
||||||
// Only use frozen days for streak calculation
|
|
||||||
const frozenDays = getFrozenDays(completions);
|
|
||||||
const allValid = Array.from(new Set([...completions, ...frozenDays]));
|
|
||||||
const sortedDates = allValid
|
|
||||||
.map(d => new Date(d))
|
|
||||||
.sort((a, b) => b - a);
|
|
||||||
|
|
||||||
let currentStreak = 0;
|
|
||||||
let longestStreak = 0;
|
|
||||||
let tempStreak = 1;
|
|
||||||
|
|
||||||
const today = new Date();
|
|
||||||
today.setHours(0, 0, 0, 0);
|
|
||||||
const yesterday = new Date(today);
|
|
||||||
yesterday.setDate(yesterday.getDate() - 1);
|
|
||||||
|
|
||||||
const mostRecent = sortedDates[0];
|
|
||||||
mostRecent.setHours(0, 0, 0, 0);
|
|
||||||
|
|
||||||
if (mostRecent.getTime() === today.getTime() || mostRecent.getTime() === yesterday.getTime()) {
|
|
||||||
currentStreak = 1;
|
|
||||||
for (let i = 1; i < sortedDates.length; i++) {
|
|
||||||
const current = new Date(sortedDates[i]);
|
|
||||||
current.setHours(0, 0, 0, 0);
|
|
||||||
const previous = new Date(sortedDates[i - 1]);
|
|
||||||
previous.setHours(0, 0, 0, 0);
|
|
||||||
const diffDays = Math.floor((previous - current) / (1000 * 60 * 60 * 24));
|
|
||||||
if (diffDays === 1) {
|
|
||||||
currentStreak++;
|
|
||||||
tempStreak++;
|
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tempStreak = 1;
|
|
||||||
for (let i = 1; i < sortedDates.length; i++) {
|
|
||||||
const current = new Date(sortedDates[i]);
|
|
||||||
current.setHours(0, 0, 0, 0);
|
|
||||||
const previous = new Date(sortedDates[i - 1]);
|
|
||||||
previous.setHours(0, 0, 0, 0);
|
|
||||||
const diffDays = Math.floor((previous - current) / (1000 * 60 * 60 * 24));
|
|
||||||
if (diffDays === 1) {
|
|
||||||
tempStreak++;
|
|
||||||
longestStreak = Math.max(longestStreak, tempStreak);
|
|
||||||
} else {
|
|
||||||
tempStreak = 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
longestStreak = Math.max(longestStreak, currentStreak, 1);
|
|
||||||
return { currentStreak, longestStreak };
|
|
||||||
}
|
|
||||||
|
|
||||||
export const exportData = () => {
|
export const exportData = () => {
|
||||||
const habits = getHabits();
|
const habits = getHabits();
|
||||||
|
|||||||
@@ -40,6 +40,71 @@ export const getWeekdayLabel = (dayIndex) => {
|
|||||||
const labels = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
const labels = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||||
return labels[dayIndex];
|
return labels[dayIndex];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Calculate current and longest streaks from a list of completion date strings (YYYY-MM-DD)
|
||||||
|
// Rules:
|
||||||
|
// - Streaks count consecutive days
|
||||||
|
// - Today or yesterday must be present to have a non-zero current streak
|
||||||
|
// - We also include "frozen" days (one missed day per month sandwiched by completions)
|
||||||
|
// - Longest streak is at least 1 if there is at least one completion
|
||||||
|
export function calculateStreaks(completions) {
|
||||||
|
if (!Array.isArray(completions) || completions.length === 0) {
|
||||||
|
return { currentStreak: 0, longestStreak: 0 };
|
||||||
|
}
|
||||||
|
// Only use frozen days for streak calculation
|
||||||
|
const frozenDays = getFrozenDays(completions);
|
||||||
|
const allValid = Array.from(new Set([...(completions || []), ...frozenDays]));
|
||||||
|
const sortedDates = allValid
|
||||||
|
.map(d => new Date(d))
|
||||||
|
.sort((a, b) => b - a);
|
||||||
|
|
||||||
|
let currentStreak = 0;
|
||||||
|
let longestStreak = 0;
|
||||||
|
let tempStreak = 1;
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
const yesterday = new Date(today);
|
||||||
|
yesterday.setDate(yesterday.getDate() - 1);
|
||||||
|
|
||||||
|
const mostRecent = sortedDates[0];
|
||||||
|
mostRecent.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
if (mostRecent.getTime() === today.getTime() || mostRecent.getTime() === yesterday.getTime()) {
|
||||||
|
currentStreak = 1;
|
||||||
|
for (let i = 1; i < sortedDates.length; i++) {
|
||||||
|
const current = new Date(sortedDates[i]);
|
||||||
|
current.setHours(0, 0, 0, 0);
|
||||||
|
const previous = new Date(sortedDates[i - 1]);
|
||||||
|
previous.setHours(0, 0, 0, 0);
|
||||||
|
const diffDays = Math.floor((previous - current) / (1000 * 60 * 60 * 24));
|
||||||
|
if (diffDays === 1) {
|
||||||
|
currentStreak++;
|
||||||
|
tempStreak++;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tempStreak = 1;
|
||||||
|
for (let i = 1; i < sortedDates.length; i++) {
|
||||||
|
const current = new Date(sortedDates[i]);
|
||||||
|
current.setHours(0, 0, 0, 0);
|
||||||
|
const previous = new Date(sortedDates[i - 1]);
|
||||||
|
previous.setHours(0, 0, 0, 0);
|
||||||
|
const diffDays = Math.floor((previous - current) / (1000 * 60 * 60 * 24));
|
||||||
|
if (diffDays === 1) {
|
||||||
|
tempStreak++;
|
||||||
|
longestStreak = Math.max(longestStreak, tempStreak);
|
||||||
|
} else {
|
||||||
|
tempStreak = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
longestStreak = Math.max(longestStreak, currentStreak, 1);
|
||||||
|
return { currentStreak, longestStreak };
|
||||||
|
}
|
||||||
// Returns array of frozen days (date strings) for a given completions array
|
// Returns array of frozen days (date strings) for a given completions array
|
||||||
export function getFrozenDays(completions) {
|
export function getFrozenDays(completions) {
|
||||||
// Map: month string -> frozen day string
|
// Map: month string -> frozen day string
|
||||||
|
|||||||
@@ -1,3 +1,11 @@
|
|||||||
|
// UUID v4 generator (browser safe, duplicate of datastore.js for local use)
|
||||||
|
function generateUUID() {
|
||||||
|
if (window.crypto && window.crypto.randomUUID) return window.crypto.randomUUID();
|
||||||
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
|
||||||
|
const r = Math.random() * 16 | 0, v = c === 'x' ? r : (r & 0x3 | 0x8);
|
||||||
|
return v.toString(16);
|
||||||
|
});
|
||||||
|
}
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
@@ -9,6 +17,12 @@ import { useToast } from '../components/ui/use-toast';
|
|||||||
import ColorPicker from '../components/ColorPicker';
|
import ColorPicker from '../components/ColorPicker';
|
||||||
import { getHabits, saveHabit, updateHabit } from '../lib/datastore';
|
import { getHabits, saveHabit, updateHabit } from '../lib/datastore';
|
||||||
|
|
||||||
|
// Local helper to get habit by id from localStorage
|
||||||
|
function getHabit(id) {
|
||||||
|
const habits = JSON.parse(localStorage.getItem('habitgrid_data') || '[]');
|
||||||
|
return habits.find(h => h.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
const AddEditHabitPage = () => {
|
const AddEditHabitPage = () => {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -37,7 +51,7 @@ const AddEditHabitPage = () => {
|
|||||||
}
|
}
|
||||||
}, [id, isEdit, navigate, toast]);
|
}, [id, isEdit, navigate, toast]);
|
||||||
|
|
||||||
const handleSubmit = (e) => {
|
const handleSubmit = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (!name.trim()) {
|
if (!name.trim()) {
|
||||||
@@ -64,10 +78,9 @@ const AddEditHabitPage = () => {
|
|||||||
description: "Your habit has been updated successfully.",
|
description: "Your habit has been updated successfully.",
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Add to localStorage for instant UI
|
// Single source of truth: delegate to datastore; it will handle local or remote as needed
|
||||||
const habits = JSON.parse(localStorage.getItem('habitgrid_data') || '[]');
|
|
||||||
const newHabit = {
|
const newHabit = {
|
||||||
id: Date.now().toString(),
|
id: generateUUID(),
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
color,
|
color,
|
||||||
category: category.trim(),
|
category: category.trim(),
|
||||||
@@ -76,11 +89,8 @@ const AddEditHabitPage = () => {
|
|||||||
longestStreak: 0,
|
longestStreak: 0,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
sortOrder: habits.length,
|
|
||||||
};
|
};
|
||||||
habits.push(newHabit);
|
await saveHabit(newHabit);
|
||||||
localStorage.setItem('habitgrid_data', JSON.stringify(habits));
|
|
||||||
saveHabit(newHabit); // background sync
|
|
||||||
toast({
|
toast({
|
||||||
title: "✅ Habit created",
|
title: "✅ Habit created",
|
||||||
description: "Your new habit is ready to track!",
|
description: "Your new habit is ready to track!",
|
||||||
|
|||||||
@@ -1,21 +1,26 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd';
|
import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd';
|
||||||
import { useNavigate } from 'react-router-dom';
|
// ...existing code...
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { Plus, Settings, TrendingUp, Flame, Calendar, Moon, Sun } from 'lucide-react';
|
import { Plus, Settings, TrendingUp, Flame, Calendar, Moon, Sun, Star } from 'lucide-react';
|
||||||
import { Button } from '../components/ui/button';
|
import { Button } from '../components/ui/button';
|
||||||
import { useToast } from '../components/ui/use-toast';
|
import { useToast } from '../components/ui/use-toast';
|
||||||
import HabitCard from '../components/HabitCard';
|
import HabitCard from '../components/HabitCard';
|
||||||
import AnimatedCounter from '../components/AnimatedCounter';
|
import AnimatedCounter from '../components/AnimatedCounter';
|
||||||
import GitActivityGrid from '../components/GitActivityGrid';
|
import GitActivityGrid from '../components/GitActivityGrid';
|
||||||
import { getGitEnabled } from '../lib/git';
|
import { getGitEnabled } from '../lib/git';
|
||||||
import { getHabits, updateHabit, syncLocalToRemoteIfNeeded, syncRemoteToLocal, getAuthUser } from '../lib/datastore';
|
import { calculateStreaks } from '../lib/utils-habit';
|
||||||
|
import { getHabits, updateHabit, syncLocalToRemoteIfNeeded, syncRemoteToLocal, getAuthUser, hasEverLoggedIn } from '../lib/datastore';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
const HomePage = () => {
|
const HomePage = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [habits, setHabits] = useState([]);
|
const [habits, setHabits] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [loggedIn, setLoggedIn] = useState(false);
|
||||||
const [collapsedGroups, setCollapsedGroups] = useState({});
|
const [collapsedGroups, setCollapsedGroups] = useState({});
|
||||||
|
const [everLoggedIn, setEverLoggedIn] = useState(false);
|
||||||
const [gitEnabled, setGitEnabled] = useState(getGitEnabled());
|
const [gitEnabled, setGitEnabled] = useState(getGitEnabled());
|
||||||
const [darkMode, setDarkMode] = useState(() => {
|
const [darkMode, setDarkMode] = useState(() => {
|
||||||
return localStorage.getItem('theme') === 'dark';
|
return localStorage.getItem('theme') === 'dark';
|
||||||
@@ -23,13 +28,22 @@ const HomePage = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
|
setLoading(true);
|
||||||
// On login, pull remote habits into localStorage
|
// On login, pull remote habits into localStorage
|
||||||
const user = await getAuthUser();
|
const user = await getAuthUser();
|
||||||
|
setLoggedIn(!!user);
|
||||||
|
// Mark whether this browser has seen a login before
|
||||||
|
try {
|
||||||
|
setEverLoggedIn(hasEverLoggedIn());
|
||||||
|
} catch (e) {
|
||||||
|
setEverLoggedIn(false);
|
||||||
|
}
|
||||||
if (user) {
|
if (user) {
|
||||||
await syncRemoteToLocal();
|
await syncRemoteToLocal();
|
||||||
}
|
}
|
||||||
await loadHabits();
|
await loadHabits();
|
||||||
setGitEnabled(getGitEnabled());
|
setGitEnabled(getGitEnabled());
|
||||||
|
setLoading(false);
|
||||||
})();
|
})();
|
||||||
// Background sync every 10s if logged in
|
// Background sync every 10s if logged in
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
@@ -57,13 +71,27 @@ const HomePage = () => {
|
|||||||
const loadHabits = async () => {
|
const loadHabits = async () => {
|
||||||
// Always read from local for instant UI
|
// Always read from local for instant UI
|
||||||
const loadedHabits = JSON.parse(localStorage.getItem('habitgrid_data') || '[]');
|
const loadedHabits = JSON.parse(localStorage.getItem('habitgrid_data') || '[]');
|
||||||
loadedHabits.sort((a, b) => {
|
// One-time consistency pass: recompute streaks from completions if missing or outdated
|
||||||
|
let changed = false;
|
||||||
|
const updated = loadedHabits.map(h => {
|
||||||
|
const { currentStreak, longestStreak } = calculateStreaks(h.completions || []);
|
||||||
|
const nextLongest = Math.max(longestStreak, h.longestStreak || 0);
|
||||||
|
if ((h.currentStreak ?? 0) !== currentStreak || (h.longestStreak ?? 0) !== nextLongest) {
|
||||||
|
changed = true;
|
||||||
|
return { ...h, currentStreak, longestStreak: nextLongest };
|
||||||
|
}
|
||||||
|
return h;
|
||||||
|
});
|
||||||
|
if (changed) {
|
||||||
|
localStorage.setItem('habitgrid_data', JSON.stringify(updated));
|
||||||
|
}
|
||||||
|
updated.sort((a, b) => {
|
||||||
if (a.sortOrder !== undefined && b.sortOrder !== undefined) return a.sortOrder - b.sortOrder;
|
if (a.sortOrder !== undefined && b.sortOrder !== undefined) return a.sortOrder - b.sortOrder;
|
||||||
if (a.sortOrder !== undefined) return -1;
|
if (a.sortOrder !== undefined) return -1;
|
||||||
if (b.sortOrder !== undefined) return 1;
|
if (b.sortOrder !== undefined) return 1;
|
||||||
return new Date(a.createdAt || 0) - new Date(b.createdAt || 0);
|
return new Date(a.createdAt || 0) - new Date(b.createdAt || 0);
|
||||||
});
|
});
|
||||||
setHabits(loadedHabits);
|
setHabits(updated);
|
||||||
// Initialize collapsed state for new categories
|
// Initialize collapsed state for new categories
|
||||||
const categories = Array.from(new Set(loadedHabits.map(h => h.category || 'Uncategorized')));
|
const categories = Array.from(new Set(loadedHabits.map(h => h.category || 'Uncategorized')));
|
||||||
setCollapsedGroups(prev => {
|
setCollapsedGroups(prev => {
|
||||||
@@ -79,6 +107,15 @@ const HomePage = () => {
|
|||||||
navigate('/add');
|
navigate('/add');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleLoginSync = () => {
|
||||||
|
navigate('/login-providers');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleManualSync = async () => {
|
||||||
|
await syncLocalToRemoteIfNeeded();
|
||||||
|
toast({ title: 'Synced!', description: 'Your habits have been synced to the cloud.' });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50 dark:from-slate-950 dark:via-slate-900 dark:to-slate-950">
|
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50 dark:from-slate-950 dark:via-slate-900 dark:to-slate-950">
|
||||||
<div className="max-w-6xl mx-auto px-4 py-6 sm:px-6 lg:px-8">
|
<div className="max-w-6xl mx-auto px-4 py-6 sm:px-6 lg:px-8">
|
||||||
@@ -118,6 +155,21 @@ const HomePage = () => {
|
|||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Prompt previously-signed-in users to re-authenticate if currently logged out */}
|
||||||
|
{!loggedIn && everLoggedIn && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -6 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="max-w-6xl mx-auto mb-6 p-4 bg-yellow-50 dark:bg-yellow-900/40 rounded-lg border border-yellow-100 dark:border-yellow-800 flex items-center justify-between"
|
||||||
|
>
|
||||||
|
<div className="text-sm text-yellow-800 dark:text-yellow-200">Looks like you were previously signed in. Sign in again to keep your habits synced across devices.</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button size="sm" onClick={() => navigate('/login-providers')}>Sign in</Button>
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => { localStorage.removeItem('habitgrid_ever_logged_in'); setEverLoggedIn(false); }}>Dismiss</Button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Stats Overview */}
|
{/* Stats Overview */}
|
||||||
{habits.length > 0 && (
|
{habits.length > 0 && (
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -170,66 +222,83 @@ const HomePage = () => {
|
|||||||
|
|
||||||
let newHabits = [...habits];
|
let newHabits = [...habits];
|
||||||
|
|
||||||
// If dropping into uncategorized, always unset category
|
// Helper to update local storage and UI instantly
|
||||||
|
const updateLocalOrder = (habitsArr) => {
|
||||||
|
localStorage.setItem('habitgrid_data', JSON.stringify(habitsArr));
|
||||||
|
setHabits(habitsArr);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Collect async remote updates to fire after local update
|
||||||
|
let remoteUpdates = [];
|
||||||
|
|
||||||
if (destination.droppableId === 'uncategorized') {
|
if (destination.droppableId === 'uncategorized') {
|
||||||
let items, removed;
|
let items, removed;
|
||||||
if (source.droppableId === 'uncategorized') {
|
if (source.droppableId === 'uncategorized') {
|
||||||
// Reorder within uncategorized
|
|
||||||
items = Array.from(uncategorized);
|
items = Array.from(uncategorized);
|
||||||
[removed] = items.splice(source.index, 1);
|
[removed] = items.splice(source.index, 1);
|
||||||
} else {
|
} else {
|
||||||
// Move from category to uncategorized
|
|
||||||
items = Array.from(uncategorized);
|
items = Array.from(uncategorized);
|
||||||
const sourceItems = Array.from(grouped[source.droppableId]);
|
const sourceItems = Array.from(grouped[source.droppableId]);
|
||||||
[removed] = sourceItems.splice(source.index, 1);
|
[removed] = sourceItems.splice(source.index, 1);
|
||||||
removed.category = '';
|
removed.category = '';
|
||||||
grouped[source.droppableId] = sourceItems;
|
grouped[source.droppableId] = sourceItems;
|
||||||
}
|
}
|
||||||
// Always set category to ''
|
|
||||||
removed.category = '';
|
removed.category = '';
|
||||||
items.splice(destination.index, 0, removed);
|
items.splice(destination.index, 0, removed);
|
||||||
items.forEach((h, i) => updateHabit(h.id, { sortOrder: i, category: '' }));
|
items.forEach((h, i) => {
|
||||||
|
h.sortOrder = i;
|
||||||
|
h.category = '';
|
||||||
|
remoteUpdates.push(updateHabit(h.id, { sortOrder: i, category: '' }));
|
||||||
|
});
|
||||||
newHabits = [
|
newHabits = [
|
||||||
...items,
|
...items,
|
||||||
...Object.values(grouped).flat()
|
...Object.values(grouped).flat()
|
||||||
];
|
];
|
||||||
|
updateLocalOrder(newHabits);
|
||||||
} else if (source.droppableId === 'uncategorized' && grouped[destination.droppableId]) {
|
} else if (source.droppableId === 'uncategorized' && grouped[destination.droppableId]) {
|
||||||
// Move from uncategorized to category
|
|
||||||
const items = Array.from(uncategorized);
|
const items = Array.from(uncategorized);
|
||||||
const [removed] = items.splice(source.index, 1);
|
const [removed] = items.splice(source.index, 1);
|
||||||
removed.category = destination.droppableId;
|
removed.category = destination.droppableId;
|
||||||
const destItems = Array.from(grouped[destination.droppableId] || []);
|
const destItems = Array.from(grouped[destination.droppableId] || []);
|
||||||
destItems.splice(destination.index, 0, removed);
|
destItems.splice(destination.index, 0, removed);
|
||||||
destItems.forEach((h, i) => updateHabit(h.id, { sortOrder: i, category: h.category }));
|
destItems.forEach((h, i) => {
|
||||||
|
h.sortOrder = i;
|
||||||
|
remoteUpdates.push(updateHabit(h.id, { sortOrder: i, category: h.category }));
|
||||||
|
});
|
||||||
newHabits = [
|
newHabits = [
|
||||||
...items,
|
...items,
|
||||||
...Object.values({ ...grouped, [destination.droppableId]: destItems }).flat()
|
...Object.values({ ...grouped, [destination.droppableId]: destItems }).flat()
|
||||||
];
|
];
|
||||||
|
updateLocalOrder(newHabits);
|
||||||
} else if (grouped[source.droppableId] && grouped[destination.droppableId]) {
|
} else if (grouped[source.droppableId] && grouped[destination.droppableId]) {
|
||||||
// Move within or between categories
|
|
||||||
const sourceItems = Array.from(grouped[source.droppableId]);
|
const sourceItems = Array.from(grouped[source.droppableId]);
|
||||||
const [removed] = sourceItems.splice(source.index, 1);
|
const [removed] = sourceItems.splice(source.index, 1);
|
||||||
if (source.droppableId === destination.droppableId) {
|
if (source.droppableId === destination.droppableId) {
|
||||||
// Reorder within same category
|
|
||||||
sourceItems.splice(destination.index, 0, removed);
|
sourceItems.splice(destination.index, 0, removed);
|
||||||
sourceItems.forEach((h, i) => updateHabit(h.id, { sortOrder: i, category: h.category }));
|
sourceItems.forEach((h, i) => {
|
||||||
|
h.sortOrder = i;
|
||||||
|
remoteUpdates.push(updateHabit(h.id, { sortOrder: i, category: h.category }));
|
||||||
|
});
|
||||||
grouped[source.droppableId] = sourceItems;
|
grouped[source.droppableId] = sourceItems;
|
||||||
} else {
|
} else {
|
||||||
// Move to another category
|
|
||||||
const destItems = Array.from(grouped[destination.droppableId] || []);
|
const destItems = Array.from(grouped[destination.droppableId] || []);
|
||||||
removed.category = destination.droppableId;
|
removed.category = destination.droppableId;
|
||||||
destItems.splice(destination.index, 0, removed);
|
destItems.splice(destination.index, 0, removed);
|
||||||
destItems.forEach((h, i) => updateHabit(h.id, { sortOrder: i, category: h.category }));
|
destItems.forEach((h, i) => {
|
||||||
|
h.sortOrder = i;
|
||||||
|
remoteUpdates.push(updateHabit(h.id, { sortOrder: i, category: h.category }));
|
||||||
|
});
|
||||||
grouped[source.droppableId] = sourceItems;
|
grouped[source.droppableId] = sourceItems;
|
||||||
grouped[destination.droppableId] = destItems;
|
grouped[destination.droppableId] = destItems;
|
||||||
}
|
}
|
||||||
// Flatten
|
|
||||||
newHabits = [
|
newHabits = [
|
||||||
...uncategorized,
|
...uncategorized,
|
||||||
...Object.values(grouped).flat()
|
...Object.values(grouped).flat()
|
||||||
];
|
];
|
||||||
|
updateLocalOrder(newHabits);
|
||||||
}
|
}
|
||||||
setTimeout(loadHabits, 0); // reload instantly after update
|
// Fire remote updates async, do not block UI
|
||||||
|
Promise.allSettled(remoteUpdates);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -326,29 +395,65 @@ const HomePage = () => {
|
|||||||
</div>
|
</div>
|
||||||
</DragDropContext>
|
</DragDropContext>
|
||||||
|
|
||||||
{/* Empty State */}
|
{/* Empty State or Loading Buffer */}
|
||||||
{habits.length === 0 && (
|
{habits.length === 0 && (
|
||||||
<motion.div
|
loading && loggedIn ? (
|
||||||
initial={{ opacity: 0, scale: 0.95 }}
|
<motion.div
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
className="text-center py-16"
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
>
|
className="flex flex-col items-center justify-center py-20"
|
||||||
<div className="w-24 h-24 bg-gradient-to-br from-green-100 to-emerald-100 dark:from-green-900/20 dark:to-emerald-900/20 rounded-3xl flex items-center justify-center mx-auto mb-6">
|
>
|
||||||
<Flame className="w-12 h-12 text-green-600 dark:text-green-400" />
|
<div className="w-16 h-16 mb-6 flex items-center justify-center">
|
||||||
</div>
|
<span className="inline-block w-12 h-12 border-4 border-emerald-400 border-t-transparent rounded-full animate-spin"></span>
|
||||||
<h2 className="text-2xl font-bold mb-2">Create your grid!</h2>
|
</div>
|
||||||
<p className="text-muted-foreground mb-8 max-w-md mx-auto">
|
<h2 className="text-xl font-semibold text-muted-foreground">Loading your habits...</h2>
|
||||||
Create your first habit and watch your progress every day as you fill in the squares. Small steps lead to big changes!
|
</motion.div>
|
||||||
</p>
|
) : (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
className="text-center py-16"
|
||||||
|
>
|
||||||
|
<div className="w-24 h-24 bg-gradient-to-br from-green-100 to-emerald-100 dark:from-green-900/20 dark:to-emerald-900/20 rounded-3xl flex items-center justify-center mx-auto mb-6">
|
||||||
|
<Flame className="w-12 h-12 text-green-600 dark:text-green-400" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-bold mb-2">Create your grid!</h2>
|
||||||
|
<p className="text-muted-foreground mb-8 max-w-md mx-auto">
|
||||||
|
Create your first habit and watch your progress every day as you fill in the squares. Small steps lead to big changes!
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
onClick={handleAddHabit}
|
||||||
|
size="lg"
|
||||||
|
className="rounded-full shadow-lg hover:shadow-xl transition-shadow"
|
||||||
|
>
|
||||||
|
<Plus className="w-5 h-5 mr-2" />
|
||||||
|
Create First Habit
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Call to Action for Login/Sync */}
|
||||||
|
<div className="mb-6 flex flex-col items-center mt-8">
|
||||||
|
{!loggedIn ? (
|
||||||
<Button
|
<Button
|
||||||
onClick={handleAddHabit}
|
onClick={handleLoginSync}
|
||||||
size="lg"
|
size="lg"
|
||||||
className="rounded-full shadow-lg hover:shadow-xl transition-shadow"
|
className="rounded-full shadow-lg bg-emerald-600 text-white hover:bg-emerald-700 transition"
|
||||||
|
>
|
||||||
|
<Flame className="w-5 h-5 mr-2" />
|
||||||
|
Login & Sync My Habits
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
onClick={handleManualSync}
|
||||||
|
size="lg"
|
||||||
|
className="rounded-full shadow-lg bg-blue-600 text-white hover:bg-blue-700 transition"
|
||||||
>
|
>
|
||||||
<Plus className="w-5 h-5 mr-2" />
|
<Plus className="w-5 h-5 mr-2" />
|
||||||
Create First Habit
|
Sync My Habits Now
|
||||||
</Button>
|
</Button>
|
||||||
</motion.div>
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Add Button */}
|
{/* Add Button */}
|
||||||
@@ -368,6 +473,34 @@ const HomePage = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.8 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{ delay: 0.3 }}
|
||||||
|
className="fixed bottom-6 left-6"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
onClick={() => window.open("https://github.com/nagaoo0/HabitGrid", "_blank")}
|
||||||
|
size="lg"
|
||||||
|
className="rounded-full shadow-lg hover:shadow-xl transition-shadow bg-gray-600 hover:bg-gray-700 text-white"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href="https://github.com/nagaoo0/HabitGrid"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center space-x-2"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="white" className="w-7 h-7 text-slate-700 dark:text-slate-200">
|
||||||
|
<path d="M12 2C6.477 2 2 6.484 2 12.021c0 4.428 2.865 8.186 6.839 9.525.5.092.682-.217.682-.483 0-.237-.009-.868-.014-1.703-2.782.605-3.369-1.342-3.369-1.342-.454-1.155-1.11-1.463-1.11-1.463-.908-.62.069-.608.069-.608 1.004.07 1.532 1.032 1.532 1.032.892 1.53 2.341 1.088 2.91.832.091-.646.35-1.088.636-1.34-2.221-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.254-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.025A9.564 9.564 0 0 1 12 6.844c.85.004 1.705.115 2.504.337 1.909-1.295 2.748-1.025 2.748-1.025.546 1.378.202 2.396.1 2.65.64.7 1.028 1.595 1.028 2.688 0 3.847-2.337 4.695-4.566 4.944.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.579.688.481C19.138 20.204 22 16.447 22 12.021 22 6.484 17.523 2 12 2z" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user