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)
|
||||
|
||||
|
||||
|
||||
## Features
|
||||
- GitHub-style habit grid (calendar view)
|
||||
- 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
|
||||
- Data export/import (JSON backup)
|
||||
- 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
|
||||
|
||||
---
|
||||
|
||||
|
||||
## 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
|
||||
|
||||
### Prerequisites
|
||||
@@ -69,7 +79,29 @@ src/
|
||||
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/):
|
||||
|
||||
@@ -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!
|
||||
|
||||
## 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/)*
|
||||
|
||||
35
index.html
35
index.html
@@ -5,6 +5,41 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Habit Tracker App</title>
|
||||
<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="stylesheet" href="/src/index.css" />
|
||||
</head>
|
||||
|
||||
@@ -1,102 +1,209 @@
|
||||
[
|
||||
"Great job! Keep going!",
|
||||
"You're on fire! 🔥",
|
||||
"Consistency is key!",
|
||||
"Amazing streak!",
|
||||
"You crushed it today!",
|
||||
"Small steps, big results!",
|
||||
"Habit hero!",
|
||||
"Progress, not perfection!",
|
||||
"Every dot counts!",
|
||||
"Keep up the momentum!",
|
||||
"You’re building something awesome!",
|
||||
"One step closer to your goal!",
|
||||
"You did it! 🎉",
|
||||
"High five! ✋",
|
||||
"You’re awesome!",
|
||||
"Keep rocking!",
|
||||
"Level up! 🚀",
|
||||
"You’re a legend!",
|
||||
"That’s how it’s done!",
|
||||
"You’re crushing it!",
|
||||
"Go you!",
|
||||
"You’re unstoppable!",
|
||||
"Keep the streak alive!",
|
||||
"You’re making it happen!",
|
||||
"Your effort is inspiring!",
|
||||
"You’re a streak superstar!",
|
||||
"Every day matters!",
|
||||
"You’re a habit legend!",
|
||||
"You’re doing fantastic!",
|
||||
"Keep shining!",
|
||||
"You’re a role model!",
|
||||
"You’re a champion!",
|
||||
"You’re making progress!",
|
||||
"Boom! Achievement unlocked!",
|
||||
"You’re a superstar!",
|
||||
"You make it look easy!",
|
||||
"You’re a force of nature!",
|
||||
"You’re a wizard! 🧙♂️",
|
||||
"You’re a ninja! 🥷",
|
||||
"You’re a rockstar! 🎸",
|
||||
"You’re a champion! 🏆",
|
||||
"You’re a hero! 🦸♂️",
|
||||
"You’re a genius!",
|
||||
"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 streak master!",
|
||||
"You’re a habit machine!",
|
||||
"You’re a streak builder!",
|
||||
"You’re a streak star!",
|
||||
"You’re a streak hero!",
|
||||
"You’re a streak ninja!",
|
||||
"You’re a streak wizard!",
|
||||
"You’re a streak warrior!",
|
||||
"You’re a streak explorer!",
|
||||
"You’re a streak adventurer!",
|
||||
"You’re a streak conqueror!",
|
||||
"You’re a streak champion!",
|
||||
"You’re a streak genius!",
|
||||
"You’re a streak guru!",
|
||||
"You’re a streak expert!",
|
||||
"You’re a streak pro!",
|
||||
"You’re a streak veteran!",
|
||||
"You’re a streak rookie!",
|
||||
"You’re a streak all-star!",
|
||||
"You’re a streak MVP!",
|
||||
"You’re a streak superstar!",
|
||||
"You’re a streak rockstar!",
|
||||
"You’re a streak dynamo!",
|
||||
"You’re a streak powerhouse!",
|
||||
"You’re a streak inspiration!",
|
||||
"You’re a streak motivator!",
|
||||
"You’re a streak leader!",
|
||||
"You’re a streak innovator!",
|
||||
"You’re a streak creator!",
|
||||
"You’re a streak builder!",
|
||||
"You’re a streak achiever!",
|
||||
"You’re a streak doer!",
|
||||
"You’re a streak finisher!",
|
||||
"You’re a streak starter!",
|
||||
"You’re a streak closer!",
|
||||
"You’re a streak winner!",
|
||||
"You’re a streak believer!",
|
||||
"You’re a streak dreamer!",
|
||||
"You’re a streak thinker!",
|
||||
"You’re a streak planner!",
|
||||
"You’re a streak organizer!",
|
||||
"You’re a streak strategist!",
|
||||
"You’re a streak tactician!",
|
||||
"You’re a streak visionary!",
|
||||
"You’re a streak optimist!",
|
||||
"You’re a streak realist!",
|
||||
"You’re a streak enthusiast!",
|
||||
"You’re a streak supporter!",
|
||||
"You’re a streak encourager!",
|
||||
"You’re a streak helper!",
|
||||
"You’re a streak friend!",
|
||||
"You’re a streak teammate!",
|
||||
"You’re a streak partner!",
|
||||
"You’re a streak ally!",
|
||||
"You’re a streak companion!",
|
||||
"You’re a streak buddy!",
|
||||
"You’re a streak pal!",
|
||||
"You’re a streak mate!",
|
||||
"You’re a streak peer!",
|
||||
"You’re a streak colleague!",
|
||||
"You’re a streak associate!",
|
||||
"You’re a streak collaborator!",
|
||||
"You’re a streak contributor!",
|
||||
"You’re a streak participant!",
|
||||
"You’re a streak member!",
|
||||
"You’re a streak player!",
|
||||
"You’re a streak contender!",
|
||||
"You’re a streak competitor!",
|
||||
"You’re a streak challenger!",
|
||||
"You’re a streak rival!",
|
||||
"You’re a streak victor!",
|
||||
"You’re a streak survivor!",
|
||||
"You’re a streak thriver!",
|
||||
"You’re a streak overcomer!",
|
||||
"You’re a streak achiever!"
|
||||
"You’re a motivator!",
|
||||
"You’re a creator!",
|
||||
"You’re a dreamer!",
|
||||
"You’re a doer!",
|
||||
"You’re a finisher!",
|
||||
"You’re a Firestarter!",
|
||||
"You’re a closer!",
|
||||
"You’re a believer!",
|
||||
"You’re a planner!",
|
||||
"You’re an organizer!",
|
||||
"You’re a strategist!",
|
||||
"You’re a visionary!",
|
||||
"You’re an optimist!",
|
||||
"You’re an enthusiast!",
|
||||
"You’re a player!",
|
||||
"You’re a contender!",
|
||||
"You’re a competitor!",
|
||||
"You’re a challenger!",
|
||||
"You’re a victor!",
|
||||
"You’re a survivor!",
|
||||
"You’re a thriver!",
|
||||
"You’re an overcomer!",
|
||||
"You’re a high achiever!",
|
||||
"Not all those who wander are lost. —J.R.R. Tolkien",
|
||||
"So it goes. —Kurt Vonnegut",
|
||||
"The only way out is through. —Robert Frost",
|
||||
"You have brains in your head. You have feet in your shoes. You can steer yourself any direction you choose. —Dr. Seuss",
|
||||
"Courage is found in unlikely places. —J.R.R. Tolkien",
|
||||
"Do or do not. There is no try. —Yoda",
|
||||
"It’s dangerous to go alone! Take this. —The Legend of Zelda",
|
||||
"The game is afoot! —Sherlock Holmes",
|
||||
"To the stars who listen and the dreams that are answered. —Sarah J. Maas",
|
||||
"All we have to decide is what to do with the time that is given us. —Gandalf",
|
||||
"Even the smallest person can change the course of the future. —Galadriel",
|
||||
"Winter passed and the world grew up. —C.S. Lewis",
|
||||
"The secret of getting ahead is getting started. —Mark Twain",
|
||||
"Not all heroes wear capes.",
|
||||
"Achievement unlocked!",
|
||||
"You rolled a natural 20!",
|
||||
"The odds are ever in your favor.",
|
||||
"You found the last Horcrux!",
|
||||
"You solved the puzzle before Watson!",
|
||||
"You’re the plot twist everyone needed.",
|
||||
"You’re the missing page in the story.",
|
||||
"You’re the last piece of the puzzle.",
|
||||
"You’re the answer to the riddle.",
|
||||
"You’re the light at the end of the tunnel.",
|
||||
"You’re the hero of your own epic.",
|
||||
"You’re the author of your next chapter.",
|
||||
"You’re the main character energy!",
|
||||
"You’re the chosen one (but in a good way).",
|
||||
"You’re the unexpected ending everyone loves.",
|
||||
"You’re the plot armor in a tough scene.",
|
||||
"You’re the magic in the mundane.",
|
||||
"You’re the hope in the dystopia.",
|
||||
"You’re the clever twist in the mystery.",
|
||||
"You’re the spark in the revolution.",
|
||||
"You’re the prophecy fulfilled.",
|
||||
"You’re the legend in the making.",
|
||||
"You’re the muse for tomorrow’s story.",
|
||||
"You’re the ink in the pen of progress.",
|
||||
"You’re the page-turner in a slow chapter.",
|
||||
"You’re the secret passage in the labyrinth.",
|
||||
"You’re the dragon’s gold at the end of the quest.",
|
||||
"You’re the ring-bearer on the journey.",
|
||||
"You’re the phoenix rising from the ashes.",
|
||||
"You’re the sword in the stone.",
|
||||
"You’re the magic bean that grew the beanstalk.",
|
||||
"You’re the time traveler who fixed the timeline.",
|
||||
"You’re the detective who cracked the case.",
|
||||
"You’re the rebel with a cause.",
|
||||
"You’re the wizard who remembered the spell.",
|
||||
"You’re the hobbit who left the Shire.",
|
||||
"You’re the poet who found the rhyme.",
|
||||
"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 { motion } from 'framer-motion';
|
||||
import { getColorIntensity, isToday, formatDate, getWeekdayLabel, getFrozenDays } from '../lib/utils-habit';
|
||||
import { toggleCompletion } from '../lib/datastore';
|
||||
import { getColorIntensity, isToday, formatDate, getWeekdayLabel, getFrozenDays, calculateStreaks } from '../lib/utils-habit';
|
||||
import { toggleCompletion, getAuthUser } from '../lib/datastore';
|
||||
|
||||
const HabitGrid = ({ habit, onUpdate, fullView = false }) => {
|
||||
const frozenDays = getFrozenDays(habit.completions);
|
||||
@@ -38,9 +38,11 @@ const HabitGrid = ({ habit, onUpdate, fullView = false }) => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleCellClick = (date) => {
|
||||
const handleCellClick = async (date) => {
|
||||
const dateStr = formatDate(date);
|
||||
// Optimistic local update
|
||||
const user = await getAuthUser();
|
||||
if (user) {
|
||||
// Optimistically update completions for instant UI
|
||||
const habits = JSON.parse(localStorage.getItem('habitgrid_data') || '[]');
|
||||
const idx = habits.findIndex(h => h.id === habit.id);
|
||||
if (idx !== -1) {
|
||||
@@ -48,10 +50,21 @@ const HabitGrid = ({ habit, onUpdate, fullView = false }) => {
|
||||
const cidx = completions.indexOf(dateStr);
|
||||
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));
|
||||
}
|
||||
toggleCompletion(habit.id, dateStr); // background sync
|
||||
onUpdate();
|
||||
// Sync in background
|
||||
toggleCompletion(habit.id, dateStr);
|
||||
} else {
|
||||
// Local-only: just call toggleCompletion, then update UI
|
||||
await toggleCompletion(habit.id, dateStr);
|
||||
onUpdate();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -38,9 +38,9 @@ function getFreezeIcon() {
|
||||
return icon || '❄️';
|
||||
}
|
||||
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 { toggleCompletion } from '../lib/datastore';
|
||||
import { toggleCompletion, getAuthUser } from '../lib/datastore';
|
||||
import { toast } from './ui/use-toast';
|
||||
|
||||
const MiniGrid = ({ habit, onUpdate }) => {
|
||||
@@ -69,7 +69,9 @@ const MiniGrid = ({ habit, onUpdate }) => {
|
||||
const dateStr = formatDate(date);
|
||||
const isTodayCell = isToday(date);
|
||||
const wasCompleted = habit.completions.includes(dateStr);
|
||||
// Optimistic local update
|
||||
const user = await getAuthUser();
|
||||
if (user) {
|
||||
// Optimistically update completions for instant UI
|
||||
const habits = JSON.parse(localStorage.getItem('habitgrid_data') || '[]');
|
||||
const idx = habits.findIndex(h => h.id === habit.id);
|
||||
if (idx !== -1) {
|
||||
@@ -77,10 +79,21 @@ const MiniGrid = ({ habit, onUpdate }) => {
|
||||
const cidx = completions.indexOf(dateStr);
|
||||
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));
|
||||
}
|
||||
toggleCompletion(habit.id, dateStr); // background sync
|
||||
onUpdate();
|
||||
// Sync in background
|
||||
toggleCompletion(habit.id, dateStr);
|
||||
} else {
|
||||
// Local-only: just call toggleCompletion, then update UI
|
||||
await toggleCompletion(habit.id, dateStr);
|
||||
onUpdate();
|
||||
}
|
||||
// Only show encouragement toast if validating (adding) today's dot
|
||||
if (isTodayCell && !wasCompleted) {
|
||||
try {
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
html, body {
|
||||
background-color: #fff;
|
||||
}
|
||||
html.dark, body.dark {
|
||||
background-color: #020617;
|
||||
}
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@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 { calculateStreaks } from './utils-habit';
|
||||
import * as local from './storage';
|
||||
|
||||
const SYNC_FLAG = 'habitgrid_remote_synced_at';
|
||||
@@ -6,7 +31,23 @@ const SYNC_FLAG = 'habitgrid_remote_synced_at';
|
||||
export const getAuthUser = async () => {
|
||||
if (!isSupabaseConfigured()) return null;
|
||||
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());
|
||||
@@ -61,7 +102,10 @@ export async function saveHabit(habit) {
|
||||
if (!(await isLoggedIn())) return local.saveHabit(habit);
|
||||
const now = new Date().toISOString();
|
||||
const { data: auth } = await supabase.auth.getUser();
|
||||
// Ensure UUID for new habit
|
||||
const id = habit.id && isValidUUID(habit.id) ? habit.id : generateUUID();
|
||||
const insert = {
|
||||
id,
|
||||
user_id: auth?.user?.id,
|
||||
name: habit.name,
|
||||
color: habit.color,
|
||||
@@ -73,10 +117,14 @@ export async function saveHabit(habit) {
|
||||
created_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) {
|
||||
console.warn('Supabase saveHabit error, writing local:', error.message);
|
||||
return local.saveHabit(habit);
|
||||
return local.saveHabit({ ...habit, id });
|
||||
}
|
||||
return {
|
||||
id: data.id,
|
||||
@@ -103,6 +151,8 @@ export async function updateHabit(id, updates) {
|
||||
console.warn('Supabase updateHabit error, writing local:', error.message);
|
||||
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) {
|
||||
@@ -123,18 +173,48 @@ export async function toggleCompletion(habitId, dateStr) {
|
||||
const completions = Array.isArray(target.completions) ? [...target.completions] : [];
|
||||
const idx = completions.indexOf(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() {
|
||||
// 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);
|
||||
}
|
||||
|
||||
|
||||
export async function importData(jsonString) {
|
||||
// Always import to local; remote sync will push on login
|
||||
return local.importData(jsonString);
|
||||
// Import to local
|
||||
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() {
|
||||
@@ -148,15 +228,13 @@ export async function syncLocalToRemoteIfNeeded() {
|
||||
const user = await getAuthUser();
|
||||
if (!user) return;
|
||||
|
||||
const already = localStorage.getItem(SYNC_FLAG);
|
||||
const { data: remote, error } = await supabase.from('habits').select('id').limit(1);
|
||||
if (error) return;
|
||||
|
||||
if (!already || (remote || []).length === 0) {
|
||||
const habits = local.getHabits();
|
||||
// Always upsert all local habits to Supabase after login
|
||||
let habits = local.getHabits();
|
||||
if (habits.length === 0) return localStorage.setItem(SYNC_FLAG, new Date().toISOString());
|
||||
habits = ensureUUIDs(habits);
|
||||
localStorage.setItem('habitgrid_data', JSON.stringify(habits));
|
||||
const rows = habits.map(h => ({
|
||||
id: h.id && h.id.length > 0 ? h.id : undefined,
|
||||
id: h.id,
|
||||
user_id: user.id,
|
||||
name: h.name,
|
||||
color: h.color,
|
||||
@@ -170,15 +248,65 @@ export async function syncLocalToRemoteIfNeeded() {
|
||||
}));
|
||||
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() {
|
||||
const user = await getAuthUser();
|
||||
if (!user) return;
|
||||
const remote = await getHabits();
|
||||
// write to local in the app's expected format
|
||||
localStorage.setItem('habitgrid_data', JSON.stringify(remote));
|
||||
// Notify UI to reload if listening
|
||||
const localHabits = JSON.parse(localStorage.getItem('habitgrid_data') || '[]');
|
||||
|
||||
// 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'));
|
||||
}
|
||||
|
||||
@@ -180,6 +180,7 @@ async function fetchGitHubEvents({ baseUrl = 'https://api.github.com', username,
|
||||
return counts;
|
||||
}
|
||||
|
||||
//This bullshit is still not working, but at least it is not crashing everything
|
||||
async function fetchGiteaLike({ baseUrl, username, token }, days = 365) {
|
||||
let authMode = token ? 'token' : null; // 'token' | 'bearer' | null
|
||||
const counts = {};
|
||||
@@ -228,6 +229,7 @@ async function fetchGiteaLike({ baseUrl, username, token }, days = 365) {
|
||||
return counts;
|
||||
}
|
||||
|
||||
//gitlab fetch should work now
|
||||
async function fetchGitLabEvents({ baseUrl = 'https://gitlab.com', token }, days = 365) {
|
||||
const headers = { 'Accept': 'application/json', 'PRIVATE-TOKEN': token };
|
||||
const counts = {};
|
||||
|
||||
@@ -2,6 +2,15 @@
|
||||
import { supabase } from './supabase';
|
||||
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 = () => {
|
||||
try {
|
||||
const data = localStorage.getItem(STORAGE_KEY);
|
||||
@@ -56,14 +65,21 @@ const remoteMirrorDelete = async (id) => {
|
||||
|
||||
export const saveHabit = (habit) => {
|
||||
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 = {
|
||||
...habit,
|
||||
id: Date.now().toString(),
|
||||
sortOrder: habits.length,
|
||||
createdAt: nowIso(),
|
||||
id,
|
||||
sortOrder: habit.sortOrder ?? habits.length,
|
||||
createdAt: habit.createdAt || nowIso(),
|
||||
updatedAt: nowIso(),
|
||||
};
|
||||
if (existingIndex >= 0) {
|
||||
habits[existingIndex] = newHabit;
|
||||
} else {
|
||||
habits.push(newHabit);
|
||||
}
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(habits));
|
||||
remoteMirrorUpsert(newHabit);
|
||||
return newHabit;
|
||||
@@ -110,65 +126,7 @@ export const toggleCompletion = (habitId, dateStr) => {
|
||||
});
|
||||
};
|
||||
|
||||
import { getFrozenDays } 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 };
|
||||
}
|
||||
import { calculateStreaks } from './utils-habit.js';
|
||||
|
||||
export const exportData = () => {
|
||||
const habits = getHabits();
|
||||
|
||||
@@ -40,6 +40,71 @@ export const getWeekdayLabel = (dayIndex) => {
|
||||
const labels = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
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
|
||||
export function getFrozenDays(completions) {
|
||||
// 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 { useParams, useNavigate } from 'react-router-dom';
|
||||
import { motion } from 'framer-motion';
|
||||
@@ -9,6 +17,12 @@ import { useToast } from '../components/ui/use-toast';
|
||||
import ColorPicker from '../components/ColorPicker';
|
||||
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 { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
@@ -37,7 +51,7 @@ const AddEditHabitPage = () => {
|
||||
}
|
||||
}, [id, isEdit, navigate, toast]);
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!name.trim()) {
|
||||
@@ -64,10 +78,9 @@ const AddEditHabitPage = () => {
|
||||
description: "Your habit has been updated successfully.",
|
||||
});
|
||||
} else {
|
||||
// Add to localStorage for instant UI
|
||||
const habits = JSON.parse(localStorage.getItem('habitgrid_data') || '[]');
|
||||
// Single source of truth: delegate to datastore; it will handle local or remote as needed
|
||||
const newHabit = {
|
||||
id: Date.now().toString(),
|
||||
id: generateUUID(),
|
||||
name: name.trim(),
|
||||
color,
|
||||
category: category.trim(),
|
||||
@@ -76,11 +89,8 @@ const AddEditHabitPage = () => {
|
||||
longestStreak: 0,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
sortOrder: habits.length,
|
||||
};
|
||||
habits.push(newHabit);
|
||||
localStorage.setItem('habitgrid_data', JSON.stringify(habits));
|
||||
saveHabit(newHabit); // background sync
|
||||
await saveHabit(newHabit);
|
||||
toast({
|
||||
title: "✅ Habit created",
|
||||
description: "Your new habit is ready to track!",
|
||||
|
||||
@@ -1,21 +1,26 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
// ...existing code...
|
||||
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 { useToast } from '../components/ui/use-toast';
|
||||
import HabitCard from '../components/HabitCard';
|
||||
import AnimatedCounter from '../components/AnimatedCounter';
|
||||
import GitActivityGrid from '../components/GitActivityGrid';
|
||||
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 navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const [habits, setHabits] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loggedIn, setLoggedIn] = useState(false);
|
||||
const [collapsedGroups, setCollapsedGroups] = useState({});
|
||||
const [everLoggedIn, setEverLoggedIn] = useState(false);
|
||||
const [gitEnabled, setGitEnabled] = useState(getGitEnabled());
|
||||
const [darkMode, setDarkMode] = useState(() => {
|
||||
return localStorage.getItem('theme') === 'dark';
|
||||
@@ -23,13 +28,22 @@ const HomePage = () => {
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
// On login, pull remote habits into localStorage
|
||||
const user = await getAuthUser();
|
||||
setLoggedIn(!!user);
|
||||
// Mark whether this browser has seen a login before
|
||||
try {
|
||||
setEverLoggedIn(hasEverLoggedIn());
|
||||
} catch (e) {
|
||||
setEverLoggedIn(false);
|
||||
}
|
||||
if (user) {
|
||||
await syncRemoteToLocal();
|
||||
}
|
||||
await loadHabits();
|
||||
setGitEnabled(getGitEnabled());
|
||||
setLoading(false);
|
||||
})();
|
||||
// Background sync every 10s if logged in
|
||||
const interval = setInterval(() => {
|
||||
@@ -57,13 +71,27 @@ const HomePage = () => {
|
||||
const loadHabits = async () => {
|
||||
// Always read from local for instant UI
|
||||
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) return -1;
|
||||
if (b.sortOrder !== undefined) return 1;
|
||||
return new Date(a.createdAt || 0) - new Date(b.createdAt || 0);
|
||||
});
|
||||
setHabits(loadedHabits);
|
||||
setHabits(updated);
|
||||
// Initialize collapsed state for new categories
|
||||
const categories = Array.from(new Set(loadedHabits.map(h => h.category || 'Uncategorized')));
|
||||
setCollapsedGroups(prev => {
|
||||
@@ -79,6 +107,15 @@ const HomePage = () => {
|
||||
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 (
|
||||
<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">
|
||||
@@ -118,6 +155,21 @@ const HomePage = () => {
|
||||
</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 */}
|
||||
{habits.length > 0 && (
|
||||
<motion.div
|
||||
@@ -170,66 +222,83 @@ const HomePage = () => {
|
||||
|
||||
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') {
|
||||
let items, removed;
|
||||
if (source.droppableId === 'uncategorized') {
|
||||
// Reorder within uncategorized
|
||||
items = Array.from(uncategorized);
|
||||
[removed] = items.splice(source.index, 1);
|
||||
} else {
|
||||
// Move from category to uncategorized
|
||||
items = Array.from(uncategorized);
|
||||
const sourceItems = Array.from(grouped[source.droppableId]);
|
||||
[removed] = sourceItems.splice(source.index, 1);
|
||||
removed.category = '';
|
||||
grouped[source.droppableId] = sourceItems;
|
||||
}
|
||||
// Always set category to ''
|
||||
removed.category = '';
|
||||
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 = [
|
||||
...items,
|
||||
...Object.values(grouped).flat()
|
||||
];
|
||||
updateLocalOrder(newHabits);
|
||||
} else if (source.droppableId === 'uncategorized' && grouped[destination.droppableId]) {
|
||||
// Move from uncategorized to category
|
||||
const items = Array.from(uncategorized);
|
||||
const [removed] = items.splice(source.index, 1);
|
||||
removed.category = destination.droppableId;
|
||||
const destItems = Array.from(grouped[destination.droppableId] || []);
|
||||
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 = [
|
||||
...items,
|
||||
...Object.values({ ...grouped, [destination.droppableId]: destItems }).flat()
|
||||
];
|
||||
updateLocalOrder(newHabits);
|
||||
} else if (grouped[source.droppableId] && grouped[destination.droppableId]) {
|
||||
// Move within or between categories
|
||||
const sourceItems = Array.from(grouped[source.droppableId]);
|
||||
const [removed] = sourceItems.splice(source.index, 1);
|
||||
if (source.droppableId === destination.droppableId) {
|
||||
// Reorder within same category
|
||||
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;
|
||||
} else {
|
||||
// Move to another category
|
||||
const destItems = Array.from(grouped[destination.droppableId] || []);
|
||||
removed.category = destination.droppableId;
|
||||
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[destination.droppableId] = destItems;
|
||||
}
|
||||
// Flatten
|
||||
newHabits = [
|
||||
...uncategorized,
|
||||
...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">
|
||||
@@ -326,8 +395,20 @@ const HomePage = () => {
|
||||
</div>
|
||||
</DragDropContext>
|
||||
|
||||
{/* Empty State */}
|
||||
{/* Empty State or Loading Buffer */}
|
||||
{habits.length === 0 && (
|
||||
loading && loggedIn ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="flex flex-col items-center justify-center py-20"
|
||||
>
|
||||
<div className="w-16 h-16 mb-6 flex items-center justify-center">
|
||||
<span className="inline-block w-12 h-12 border-4 border-emerald-400 border-t-transparent rounded-full animate-spin"></span>
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-muted-foreground">Loading your habits...</h2>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
@@ -348,7 +429,31 @@ const HomePage = () => {
|
||||
<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
|
||||
onClick={handleLoginSync}
|
||||
size="lg"
|
||||
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" />
|
||||
Sync My Habits Now
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Add Button */}
|
||||
@@ -368,6 +473,34 @@ const HomePage = () => {
|
||||
</Button>
|
||||
</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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user