Compare commits

...

56 Commits

Author SHA1 Message Date
05fcb0a0d5 build fix 2026-04-20 15:33:29 +05:30
d6da8177c1 docker update 2026-04-20 15:31:02 +05:30
237ba6b3c1 notification working 2026-04-20 15:23:28 +05:30
93dbf2023c more fix 2026-04-16 15:28:12 +05:30
85477e5499 edit entry option added 2026-04-16 15:06:40 +05:30
7f06fa347a history page loader transparent fix 2026-04-16 14:58:22 +05:30
11940678f7 animation flow fix 2026-04-16 12:55:37 +05:30
bf7245d6d1 update seo 2026-04-16 12:20:32 +05:30
816476ed02 added liquid glass theme 2026-04-14 15:26:13 +05:30
6e906436cc update policies 2026-04-14 15:13:15 +05:30
84019c3881 added swipe gestures 2026-04-14 15:02:33 +05:30
09464aaa96 warning fix 2026-04-14 14:58:05 +05:30
7d60fe4634 warning 2026-04-14 14:56:04 +05:30
07a72d6c9f navbar update 2026-04-14 14:53:58 +05:30
d183cf2fd6 image bg upload limit 2026-04-14 14:48:51 +05:30
19dcd73b29 final notif changes 2026-04-14 11:10:44 +05:30
a1ac8e7933 Create liquidglass.md 2026-04-13 15:18:03 +05:30
4d3a0ca1bd Update index.css 2026-04-13 15:07:36 +05:30
937a98c58d opacity change 2026-04-13 15:07:14 +05:30
1353dfc69d added bg feature 2026-04-13 14:49:12 +05:30
34254f94f9 seo improvement and updated notifs 2026-04-13 12:27:30 +05:30
df4bb88f70 added new pages 2026-04-08 11:29:14 +05:30
df9f5dc12b Update CICD_SETUP.md 2026-04-08 11:19:01 +05:30
eefdf32aa8 seo update 2026-04-08 11:01:53 +05:30
de7c1d5ad8 seo setup 2026-04-07 11:16:57 +05:30
88351ffc70 final pwa setup 2026-04-07 10:56:31 +05:30
529b1bad89 auto replace cache on new deploy 2026-04-07 10:35:50 +05:30
0ca694ca99 loading entries 2026-03-31 14:16:38 +05:30
d8f90c7d6c Update icon.svg 2026-03-31 12:44:21 +05:30
b86f02699e Create CICD_SETUP.md 2026-03-31 12:31:20 +05:30
d1800b4888 profile settings 2026-03-31 11:54:39 +05:30
2defb7c02f Update App.css 2026-03-31 11:43:09 +05:30
2b9c5d0248 ui improvs 2026-03-31 11:40:41 +05:30
238ce8b69f minor fix 2026-03-31 11:22:43 +05:30
df5991949e Update SettingsPage.tsx 2026-03-31 11:18:03 +05:30
41daa26835 Update deploy.sh 2026-03-31 11:15:48 +05:30
fd7571c936 added button 2026-03-31 11:15:17 +05:30
de7ce040c8 add to homescreen feature 2026-03-31 11:05:26 +05:30
a1dd555c96 deploy command 2026-03-31 10:43:24 +05:30
8ea81e94d9 more fix 2026-03-31 10:41:13 +05:30
b240ec7be9 docker fix 2026-03-31 10:39:43 +05:30
8df7513295 docker deployment issue fixed 2026-03-31 10:31:10 +05:30
cfecfa5116 fixes 2026-03-31 10:23:49 +05:30
f488400c6d redirect after saving 2026-03-26 15:32:32 +05:30
feb6c10417 select text disabled 2026-03-26 15:26:54 +05:30
2b293a20b7 theme fix 2026-03-26 15:23:08 +05:30
fa10677e41 fallback sign in flow 2026-03-26 15:05:03 +05:30
625e4709d3 added individual entry delete option 2026-03-26 14:55:40 +05:30
0ea8038f15 more ani 2026-03-26 14:41:54 +05:30
57582fbb59 save animation 2026-03-26 14:40:17 +05:30
bb3bf6b238 login page update 2026-03-26 12:05:46 +05:30
711ad6fb70 Create start-all.bat 2026-03-26 11:29:06 +05:30
4233d438ea Create TODO.md 2026-03-24 15:52:19 +05:30
a1719408d3 added mongo auth 2026-03-24 11:47:02 +05:30
6e425e2f04 testing 2026-03-24 10:48:20 +05:30
bd1af0bf44 icon 2026-03-23 14:58:39 +05:30
91 changed files with 10646 additions and 394 deletions

View File

@@ -0,0 +1,412 @@
---
name: seo-audit
description: When the user wants to audit, review, or diagnose SEO issues on their site. Also use when the user mentions "SEO audit," "technical SEO," "why am I not ranking," "SEO issues," "on-page SEO," "meta tags review," "SEO health check," "my traffic dropped," "lost rankings," "not showing up in Google," "site isn't ranking," "Google update hit me," "page speed," "core web vitals," "crawl errors," or "indexing issues." Use this even if the user just says something vague like "my SEO is bad" or "help with SEO" — start with an audit. For building pages at scale to target keywords, see programmatic-seo. For adding structured data, see schema-markup. For AI search optimization, see ai-seo.
metadata:
version: 1.1.0
---
# SEO Audit
You are an expert in search engine optimization. Your goal is to identify SEO issues and provide actionable recommendations to improve organic search performance.
## Initial Assessment
**Check for product marketing context first:**
If `.agents/product-marketing-context.md` exists (or `.claude/product-marketing-context.md` in older setups), read it before asking questions. Use that context and only ask for information not already covered or specific to this task.
Before auditing, understand:
1. **Site Context**
- What type of site? (SaaS, e-commerce, blog, etc.)
- What's the primary business goal for SEO?
- What keywords/topics are priorities?
2. **Current State**
- Any known issues or concerns?
- Current organic traffic level?
- Recent changes or migrations?
3. **Scope**
- Full site audit or specific pages?
- Technical + on-page, or one focus area?
- Access to Search Console / analytics?
---
## Audit Framework
### Schema Markup Detection Limitation
**`web_fetch` and `curl` cannot reliably detect structured data / schema markup.**
Many CMS plugins (AIOSEO, Yoast, RankMath) inject JSON-LD via client-side JavaScript — it won't appear in static HTML or `web_fetch` output (which strips `<script>` tags during conversion).
**To accurately check for schema markup, use one of these methods:**
1. **Browser tool** — render the page and run: `document.querySelectorAll('script[type="application/ld+json"]')`
2. **Google Rich Results Test** — https://search.google.com/test/rich-results
3. **Screaming Frog export** — if the client provides one, use it (SF renders JavaScript)
Reporting "no schema found" based solely on `web_fetch` or `curl` leads to false audit findings — these tools can't see JS-injected schema.
### Priority Order
1. **Crawlability & Indexation** (can Google find and index it?)
2. **Technical Foundations** (is the site fast and functional?)
3. **On-Page Optimization** (is content optimized?)
4. **Content Quality** (does it deserve to rank?)
5. **Authority & Links** (does it have credibility?)
---
## Technical SEO Audit
### Crawlability
**Robots.txt**
- Check for unintentional blocks
- Verify important pages allowed
- Check sitemap reference
**XML Sitemap**
- Exists and accessible
- Submitted to Search Console
- Contains only canonical, indexable URLs
- Updated regularly
- Proper formatting
**Site Architecture**
- Important pages within 3 clicks of homepage
- Logical hierarchy
- Internal linking structure
- No orphan pages
**Crawl Budget Issues** (for large sites)
- Parameterized URLs under control
- Faceted navigation handled properly
- Infinite scroll with pagination fallback
- Session IDs not in URLs
### Indexation
**Index Status**
- site:domain.com check
- Search Console coverage report
- Compare indexed vs. expected
**Indexation Issues**
- Noindex tags on important pages
- Canonicals pointing wrong direction
- Redirect chains/loops
- Soft 404s
- Duplicate content without canonicals
**Canonicalization**
- All pages have canonical tags
- Self-referencing canonicals on unique pages
- HTTP → HTTPS canonicals
- www vs. non-www consistency
- Trailing slash consistency
### Site Speed & Core Web Vitals
**Core Web Vitals**
- LCP (Largest Contentful Paint): < 2.5s
- INP (Interaction to Next Paint): < 200ms
- CLS (Cumulative Layout Shift): < 0.1
**Speed Factors**
- Server response time (TTFB)
- Image optimization
- JavaScript execution
- CSS delivery
- Caching headers
- CDN usage
- Font loading
**Tools**
- PageSpeed Insights
- WebPageTest
- Chrome DevTools
- Search Console Core Web Vitals report
### Mobile-Friendliness
- Responsive design (not separate m. site)
- Tap target sizes
- Viewport configured
- No horizontal scroll
- Same content as desktop
- Mobile-first indexing readiness
### Security & HTTPS
- HTTPS across entire site
- Valid SSL certificate
- No mixed content
- HTTP → HTTPS redirects
- HSTS header (bonus)
### URL Structure
- Readable, descriptive URLs
- Keywords in URLs where natural
- Consistent structure
- No unnecessary parameters
- Lowercase and hyphen-separated
---
## On-Page SEO Audit
### Title Tags
**Check for:**
- Unique titles for each page
- Primary keyword near beginning
- 50-60 characters (visible in SERP)
- Compelling and click-worthy
- No brand name placement (SERPs include brand name above title already)
**Common issues:**
- Duplicate titles
- Too long (truncated)
- Too short (wasted opportunity)
- Keyword stuffing
- Missing entirely
### Meta Descriptions
**Check for:**
- Unique descriptions per page
- 150-160 characters
- Includes primary keyword
- Clear value proposition
- Call to action
**Common issues:**
- Duplicate descriptions
- Auto-generated garbage
- Too long/short
- No compelling reason to click
### Heading Structure
**Check for:**
- One H1 per page
- H1 contains primary keyword
- Logical hierarchy (H1 → H2 → H3)
- Headings describe content
- Not just for styling
**Common issues:**
- Multiple H1s
- Skip levels (H1 → H3)
- Headings used for styling only
- No H1 on page
### Content Optimization
**Primary Page Content**
- Keyword in first 100 words
- Related keywords naturally used
- Sufficient depth/length for topic
- Answers search intent
- Better than competitors
**Thin Content Issues**
- Pages with little unique content
- Tag/category pages with no value
- Doorway pages
- Duplicate or near-duplicate content
### Image Optimization
**Check for:**
- Descriptive file names
- Alt text on all images
- Alt text describes image
- Compressed file sizes
- Modern formats (WebP)
- Lazy loading implemented
- Responsive images
### Internal Linking
**Check for:**
- Important pages well-linked
- Descriptive anchor text
- Logical link relationships
- No broken internal links
- Reasonable link count per page
**Common issues:**
- Orphan pages (no internal links)
- Over-optimized anchor text
- Important pages buried
- Excessive footer/sidebar links
### Keyword Targeting
**Per Page**
- Clear primary keyword target
- Title, H1, URL aligned
- Content satisfies search intent
- Not competing with other pages (cannibalization)
**Site-Wide**
- Keyword mapping document
- No major gaps in coverage
- No keyword cannibalization
- Logical topical clusters
---
## Content Quality Assessment
### E-E-A-T Signals
**Experience**
- First-hand experience demonstrated
- Original insights/data
- Real examples and case studies
**Expertise**
- Author credentials visible
- Accurate, detailed information
- Properly sourced claims
**Authoritativeness**
- Recognized in the space
- Cited by others
- Industry credentials
**Trustworthiness**
- Accurate information
- Transparent about business
- Contact information available
- Privacy policy, terms
- Secure site (HTTPS)
### Content Depth
- Comprehensive coverage of topic
- Answers follow-up questions
- Better than top-ranking competitors
- Updated and current
### User Engagement Signals
- Time on page
- Bounce rate in context
- Pages per session
- Return visits
---
## Common Issues by Site Type
### SaaS/Product Sites
- Product pages lack content depth
- Blog not integrated with product pages
- Missing comparison/alternative pages
- Feature pages thin on content
- No glossary/educational content
### E-commerce
- Thin category pages
- Duplicate product descriptions
- Missing product schema
- Faceted navigation creating duplicates
- Out-of-stock pages mishandled
### Content/Blog Sites
- Outdated content not refreshed
- Keyword cannibalization
- No topical clustering
- Poor internal linking
- Missing author pages
### Local Business
- Inconsistent NAP
- Missing local schema
- No Google Business Profile optimization
- Missing location pages
- No local content
---
## Output Format
### Audit Report Structure
**Executive Summary**
- Overall health assessment
- Top 3-5 priority issues
- Quick wins identified
**Technical SEO Findings**
For each issue:
- **Issue**: What's wrong
- **Impact**: SEO impact (High/Medium/Low)
- **Evidence**: How you found it
- **Fix**: Specific recommendation
- **Priority**: 1-5 or High/Medium/Low
**On-Page SEO Findings**
Same format as above
**Content Findings**
Same format as above
**Prioritized Action Plan**
1. Critical fixes (blocking indexation/ranking)
2. High-impact improvements
3. Quick wins (easy, immediate benefit)
4. Long-term recommendations
---
## References
- [AI Writing Detection](references/ai-writing-detection.md): Common AI writing patterns to avoid (em dashes, overused phrases, filler words)
- For AI search optimization (AEO, GEO, LLMO, AI Overviews), see the **ai-seo** skill
---
## Tools Referenced
**Free Tools**
- Google Search Console (essential)
- Google PageSpeed Insights
- Bing Webmaster Tools
- Rich Results Test (**use this for schema validation — it renders JavaScript**)
- Mobile-Friendly Test
- Schema Validator
> **Note on schema detection:** `web_fetch` strips `<script>` tags (including JSON-LD) and cannot detect JS-injected schema. Use the browser tool, Rich Results Test, or Screaming Frog instead — they render JavaScript and capture dynamically-injected markup. See the Schema Markup Detection Limitation section above.
**Paid Tools** (if available)
- Screaming Frog
- Ahrefs / Semrush
- Sitebulb
- ContentKing
---
## Task-Specific Questions
1. What pages/keywords matter most?
2. Do you have Search Console access?
3. Any recent changes or migrations?
4. Who are your top organic competitors?
5. What's your current organic traffic baseline?
---
## Related Skills
- **ai-seo**: For optimizing content for AI search engines (AEO, GEO, LLMO)
- **programmatic-seo**: For building SEO pages at scale
- **site-architecture**: For page hierarchy, navigation design, and URL structure
- **schema-markup**: For implementing structured data
- **page-cro**: For optimizing pages for conversion (not just ranking)
- **analytics-tracking**: For measuring SEO performance

View File

@@ -0,0 +1,136 @@
{
"skill_name": "seo-audit",
"evals": [
{
"id": 1,
"prompt": "Can you do an SEO audit of our SaaS website? We're getting about 2,000 organic visits/month but feel like we should be getting more. URL: https://example.com",
"expected_output": "Should check for product-marketing-context.md first. Should ask clarifying questions about priority keywords, Search Console access, recent changes, and competitors. Should follow the audit framework priority order: Crawlability & Indexation, Technical Foundations, On-Page Optimization, Content Quality, Authority & Links. Should check robots.txt, XML sitemap, site architecture. Should evaluate title tags, meta descriptions, heading structure, and content optimization. Should NOT report on schema markup based solely on web_fetch (must note the detection limitation). Output should follow the Audit Report Structure: Executive Summary, Technical SEO Findings, On-Page SEO Findings, Content Findings, and Prioritized Action Plan.",
"assertions": [
"Checks for product-marketing-context.md",
"Asks clarifying questions about keywords, Search Console, recent changes",
"Follows audit priority order: crawlability first, then technical, on-page, content, authority",
"Checks robots.txt and XML sitemap",
"Evaluates title tags, meta descriptions, heading structure",
"Does NOT claim 'no schema found' based on web_fetch alone",
"Notes schema markup detection limitation",
"Output has Executive Summary",
"Output has Prioritized Action Plan",
"Each finding has Issue, Impact, Evidence, Fix, and Priority"
],
"files": []
},
{
"id": 2,
"prompt": "Why am I not ranking for 'project management software'? We have a page targeting that keyword but it's stuck on page 3.",
"expected_output": "Should trigger on the casual 'why am I not ranking' phrasing. Should investigate both on-page and off-page factors. On-page: check title tag, H1, URL alignment with keyword; evaluate content depth vs competitors; check for keyword cannibalization. Technical: check indexation status, canonical tags, crawlability. Content quality: assess E-E-A-T signals, content depth, user engagement. Should provide specific, actionable fixes organized by priority. Should mention competitive analysis against current top-ranking pages.",
"assertions": [
"Triggers on casual 'why am I not ranking' phrasing",
"Checks title tag, H1, URL alignment with target keyword",
"Evaluates content depth vs competitors",
"Checks for keyword cannibalization",
"Checks indexation status and canonical tags",
"Assesses E-E-A-T signals",
"Mentions competitive analysis against top-ranking pages",
"Provides actionable fixes organized by priority"
],
"files": []
},
{
"id": 3,
"prompt": "We just migrated from WordPress to Next.js and our organic traffic dropped 40% in the last month. Help!",
"expected_output": "Should treat this as an urgent migration diagnostic. Should immediately check: redirect mapping (301s from old URLs to new), canonical tags on new pages, robots.txt not blocking crawlers, XML sitemap submitted and updated, meta tags preserved. Should check for common migration issues: redirect chains/loops, soft 404s, lost internal links, changed URL structures without redirects. Should reference Search Console coverage report for indexation issues. Should provide a prioritized recovery plan with critical fixes first. Should mention monitoring timeline expectations (recovery can take weeks).",
"assertions": [
"Treats as urgent migration diagnostic",
"Checks redirect mapping (301s)",
"Checks canonical tags on new pages",
"Checks robots.txt not blocking crawlers",
"Checks XML sitemap updated and submitted",
"Checks for redirect chains or loops",
"Checks for soft 404s",
"References Search Console coverage report",
"Provides prioritized recovery plan",
"Mentions recovery timeline expectations"
],
"files": []
},
{
"id": 4,
"prompt": "Review the technical SEO of our e-commerce site. We have about 50,000 products and use faceted navigation.",
"expected_output": "Should focus on e-commerce-specific technical issues: faceted navigation creating duplicate content, crawl budget management for large product catalog, parameterized URLs, product schema markup (with the caveat about detection limitations). Should check for thin category pages, duplicate product descriptions, out-of-stock page handling. Should address crawl budget issues: pagination, infinite scroll handling, session IDs in URLs. Should provide structured findings with Impact ratings and specific fixes.",
"assertions": [
"Addresses faceted navigation duplicate content",
"Addresses crawl budget for large catalog",
"Checks for parameterized URL issues",
"Mentions product schema with detection limitation caveat",
"Checks for thin category pages",
"Checks for duplicate product descriptions",
"Addresses out-of-stock page handling",
"Addresses pagination and infinite scroll",
"Findings include Impact ratings and specific fixes"
],
"files": []
},
{
"id": 5,
"prompt": "Can you check our blog posts for on-page SEO issues? We publish 4 posts per week but traffic has been flat for 6 months.",
"expected_output": "Should apply the Content/Blog Sites framework: check for outdated content not refreshed, keyword cannibalization, missing topical clustering, poor internal linking, missing author pages. Should audit on-page elements: title tags, meta descriptions, heading structure, keyword targeting per post. Should assess E-E-A-T signals for blog content. Should check for content depth issues and whether posts answer search intent. Should recommend a content audit process and provide a prioritized action plan for the existing content library.",
"assertions": [
"Applies Content/Blog Sites framework",
"Checks for outdated content",
"Checks for keyword cannibalization",
"Checks for topical clustering",
"Checks for internal linking quality",
"Checks for author pages and E-E-A-T signals",
"Audits title tags, meta descriptions, heading structure",
"Assesses whether content answers search intent",
"Recommends content audit process",
"Provides prioritized action plan"
],
"files": []
},
{
"id": 6,
"prompt": "I run a local plumbing business with 3 locations. My website barely shows up when people search for 'plumber near me' in our areas. What's wrong?",
"expected_output": "Should apply the Local Business site-type framework. Should check for: inconsistent NAP (Name, Address, Phone) across the site, missing local schema markup (with detection limitation caveat), Google Business Profile optimization, missing individual location pages for each of the 3 locations, and missing local content. Should also check standard technical and on-page factors. Should recommend local-specific fixes: location-specific pages with unique content, local schema on each, GBP optimization, citation consistency.",
"assertions": [
"Applies Local Business framework",
"Checks NAP consistency",
"Checks for local schema markup with detection caveat",
"Addresses Google Business Profile optimization",
"Recommends individual location pages for each location",
"Recommends local content strategy",
"Checks standard technical SEO factors too",
"Provides prioritized local SEO action plan"
],
"files": []
},
{
"id": 7,
"prompt": "Our site loads really slowly, especially on mobile. Pages take 5-6 seconds to load. Is this hurting our SEO?",
"expected_output": "Should focus on Site Speed and Core Web Vitals. Should explain CWV thresholds: LCP < 2.5s, INP < 200ms, CLS < 0.1, and that 5-6s load time is well above acceptable. Should investigate speed factors: server response time (TTFB), image optimization, JavaScript execution, CSS delivery, caching headers, CDN usage, font loading. Should recommend specific tools: PageSpeed Insights, WebPageTest, Chrome DevTools, Search Console CWV report. Should explain that yes, page speed is a ranking factor and directly impacts SEO. Should provide prioritized fixes.",
"assertions": [
"Focuses on Core Web Vitals",
"Explains CWV thresholds (LCP, INP, CLS)",
"Identifies 5-6s as well above acceptable",
"Investigates specific speed factors",
"Recommends specific diagnostic tools",
"Confirms page speed impacts SEO rankings",
"Provides prioritized speed fixes",
"Addresses mobile-specific performance"
],
"files": []
},
{
"id": 8,
"prompt": "I want to add FAQ schema to my product pages. Can you help me set that up?",
"expected_output": "Should recognize this is a schema markup implementation task, not an SEO audit. Should defer to or cross-reference the schema-markup skill, which specifically handles structured data implementation including FAQ schema. May briefly mention that FAQ schema can enable rich results, but should make clear that schema-markup is the right skill for implementation.",
"assertions": [
"Recognizes this as schema markup implementation",
"References or defers to schema-markup skill",
"Does not attempt a full SEO audit",
"May briefly mention FAQ schema benefits"
],
"files": []
}
]
}

View File

@@ -0,0 +1,200 @@
# AI Writing Detection
Words, phrases, and punctuation patterns commonly associated with AI-generated text. Avoid these to ensure writing sounds natural and human.
Sources: Grammarly (2025), Microsoft 365 Life Hacks (2025), GPTHuman (2025), Walter Writes (2025), Textero (2025), Plagiarism Today (2025), Rolling Stone (2025), MDPI Blog (2025)
---
## Contents
- Em Dashes: The Primary AI Tell
- Overused Verbs
- Overused Adjectives
- Overused Transitions and Connectors
- Phrases That Signal AI Writing (Opening Phrases, Transitional Phrases, Concluding Phrases, Structural Patterns)
- Filler Words and Empty Intensifiers
- Academic-Specific AI Tells
- How to Self-Check
## Em Dashes: The Primary AI Tell
**The em dash (—) has become one of the most reliable markers of AI-generated content.**
Em dashes are longer than hyphens (-) and are used for emphasis, interruptions, or parenthetical information. While they have legitimate uses in writing, AI models drastically overuse them.
### Why Em Dashes Signal AI Writing
- AI models were trained on edited books, academic papers, and style guides where em dashes appear frequently
- AI uses em dashes as a shortcut for sentence variety instead of commas, colons, or parentheses
- Most human writers rarely use em dashes because they don't exist as a standard keyboard key
- The overuse is so consistent that it has become the unofficial signature of ChatGPT writing
### What To Do Instead
| Instead of | Use |
|------------|-----|
| The results—which were surprising—showed... | The results, which were surprising, showed... |
| This approach—unlike traditional methods—allows... | This approach, unlike traditional methods, allows... |
| The study found—as expected—that... | The study found, as expected, that... |
| Communication skills—both written and verbal—are essential | Communication skills (both written and verbal) are essential |
### Guidelines
- Use commas for most parenthetical information
- Use colons to introduce explanations or lists
- Use parentheses for supplementary information
- Reserve em dashes for rare, deliberate emphasis only
- If you find yourself using more than one em dash per page, revise
---
## Overused Verbs
| Avoid | Use Instead |
|-------|-------------|
| delve (into) | explore, examine, investigate, look at |
| leverage | use, apply, draw on |
| optimise | improve, refine, enhance |
| utilise | use |
| facilitate | help, enable, support |
| foster | encourage, support, develop, nurture |
| bolster | strengthen, support, reinforce |
| underscore | emphasise, highlight, stress |
| unveil | reveal, show, introduce, present |
| navigate | manage, handle, work through |
| streamline | simplify, make more efficient |
| enhance | improve, strengthen |
| endeavour | try, attempt, effort |
| ascertain | find out, determine, establish |
| elucidate | explain, clarify, make clear |
---
## Overused Adjectives
| Avoid | Use Instead |
|-------|-------------|
| robust | strong, reliable, thorough, solid |
| comprehensive | complete, thorough, full, detailed |
| pivotal | key, critical, central, important |
| crucial | important, key, essential, critical |
| vital | important, essential, necessary |
| transformative | significant, important, major |
| cutting-edge | new, advanced, recent, modern |
| groundbreaking | new, original, significant |
| innovative | new, original, creative |
| seamless | smooth, easy, effortless |
| intricate | complex, detailed, complicated |
| nuanced | subtle, complex, detailed |
| multifaceted | complex, varied, diverse |
| holistic | complete, whole, comprehensive |
---
## Overused Transitions and Connectors
| Avoid | Use Instead |
|-------|-------------|
| furthermore | also, in addition, and |
| moreover | also, and, besides |
| notwithstanding | despite, even so, still |
| that being said | however, but, still |
| at its core | essentially, fundamentally, basically |
| to put it simply | in short, simply put |
| it is worth noting that | note that, importantly |
| in the realm of | in, within, regarding |
| in the landscape of | in, within |
| in today's [anything] | currently, now, today |
---
## Phrases That Signal AI Writing
### Opening Phrases to Avoid
- "In today's fast-paced world..."
- "In today's digital age..."
- "In an era of..."
- "In the ever-evolving landscape of..."
- "In the realm of..."
- "It's important to note that..."
- "Let's delve into..."
- "Imagine a world where..."
### Transitional Phrases to Avoid
- "That being said..."
- "With that in mind..."
- "It's worth mentioning that..."
- "At its core..."
- "To put it simply..."
- "In essence..."
- "This begs the question..."
### Concluding Phrases to Avoid
- "In conclusion..."
- "To sum up..."
- "By [doing X], you can [achieve Y]..."
- "In the final analysis..."
- "All things considered..."
- "At the end of the day..."
### Structural Patterns to Avoid
- "Whether you're a [X], [Y], or [Z]..." (listing three examples after "whether")
- "It's not just [X], it's also [Y]..."
- "Think of [X] as [elaborate metaphor]..."
- Starting sentences with "By" followed by a gerund: "By understanding X, you can Y..."
---
## Filler Words and Empty Intensifiers
These words often add nothing to meaning. Remove them or find specific alternatives:
- absolutely
- actually
- basically
- certainly
- clearly
- definitely
- essentially
- extremely
- fundamentally
- incredibly
- interestingly
- naturally
- obviously
- quite
- really
- significantly
- simply
- surely
- truly
- ultimately
- undoubtedly
- very
---
## Academic-Specific AI Tells
| Avoid | Use Instead |
|-------|-------------|
| shed light on | clarify, explain, reveal |
| pave the way for | enable, allow, make possible |
| a myriad of | many, numerous, various |
| a plethora of | many, numerous, several |
| paramount | very important, essential, critical |
| pertaining to | about, regarding, concerning |
| prior to | before |
| subsequent to | after |
| in light of | because of, given, considering |
| with respect to | about, regarding, for |
| in terms of | regarding, for, about |
| the fact that | that (or rewrite sentence) |
---
## How to Self-Check
1. Read your text aloud. If phrases sound unnatural in speech, revise them
2. Ask: "Would I say this in a conversation with a colleague?"
3. Check for repetitive sentence structures
4. Look for clusters of the words listed above
5. Ensure varied sentence lengths (not all similar length)
6. Verify each intensifier adds genuine meaning

513
.agents/skills/seo/SKILL.md Normal file
View File

@@ -0,0 +1,513 @@
---
name: seo
description: Optimize for search engine visibility and ranking. Use when asked to "improve SEO", "optimize for search", "fix meta tags", "add structured data", "sitemap optimization", or "search engine optimization".
license: MIT
metadata:
author: web-quality-skills
version: "1.0"
---
# SEO optimization
Search engine optimization based on Lighthouse SEO audits and Google Search guidelines. Focus on technical SEO, on-page optimization, and structured data.
## SEO fundamentals
Search ranking factors (approximate influence):
| Factor | Influence | This Skill |
|--------|-----------|------------|
| Content quality & relevance | ~40% | Partial (structure) |
| Backlinks & authority | ~25% | ✗ |
| Technical SEO | ~15% | ✓ |
| Page experience (Core Web Vitals) | ~10% | See [Core Web Vitals](../core-web-vitals/SKILL.md) |
| On-page SEO | ~10% | ✓ |
---
## Technical SEO
### Crawlability
**robots.txt:**
```text
# /robots.txt
User-agent: *
Allow: /
# Block admin/private areas
Disallow: /admin/
Disallow: /api/
Disallow: /private/
# Don't block resources needed for rendering
# ❌ Disallow: /static/
Sitemap: https://example.com/sitemap.xml
```
**Meta robots:**
```html
<!-- Default: indexable, followable -->
<meta name="robots" content="index, follow">
<!-- Noindex specific pages -->
<meta name="robots" content="noindex, nofollow">
<!-- Indexable but don't follow links -->
<meta name="robots" content="index, nofollow">
<!-- Control snippets -->
<meta name="robots" content="max-snippet:150, max-image-preview:large">
```
**Canonical URLs:**
```html
<!-- Prevent duplicate content issues -->
<link rel="canonical" href="https://example.com/page">
<!-- Self-referencing canonical (recommended) -->
<link rel="canonical" href="https://example.com/current-page">
<!-- For paginated content -->
<link rel="canonical" href="https://example.com/products">
<!-- Or use rel="prev" / rel="next" for explicit pagination -->
```
### XML sitemap
```xml
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://example.com/</loc>
<lastmod>2024-01-15</lastmod>
<changefreq>daily</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://example.com/products</loc>
<lastmod>2024-01-14</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
</urlset>
```
**Sitemap best practices:**
- Maximum 50,000 URLs or 50MB per sitemap
- Use sitemap index for larger sites
- Include only canonical, indexable URLs
- Update `lastmod` when content changes
- Submit to Google Search Console
### URL structure
```
✅ Good URLs:
https://example.com/products/blue-widget
https://example.com/blog/how-to-use-widgets
❌ Poor URLs:
https://example.com/p?id=12345
https://example.com/products/item/category/subcategory/blue-widget-2024-sale-discount
```
**URL guidelines:**
- Use hyphens, not underscores
- Lowercase only
- Keep short (< 75 characters)
- Include target keywords naturally
- Avoid parameters when possible
- Use HTTPS always
### HTTPS & security
```html
<!-- Ensure all resources use HTTPS -->
<img src="https://example.com/image.jpg">
<!-- Not: -->
<img src="http://example.com/image.jpg">
```
**Security headers for SEO trust signals:**
```
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
```
---
## On-page SEO
### Title tags
```html
<!-- ❌ Missing or generic -->
<title>Page</title>
<title>Home</title>
<!-- ✅ Descriptive with primary keyword -->
<title>Blue Widgets for Sale | Premium Quality | Example Store</title>
```
**Title tag guidelines:**
- 50-60 characters (Google truncates ~60)
- Primary keyword near the beginning
- Unique for every page
- Brand name at end (unless homepage)
- Action-oriented when appropriate
### Meta descriptions
```html
<!-- ❌ Missing or duplicate -->
<meta name="description" content="">
<!-- ✅ Compelling and unique -->
<meta name="description" content="Shop premium blue widgets with free shipping. 30-day returns. Rated 4.9/5 by 10,000+ customers. Order today and save 20%.">
```
**Meta description guidelines:**
- 150-160 characters
- Include primary keyword naturally
- Compelling call-to-action
- Unique for every page
- Matches page content
### Heading structure
```html
<!-- ❌ Poor structure -->
<h2>Welcome to Our Store</h2>
<h4>Products</h4>
<h1>Contact Us</h1>
<!-- ✅ Proper hierarchy -->
<h1>Blue Widgets - Premium Quality</h1>
<h2>Product Features</h2>
<h3>Durability</h3>
<h3>Design</h3>
<h2>Customer Reviews</h2>
<h2>Pricing</h2>
```
**Heading guidelines:**
- Single `<h1>` per page (the main topic)
- Logical hierarchy (don't skip levels)
- Include keywords naturally
- Descriptive, not generic
### Image SEO
```html
<!-- ❌ Poor image SEO -->
<img src="IMG_12345.jpg">
<!-- ✅ Optimized image -->
<img src="blue-widget-product-photo.webp"
alt="Blue widget with chrome finish, side view showing control panel"
width="800"
height="600"
loading="lazy">
```
**Image guidelines:**
- Descriptive filenames with keywords
- Alt text describes the image content
- Compressed and properly sized
- WebP/AVIF with fallbacks
- Lazy load below-fold images
### Internal linking
```html
<!-- ❌ Non-descriptive -->
<a href="/products">Click here</a>
<a href="/widgets">Read more</a>
<!-- ✅ Descriptive anchor text -->
<a href="/products/blue-widgets">Browse our blue widget collection</a>
<a href="/guides/widget-maintenance">Learn how to maintain your widgets</a>
```
**Linking guidelines:**
- Descriptive anchor text with keywords
- Link to relevant internal pages
- Reasonable number of links per page
- Fix broken links promptly
- Use breadcrumbs for hierarchy
---
## Structured data (JSON-LD)
### Organization
```html
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Organization",
"name": "Example Company",
"url": "https://example.com",
"logo": "https://example.com/logo.png",
"sameAs": [
"https://twitter.com/example",
"https://linkedin.com/company/example"
],
"contactPoint": {
"@type": "ContactPoint",
"telephone": "+1-555-123-4567",
"contactType": "customer service"
}
}
</script>
```
### Article
```html
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Article",
"headline": "How to Choose the Right Widget",
"description": "Complete guide to selecting widgets for your needs.",
"image": "https://example.com/article-image.jpg",
"author": {
"@type": "Person",
"name": "Jane Smith",
"url": "https://example.com/authors/jane-smith"
},
"publisher": {
"@type": "Organization",
"name": "Example Blog",
"logo": {
"@type": "ImageObject",
"url": "https://example.com/logo.png"
}
},
"datePublished": "2024-01-15",
"dateModified": "2024-01-20"
}
</script>
```
### Product
```html
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Product",
"name": "Blue Widget Pro",
"image": "https://example.com/blue-widget.jpg",
"description": "Premium blue widget with advanced features.",
"brand": {
"@type": "Brand",
"name": "WidgetCo"
},
"offers": {
"@type": "Offer",
"price": "49.99",
"priceCurrency": "USD",
"availability": "https://schema.org/InStock",
"url": "https://example.com/products/blue-widget"
},
"aggregateRating": {
"@type": "AggregateRating",
"ratingValue": "4.8",
"reviewCount": "1250"
}
}
</script>
```
### FAQ
```html
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "FAQPage",
"mainEntity": [
{
"@type": "Question",
"name": "What colors are available?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Our widgets come in blue, red, and green."
}
},
{
"@type": "Question",
"name": "What is the warranty?",
"acceptedAnswer": {
"@type": "Answer",
"text": "All widgets include a 2-year warranty."
}
}
]
}
</script>
```
### Breadcrumbs
```html
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": [
{
"@type": "ListItem",
"position": 1,
"name": "Home",
"item": "https://example.com"
},
{
"@type": "ListItem",
"position": 2,
"name": "Products",
"item": "https://example.com/products"
},
{
"@type": "ListItem",
"position": 3,
"name": "Blue Widgets",
"item": "https://example.com/products/blue-widgets"
}
]
}
</script>
```
### Validation
Test structured data at:
- [Google Rich Results Test](https://search.google.com/test/rich-results)
- [Schema.org Validator](https://validator.schema.org/)
---
## Mobile SEO
### Responsive design
```html
<!-- ❌ Not mobile-friendly -->
<meta name="viewport" content="width=1024">
<!-- ✅ Responsive viewport -->
<meta name="viewport" content="width=device-width, initial-scale=1">
```
### Tap targets
```css
/* ❌ Too small for mobile */
.small-link {
padding: 4px;
font-size: 12px;
}
/* ✅ Adequate tap target */
.mobile-friendly-link {
padding: 12px;
font-size: 16px;
min-height: 48px;
min-width: 48px;
}
```
### Font sizes
```css
/* ❌ Too small on mobile */
body {
font-size: 10px;
}
/* ✅ Readable without zooming */
body {
font-size: 16px;
line-height: 1.5;
}
```
---
## International SEO
### Hreflang tags
```html
<!-- For multi-language sites -->
<link rel="alternate" hreflang="en" href="https://example.com/page">
<link rel="alternate" hreflang="es" href="https://example.com/es/page">
<link rel="alternate" hreflang="fr" href="https://example.com/fr/page">
<link rel="alternate" hreflang="x-default" href="https://example.com/page">
```
### Language declaration
```html
<html lang="en">
<!-- or -->
<html lang="es-MX">
```
---
## SEO audit checklist
### Critical
- [ ] HTTPS enabled
- [ ] robots.txt allows crawling
- [ ] No `noindex` on important pages
- [ ] Title tags present and unique
- [ ] Single `<h1>` per page
### High priority
- [ ] Meta descriptions present
- [ ] Sitemap submitted
- [ ] Canonical URLs set
- [ ] Mobile-responsive
- [ ] Core Web Vitals passing
### Medium priority
- [ ] Structured data implemented
- [ ] Internal linking strategy
- [ ] Image alt text
- [ ] Descriptive URLs
- [ ] Breadcrumb navigation
### Ongoing
- [ ] Fix crawl errors in Search Console
- [ ] Update sitemap when content changes
- [ ] Monitor ranking changes
- [ ] Check for broken links
- [ ] Review Search Console insights
---
## Tools
| Tool | Use |
|------|-----|
| Google Search Console | Monitor indexing, fix issues |
| Google PageSpeed Insights | Performance + Core Web Vitals |
| Rich Results Test | Validate structured data |
| Lighthouse | Full SEO audit |
| Screaming Frog | Crawl analysis |
## References
- [Google Search Central](https://developers.google.com/search)
- [Schema.org](https://schema.org/)
- [Core Web Vitals](../core-web-vitals/SKILL.md)
- [Web Quality Audit](../web-quality-audit/SKILL.md)

View File

@@ -4,7 +4,26 @@
"Bash(docker compose:*)", "Bash(docker compose:*)",
"Bash(npx tsc:*)", "Bash(npx tsc:*)",
"Bash(curl -s http://127.0.0.1:8000/api/users/by-email/jeet.debnath2004@gmail.com)", "Bash(curl -s http://127.0.0.1:8000/api/users/by-email/jeet.debnath2004@gmail.com)",
"Bash(ipconfig getifaddr:*)" "Bash(ipconfig getifaddr:*)",
"Bash(npm run:*)",
"Bash(pip install:*)",
"Bash(pip3 install:*)",
"Bash(/Users/jeet/Library/Python/3.9/bin/pytest -v 2>&1)",
"Bash(conda run:*)",
"Bash(git rm:*)",
"Bash(git remote:*)",
"Bash(find /Users/jeet/Desktop/Jio/grateful-journal/src -type f -name *.ts -o -name *.tsx)",
"Bash(ls -la /Users/jeet/Desktop/Jio/grateful-journal/*.config.*)",
"mcp__ide__getDiagnostics",
"Bash(npx skills:*)",
"Bash(ls /Users/jeet/Desktop/Jio/grateful-journal/.env*)",
"Bash(ls /Users/jeet/Desktop/Jio/grateful-journal/backend/.env*)",
"Bash(lsof -ti:8000,4173)",
"Bash(npx --yes lighthouse --version)",
"Bash(curl:*)",
"Bash(npx lighthouse:*)",
"Bash(echo \"exit:$?\")",
"Bash(python -c \"from config import get_settings; s = get_settings\\(\\); print\\('SA JSON set:', bool\\(s.firebase_service_account_json\\)\\)\")"
] ]
} }
} }

1
.claude/skills/seo Symbolic link
View File

@@ -0,0 +1 @@
../../.agents/skills/seo

1
.claude/skills/seo-audit Symbolic link
View File

@@ -0,0 +1 @@
../../.agents/skills/seo-audit

14
.gitignore vendored
View File

@@ -15,6 +15,20 @@ dist-ssr
.env.* .env.*
.env.local .env.local
# Test coverage reports
coverage/
.coverage
htmlcov/
# Python
__pycache__/
*.pyc
*.pyo
.pytest_cache/
# Claude Code memory (local only)
memory/
# Editor directories and files # Editor directories and files
.vscode/* .vscode/*
!.vscode/extensions.json !.vscode/extensions.json

124
CICD_SETUP.md Normal file
View File

@@ -0,0 +1,124 @@
# CI/CD Setup — Gitea Actions (Auto Deploy)
This doc covers how to set up automatic deployment to your VPS whenever you push to `main`. The deploy runs `deploy.sh` (`git pull && docker-compose down && docker-compose up -d --build`).
The runner is installed **directly on the VPS** — no SSH keys needed.
---
## Step 1 — Install act_runner on your VPS
```bash
wget https://gitea.com/gitea/act_runner/releases/latest/download/act_runner-linux-amd64
chmod +x act_runner-linux-amd64
mv act_runner-linux-amd64 /usr/local/bin/act_runner
```
---
## Step 2 — Get a runner token from Gitea
Go to: **Gitea repo → Settings → Actions → Runners → Create Runner**
Copy the token shown.
---
## Step 3 — Register the runner on your VPS
```bash
act_runner register \
--instance https://YOUR_GITEA_URL \
--token YOUR_RUNNER_TOKEN \
--name vps-runner \
--labels ubuntu-latest
```
---
## Step 4 — Run it as a systemd service
```bash
nano /etc/systemd/system/act_runner.service
```
Paste:
```ini
[Unit]
Description=Gitea Act Runner
After=network.target
[Service]
ExecStart=/usr/local/bin/act_runner daemon
WorkingDirectory=/root
Restart=always
[Install]
WantedBy=multi-user.target
```
Enable and start:
```bash
systemctl daemon-reload
systemctl enable --now act_runner
```
---
## Step 5 — Create the workflow file
File is already at `.gitea/workflows/deploy.yml`:
```yaml
name: Deploy to VPS
on:
push:
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Deploy
run: |
cd /path/to/grateful-journal
bash deploy.sh
```
Update `/path/to/grateful-journal` to the actual path on your VPS where the repo is cloned.
---
## Step 6 — Make sure the repo is cloned on your VPS
```bash
git clone https://YOUR_GITEA_URL/username/grateful-journal.git
```
Skip if already cloned.
---
## How it works
```
Push to main
→ Gitea triggers the workflow
→ act_runner (on VPS) picks up the job
→ Runs deploy.sh in place: git pull + docker-compose rebuild
→ App is live
```
---
## Verifying it works
1. Push a commit to `main`
2. Go to **Gitea repo → Actions tab**
3. You should see the workflow run with step-by-step logs
If the runner isn't picking up jobs, check it's online at **Site Administration → Runners**.

View File

@@ -8,6 +8,7 @@ ARG VITE_FIREBASE_PROJECT_ID
ARG VITE_FIREBASE_STORAGE_BUCKET ARG VITE_FIREBASE_STORAGE_BUCKET
ARG VITE_FIREBASE_MESSAGING_SENDER_ID ARG VITE_FIREBASE_MESSAGING_SENDER_ID
ARG VITE_FIREBASE_APP_ID ARG VITE_FIREBASE_APP_ID
ARG VITE_FIREBASE_VAPID_KEY
ARG VITE_API_URL=/api ARG VITE_API_URL=/api
ENV VITE_FIREBASE_API_KEY=${VITE_FIREBASE_API_KEY} ENV VITE_FIREBASE_API_KEY=${VITE_FIREBASE_API_KEY}
@@ -16,6 +17,7 @@ ENV VITE_FIREBASE_PROJECT_ID=${VITE_FIREBASE_PROJECT_ID}
ENV VITE_FIREBASE_STORAGE_BUCKET=${VITE_FIREBASE_STORAGE_BUCKET} ENV VITE_FIREBASE_STORAGE_BUCKET=${VITE_FIREBASE_STORAGE_BUCKET}
ENV VITE_FIREBASE_MESSAGING_SENDER_ID=${VITE_FIREBASE_MESSAGING_SENDER_ID} ENV VITE_FIREBASE_MESSAGING_SENDER_ID=${VITE_FIREBASE_MESSAGING_SENDER_ID}
ENV VITE_FIREBASE_APP_ID=${VITE_FIREBASE_APP_ID} ENV VITE_FIREBASE_APP_ID=${VITE_FIREBASE_APP_ID}
ENV VITE_FIREBASE_VAPID_KEY=${VITE_FIREBASE_VAPID_KEY}
ENV VITE_API_URL=${VITE_API_URL} ENV VITE_API_URL=${VITE_API_URL}
COPY package.json package-lock.json* ./ COPY package.json package-lock.json* ./

328
REMINDER_FEATURE_SETUP.md Normal file
View File

@@ -0,0 +1,328 @@
# Daily Reminder Feature - Complete Setup & Context
**Date:** 2026-04-20
**Status:** ✅ Enabled & Ready for Testing
---
## Overview
The Daily Reminder feature is a **fully implemented Firebase Cloud Messaging (FCM)** system that sends push notifications to remind users to journal. It works even when the browser is closed (on mobile PWA).
**Key Point:** All code was already in place but disabled in the UI. This document captures the setup and what was changed to enable it.
---
## Architecture
### Frontend Flow
**Files:** `src/hooks/useReminder.ts`, `src/hooks/reminderApi.ts`, `src/pages/SettingsPage.tsx`
1. User opens Settings → clicks "Daily Reminder" button
2. Modal opens with time picker (`ClockTimePicker` component)
3. User selects time (e.g., 08:00) → clicks "Save"
4. `enableReminder()` is called:
- Requests browser notification permission (`Notification.requestPermission()`)
- Gets FCM token from service worker
- Sends token to backend: `POST /api/notifications/fcm-token`
- Sends settings to backend: `PUT /api/notifications/reminder/{userId}`
- Stores time + enabled state in localStorage
**Message Handling:**
- `listenForegroundMessages()` called on app mount (in `src/main.tsx`)
- When app is **focused**: Firebase SDK triggers `onMessage()` → shows notification manually
- When app is **closed**: Service worker (`public/sw.js`) handles it via `onBackgroundMessage()` → shows notification
### Backend Flow
**Files:** `backend/scheduler.py`, `backend/routers/notifications.py`, `backend/main.py`
**Initialization:**
- `start_scheduler()` called in FastAPI app lifespan
- Initializes Firebase Admin SDK (requires `FIREBASE_SERVICE_ACCOUNT_JSON`)
- Starts APScheduler cron job
**Every Minute:**
1. Find all users with `reminder.enabled=true` and FCM tokens
2. For each user:
- Convert UTC time → user's timezone (stored in DB)
- Check if current HH:MM matches `reminder.time` (e.g., "08:00")
- Check if already notified today (via `reminder.lastNotifiedDate`)
- Check if user has written a journal entry today
- **If NOT written yet:** Send FCM push via `firebase_admin.messaging.send_each_for_multicast()`
- Auto-prune stale tokens on failure
- Mark as notified today
**Database Structure (MongoDB):**
```js
users collection {
_id: ObjectId,
fcmTokens: [token1, token2, ...], // per device
reminder: {
enabled: boolean,
time: "HH:MM", // 24-hour format
timezone: "Asia/Kolkata", // IANA timezone
lastNotifiedDate: "2026-04-16" // prevents duplicates today
}
}
```
---
## Changes Made (2026-04-20)
### 1. Updated Frontend Environment (`.env.local`)
**Changed:** Firebase credentials from mentor's project → personal test project
```env
VITE_FIREBASE_API_KEY=AIzaSyAjGq7EFrp1mE_8Ni2iZz8LNk7ySVz-lX8
VITE_FIREBASE_AUTH_DOMAIN=react-test-8cb04.firebaseapp.com
VITE_FIREBASE_PROJECT_ID=react-test-8cb04
VITE_FIREBASE_MESSAGING_SENDER_ID=1036594341832
VITE_FIREBASE_APP_ID=1:1036594341832:web:9db6fa337e9cd2e953c2fd
VITE_FIREBASE_VAPID_KEY=BLXhAWY-ms-ACW4PFpqnPak3VZobBIruylVE8Jt-Gm4x53g4aAzEhQzjTvGW8O7dX76-ZoUjlBV15b-EODr1IaY
```
### 2. Updated Backend Environment (`backend/.env`)
**Changed:** Added Firebase service account JSON (from personal test project)
```env
FIREBASE_SERVICE_ACCOUNT_JSON={"type":"service_account","project_id":"react-test-8cb04",...}
```
### 3. Deleted Service Account JSON File
- Removed: `service account.json` (no longer needed — credentials now in env var)
### 4. Enabled Reminder UI (`src/pages/SettingsPage.tsx`)
**Before:**
```tsx
<div className="settings-item" style={{ opacity: 0.5 }}>
<label className="settings-toggle">
<input type="checkbox" checked={false} disabled readOnly />
</label>
</div>
```
**After:**
```tsx
<button
type="button"
className="settings-item settings-item-button"
onClick={handleOpenReminderModal}
>
<div className="settings-item-content">
<h4 className="settings-item-title">Daily Reminder</h4>
<p className="settings-item-subtitle">
{reminderEnabled && reminderTime
? `Set for ${reminderTime}`
: "Set a daily reminder"}
</p>
</div>
</button>
```
- Changed from disabled toggle → interactive button
- Shows current reminder time or "Set a daily reminder"
- Clicking opens time picker modal
### 5. Removed Type Ignore Comment
**Before:**
```tsx
// @ts-ignore — intentionally unused, reminder is disabled (coming soon)
const handleReminderToggle = async () => {
```
**After:**
```tsx
const handleReminderToggle = async () => {
```
---
## Critical Code Files
| File | Purpose |
| ---------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| `src/hooks/useReminder.ts` | `enableReminder()`, `disableReminder()`, `reenableReminder()`, `getFcmToken()`, `listenForegroundMessages()` |
| `src/hooks/reminderApi.ts` | `saveFcmToken()`, `saveReminderSettings()` |
| `backend/scheduler.py` | `send_reminder_notifications()`, `_process_user()`, `_send_push()`, `init_firebase()` |
| `backend/routers/notifications.py` | `POST /fcm-token`, `PUT /reminder/{user_id}` endpoints |
| `public/sw.js` | Service worker background message handler |
| `src/pages/SettingsPage.tsx` | UI: time picker modal, reminder state mgmt |
| `src/main.tsx` | Calls `listenForegroundMessages()` on mount |
| `backend/main.py` | Scheduler initialization in app lifespan |
---
## How to Test
### Prerequisites
- ✅ Backend `.env` has Firebase service account JSON
- ✅ Frontend `.env.local` has Firebase web config + VAPID key
- ✅ UI is enabled (button visible in Settings)
### Steps
1. **Restart the backend** (so it picks up new `FIREBASE_SERVICE_ACCOUNT_JSON`)
```bash
docker-compose down
docker-compose up
```
2. **Open the app** and go to **Settings**
3. **Click "Daily Reminder"** → time picker modal opens
4. **Pick a time** (e.g., 14:30 for testing: pick a time 1-2 minutes in the future)
5. **Click "Save"**
- Browser asks for notification permission → Accept
- Time is saved locally + sent to backend
6. **Monitor backend logs:**
```bash
docker logs grateful-journal-backend-1 -f
```
Look for: `Reminder sent to user {user_id}: X ok, 0 failed`
7. **At the reminder time:**
- If browser is open: notification appears in-app
- If browser is closed: PWA/OS notification appears (mobile)
### Troubleshooting
| Issue | Solution |
| --------------------------------------------------- | ---------------------------------------------------------------------------------- |
| Browser asks for notification permission repeatedly | Check `Notification.permission === 'default'` in browser console |
| FCM token is null | Check `VITE_FIREBASE_VAPID_KEY` is correct; browser may not support FCM |
| Scheduler doesn't run | Restart backend; check `FIREBASE_SERVICE_ACCOUNT_JSON` is valid JSON |
| Notification doesn't appear | Check `reminder.lastNotifiedDate` in MongoDB; trigger time must match exactly |
| Token registration fails | Check backend logs; 400 error means invalid userId format (must be valid ObjectId) |
---
## Environment Variables Reference
### Frontend (`.env.local`)
```
VITE_FIREBASE_API_KEY # Firebase API key
VITE_FIREBASE_AUTH_DOMAIN # Firebase auth domain
VITE_FIREBASE_PROJECT_ID # Firebase project ID
VITE_FIREBASE_MESSAGING_SENDER_ID # Firebase sender ID
VITE_FIREBASE_APP_ID # Firebase app ID
VITE_FIREBASE_VAPID_KEY # FCM Web Push VAPID key (from Firebase Console → Messaging)
VITE_API_URL # Backend API URL (e.g., http://localhost:8001/api)
```
### Backend (`backend/.env`)
```
FIREBASE_SERVICE_ACCOUNT_JSON # Entire Firebase service account JSON (minified single line)
MONGODB_URI # MongoDB connection string
MONGODB_DB_NAME # Database name
API_PORT # Backend port
ENVIRONMENT # production/development
FRONTEND_URL # Frontend URL for CORS
```
---
## Next Steps
### For Production
- Switch back to mentor's Firebase credentials (remove personal test project)
- Update `.env.local` and `backend/.env` with production Firebase values
### Future Improvements
- Add UI toggle to enable/disable without removing settings
- Show timezone in Settings (currently auto-detected)
- Show last notification date in UI
- Add snooze button to notifications
- Let users set multiple reminder times
### Resetting to Disabled State
If you need to disable reminders again:
1. Revert `.env.local` and `backend/.env` to mentor's credentials
2. Revert `src/pages/SettingsPage.tsx` to show "Coming soon" UI
3. Add back `@ts-ignore` comment
---
## Technical Notes
### Why This Approach?
- **FCM:** Works on web, mobile, PWA; no polling needed
- **Service Worker:** Handles background notifications even when browser closed
- **Timezone:** Stores user's IANA timezone to support global users
- **Duplicate Prevention:** Tracks `lastNotifiedDate` per user
- **Smart Timing:** Only notifies if user hasn't written today (no spam)
### Security Considerations
- Firebase service account JSON should never be in git (only in env vars)
- FCM tokens are device-specific; backend stores them securely
- All reminder data is encrypted end-to-end (matches app's crypto design)
### Known Limitations
- Reminder check runs every minute (not more frequent)
- FCM token refresh is handled by Firebase SDK automatically
- Stale tokens are auto-pruned on failed sends
- Timezone must be valid IANA format (not GMT±X)
---
## Quick Reference Commands
**Check backend scheduler logs:**
```bash
docker logs grateful-journal-backend-1 -f | grep -i "reminder\|firebase"
```
**View user reminders in MongoDB:**
```bash
docker exec grateful-journal-mongo-1 mongosh grateful_journal --eval "db.users.findOne({_id: ObjectId('...')})" --username admin --password internvps
```
**Clear FCM tokens for a user (testing):**
```bash
docker exec grateful-journal-mongo-1 mongosh grateful_journal --eval "db.users.updateOne({_id: ObjectId('...')}, {\$set: {fcmTokens: []}})" --username admin --password internvps
```
---
## Support
For questions about:
- **Reminders:** Check daily_reminder_feature.md in memory
- **FCM:** Firebase Cloud Messaging docs
- **APScheduler:** APScheduler documentation
- **Firebase Admin SDK:** Firebase Admin SDK for Python docs

115
about.html Normal file
View File

@@ -0,0 +1,115 @@
<!doctype html>
<html lang="en" style="background-color:#eef6ee">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="manifest" href="/manifest.json" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="Grateful Journal" />
<meta name="theme-color" content="#16a34a" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
/>
<!-- SEO -->
<title>About Grateful Journal | Private, Encrypted Gratitude Journaling</title>
<meta name="description" content="Learn about Grateful Journal — a free, end-to-end encrypted daily gratitude journal. No ads, no tracking, no social feed. Just you and your thoughts." />
<meta name="keywords" content="about grateful journal, private gratitude journal, encrypted journal app, gratitude journaling, mindfulness app" />
<meta name="robots" content="index, follow, max-snippet:160, max-image-preview:large" />
<link rel="canonical" href="https://gratefuljournal.online/about" />
<!-- Open Graph -->
<meta property="og:type" content="website" />
<meta property="og:locale" content="en_US" />
<meta property="og:url" content="https://gratefuljournal.online/about" />
<meta property="og:title" content="About Grateful Journal | Private, Encrypted Gratitude Journaling" />
<meta property="og:description" content="A free, private gratitude journal with end-to-end encryption. Learn how we built a distraction-free space for your daily reflection practice." />
<meta property="og:image" content="https://gratefuljournal.online/web-app-manifest-512x512.png" />
<meta property="og:image:width" content="512" />
<meta property="og:image:height" content="512" />
<meta property="og:image:alt" content="Grateful Journal logo - a green sprout" />
<meta property="og:site_name" content="Grateful Journal" />
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="About Grateful Journal | Private, Encrypted Gratitude Journaling" />
<meta name="twitter:description" content="A free, private gratitude journal with end-to-end encryption. No ads, no tracking, no social feed." />
<meta name="twitter:image" content="https://gratefuljournal.online/web-app-manifest-512x512.png" />
<meta name="twitter:image:alt" content="Grateful Journal logo - a green sprout" />
<!-- JSON-LD: WebPage -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "AboutPage",
"name": "About Grateful Journal",
"url": "https://gratefuljournal.online/about",
"description": "Learn about Grateful Journal — a free, end-to-end encrypted daily gratitude journal. No ads, no tracking, no social feed.",
"isPartOf": {
"@type": "WebSite",
"name": "Grateful Journal",
"url": "https://gratefuljournal.online/"
}
}
</script>
<!-- JSON-LD: Organization -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Organization",
"name": "Grateful Journal",
"url": "https://gratefuljournal.online/",
"logo": {
"@type": "ImageObject",
"url": "https://gratefuljournal.online/web-app-manifest-512x512.png",
"width": 512,
"height": 512
},
"description": "A private, end-to-end encrypted gratitude journal. No feeds, no noise — just you and your thoughts.",
"sameAs": []
}
</script>
</head>
<body>
<div id="root"></div>
<noscript>
<main style="font-family:sans-serif;max-width:680px;margin:4rem auto;padding:1rem 1.5rem;color:#1a1a1a;line-height:1.7">
<nav style="margin-bottom:2rem"><a href="/" style="color:#15803d">&#8592; Grateful Journal</a></nav>
<h1 style="color:#15803d">About Grateful Journal</h1>
<p style="font-size:1.1rem">A private space for gratitude and reflection. No feeds. No noise. Just you and your thoughts.</p>
<h2>What is it?</h2>
<p>Grateful Journal is a free, end-to-end encrypted daily journal focused on gratitude. You write a few things you're grateful for each day, and over time you build a private record of the good in your life — visible only to you.</p>
<h2>Features</h2>
<ul>
<li><strong>End-to-end encrypted entries</strong> — your journal content is encrypted before leaving your device. We cannot read it.</li>
<li><strong>No ads, no tracking</strong> — we don't sell your data or show you ads.</li>
<li><strong>Works offline</strong> — installable as a PWA on Android, iOS, and desktop.</li>
<li><strong>Daily prompts</strong> — gentle nudges to keep your practice consistent.</li>
<li><strong>History view</strong> — browse past entries and reflect on how far you've come.</li>
<li><strong>Free to use</strong> — no subscription, no paywall.</li>
</ul>
<h2>Why gratitude?</h2>
<p>Research consistently shows that a regular gratitude practice improves mood, reduces stress, and builds resilience. Grateful Journal gives you the simplest possible tool to build that habit — without distractions or social pressure.</p>
<h2>Privacy first</h2>
<p>We built Grateful Journal because we believe your inner thoughts deserve a private space. Your journal entries are end-to-end encrypted — only you can read them. App preferences such as your display name, profile photo, and background images are stored as plain account settings and are not encrypted. Read our full <a href="/privacy">Privacy Policy</a> for a complete breakdown of what is and isn't encrypted.</p>
<nav style="margin-top:2rem">
<a href="/">&#8592; Back to Grateful Journal</a> ·
<a href="/privacy">Privacy Policy</a>
</nav>
</main>
</noscript>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -8,3 +8,8 @@ FRONTEND_URL=http://localhost:8000
# MONGODB_URI=mongodb://mongo:27017 # MONGODB_URI=mongodb://mongo:27017
# ENVIRONMENT=production # ENVIRONMENT=production
# Firebase Admin SDK service account (for sending push notifications)
# Firebase Console → Project Settings → Service Accounts → Generate new private key
# Paste the entire JSON on a single line (escape double quotes if needed):
FIREBASE_SERVICE_ACCOUNT_JSON=

View File

@@ -1,5 +1,8 @@
from pydantic_settings import BaseSettings # type: ignore from pydantic_settings import BaseSettings, SettingsConfigDict # type: ignore
from functools import lru_cache from functools import lru_cache
from pathlib import Path
_ENV_FILE = str(Path(__file__).parent / ".env")
class Settings(BaseSettings): class Settings(BaseSettings):
@@ -8,10 +11,14 @@ class Settings(BaseSettings):
api_port: int = 8001 api_port: int = 8001
environment: str = "development" environment: str = "development"
frontend_url: str = "http://localhost:8000" frontend_url: str = "http://localhost:8000"
# Firebase Admin SDK service account JSON (paste the full JSON as a single-line string)
firebase_service_account_json: str = ""
class Config: model_config = SettingsConfigDict(
env_file = ".env" env_file=_ENV_FILE,
case_sensitive = False case_sensitive=False,
extra="ignore", # ignore unknown env vars (e.g. VITE_* from root .env)
)
@lru_cache() @lru_cache()

View File

@@ -1,19 +1,34 @@
from fastapi import FastAPI, HTTPException, Depends import logging
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from db import MongoDB, get_database from db import MongoDB
from config import get_settings from config import get_settings
from routers import entries, users from routers import entries, users
from routers import notifications
from scheduler import start_scheduler
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
force=True,
)
logging.getLogger("scheduler").setLevel(logging.DEBUG)
settings = get_settings() settings = get_settings()
_scheduler = None
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
# Startup # Startup
MongoDB.connect_db() MongoDB.connect_db()
global _scheduler
_scheduler = start_scheduler()
yield yield
# Shutdown # Shutdown
if _scheduler:
_scheduler.shutdown(wait=False)
MongoDB.close_db() MongoDB.close_db()
app = FastAPI( app = FastAPI(
@@ -43,6 +58,7 @@ app.add_middleware(
# Include routers # Include routers
app.include_router(users.router, prefix="/api/users", tags=["users"]) app.include_router(users.router, prefix="/api/users", tags=["users"])
app.include_router(entries.router, prefix="/api/entries", tags=["entries"]) app.include_router(entries.router, prefix="/api/entries", tags=["entries"])
app.include_router(notifications.router, prefix="/api/notifications", tags=["notifications"])
@app.get("/health") @app.get("/health")

View File

@@ -38,6 +38,9 @@ class UserUpdate(BaseModel):
displayName: Optional[str] = None displayName: Optional[str] = None
photoURL: Optional[str] = None photoURL: Optional[str] = None
theme: Optional[str] = None theme: Optional[str] = None
tutorial: Optional[bool] = None
backgroundImage: Optional[str] = None
backgroundImages: Optional[List[str]] = None
class Config: class Config:
json_schema_extra = { json_schema_extra = {
@@ -56,6 +59,7 @@ class User(BaseModel):
createdAt: datetime createdAt: datetime
updatedAt: datetime updatedAt: datetime
theme: str = "light" theme: str = "light"
tutorial: Optional[bool] = None
class Config: class Config:
from_attributes = True from_attributes = True

3
backend/pytest.ini Normal file
View File

@@ -0,0 +1,3 @@
[pytest]
pythonpath = .
testpaths = tests

View File

@@ -1,7 +1,15 @@
fastapi==0.104.1 fastapi>=0.115.0
uvicorn==0.24.0 uvicorn==0.24.0
pymongo==4.6.0 pymongo==4.6.0
pydantic==2.5.0 pydantic>=2.5.0
python-dotenv==1.0.0 python-dotenv==1.0.0
pydantic-settings==2.1.0 pydantic-settings>=2.1.0
python-multipart==0.0.6 python-multipart==0.0.6
firebase-admin>=6.5.0
apscheduler>=3.10.4
pytz>=2024.1
# Testing
pytest>=7.4.0
httpx>=0.25.0
mongomock>=4.1.2

View File

@@ -5,6 +5,7 @@ from models import JournalEntryCreate, JournalEntryUpdate, JournalEntry, Entries
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import List, Optional from typing import List, Optional
from bson import ObjectId from bson import ObjectId
from bson.errors import InvalidId
from utils import format_ist_timestamp from utils import format_ist_timestamp
router = APIRouter() router = APIRouter()
@@ -50,7 +51,10 @@ async def create_entry(user_id: str, entry_data: JournalEntryCreate):
try: try:
user_oid = ObjectId(user_id) user_oid = ObjectId(user_id)
except InvalidId:
raise HTTPException(status_code=400, detail="Invalid user ID format")
try:
# Verify user exists # Verify user exists
user = db.users.find_one({"_id": user_oid}) user = db.users.find_one({"_id": user_oid})
if not user: if not user:
@@ -91,9 +95,6 @@ async def create_entry(user_id: str, entry_data: JournalEntryCreate):
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
if "invalid ObjectId" in str(e).lower():
raise HTTPException(
status_code=400, detail="Invalid user ID format")
raise HTTPException( raise HTTPException(
status_code=500, detail=f"Failed to create entry: {str(e)}") status_code=500, detail=f"Failed to create entry: {str(e)}")
@@ -113,7 +114,10 @@ async def get_user_entries(
try: try:
user_oid = ObjectId(user_id) user_oid = ObjectId(user_id)
except InvalidId:
raise HTTPException(status_code=400, detail="Invalid user ID format")
try:
# Verify user exists # Verify user exists
user = db.users.find_one({"_id": user_oid}) user = db.users.find_one({"_id": user_oid})
if not user: if not user:
@@ -142,10 +146,9 @@ async def get_user_entries(
"hasMore": has_more "hasMore": has_more
} }
} }
except HTTPException:
raise
except Exception as e: except Exception as e:
if "invalid ObjectId" in str(e).lower():
raise HTTPException(
status_code=400, detail="Invalid user ID format")
raise HTTPException( raise HTTPException(
status_code=500, detail=f"Failed to fetch entries: {str(e)}") status_code=500, detail=f"Failed to fetch entries: {str(e)}")
@@ -158,7 +161,10 @@ async def get_entry(user_id: str, entry_id: str):
try: try:
user_oid = ObjectId(user_id) user_oid = ObjectId(user_id)
entry_oid = ObjectId(entry_id) entry_oid = ObjectId(entry_id)
except InvalidId:
raise HTTPException(status_code=400, detail="Invalid ID format")
try:
entry = db.entries.find_one({ entry = db.entries.find_one({
"_id": entry_oid, "_id": entry_oid,
"userId": user_oid "userId": user_oid
@@ -168,9 +174,9 @@ async def get_entry(user_id: str, entry_id: str):
raise HTTPException(status_code=404, detail="Entry not found") raise HTTPException(status_code=404, detail="Entry not found")
return _format_entry(entry) return _format_entry(entry)
except HTTPException:
raise
except Exception as e: except Exception as e:
if "invalid ObjectId" in str(e).lower():
raise HTTPException(status_code=400, detail="Invalid ID format")
raise HTTPException( raise HTTPException(
status_code=500, detail=f"Failed to fetch entry: {str(e)}") status_code=500, detail=f"Failed to fetch entry: {str(e)}")
@@ -183,7 +189,10 @@ async def update_entry(user_id: str, entry_id: str, entry_data: JournalEntryUpda
try: try:
user_oid = ObjectId(user_id) user_oid = ObjectId(user_id)
entry_oid = ObjectId(entry_id) entry_oid = ObjectId(entry_id)
except InvalidId:
raise HTTPException(status_code=400, detail="Invalid ID format")
try:
update_data = entry_data.model_dump(exclude_unset=True) update_data = entry_data.model_dump(exclude_unset=True)
update_data["updatedAt"] = datetime.utcnow() update_data["updatedAt"] = datetime.utcnow()
@@ -206,9 +215,9 @@ async def update_entry(user_id: str, entry_id: str, entry_data: JournalEntryUpda
# Fetch and return updated entry # Fetch and return updated entry
entry = db.entries.find_one({"_id": entry_oid}) entry = db.entries.find_one({"_id": entry_oid})
return _format_entry(entry) return _format_entry(entry)
except HTTPException:
raise
except Exception as e: except Exception as e:
if "invalid ObjectId" in str(e).lower():
raise HTTPException(status_code=400, detail="Invalid ID format")
raise HTTPException( raise HTTPException(
status_code=500, detail=f"Failed to update entry: {str(e)}") status_code=500, detail=f"Failed to update entry: {str(e)}")
@@ -221,7 +230,10 @@ async def delete_entry(user_id: str, entry_id: str):
try: try:
user_oid = ObjectId(user_id) user_oid = ObjectId(user_id)
entry_oid = ObjectId(entry_id) entry_oid = ObjectId(entry_id)
except InvalidId:
raise HTTPException(status_code=400, detail="Invalid ID format")
try:
result = db.entries.delete_one({ result = db.entries.delete_one({
"_id": entry_oid, "_id": entry_oid,
"userId": user_oid "userId": user_oid
@@ -231,9 +243,9 @@ async def delete_entry(user_id: str, entry_id: str):
raise HTTPException(status_code=404, detail="Entry not found") raise HTTPException(status_code=404, detail="Entry not found")
return {"message": "Entry deleted successfully"} return {"message": "Entry deleted successfully"}
except HTTPException:
raise
except Exception as e: except Exception as e:
if "invalid ObjectId" in str(e).lower():
raise HTTPException(status_code=400, detail="Invalid ID format")
raise HTTPException( raise HTTPException(
status_code=500, detail=f"Failed to delete entry: {str(e)}") status_code=500, detail=f"Failed to delete entry: {str(e)}")
@@ -249,7 +261,10 @@ async def get_entries_by_date(user_id: str, date_str: str):
try: try:
user_oid = ObjectId(user_id) user_oid = ObjectId(user_id)
except InvalidId:
raise HTTPException(status_code=400, detail="Invalid user ID format")
try:
# Parse date # Parse date
target_date = datetime.strptime(date_str, "%Y-%m-%d") target_date = datetime.strptime(date_str, "%Y-%m-%d")
next_date = target_date + timedelta(days=1) next_date = target_date + timedelta(days=1)
@@ -274,10 +289,9 @@ async def get_entries_by_date(user_id: str, date_str: str):
except ValueError: except ValueError:
raise HTTPException( raise HTTPException(
status_code=400, detail="Invalid date format. Use YYYY-MM-DD") status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
except HTTPException:
raise
except Exception as e: except Exception as e:
if "invalid ObjectId" in str(e).lower():
raise HTTPException(
status_code=400, detail="Invalid user ID format")
raise HTTPException( raise HTTPException(
status_code=500, detail=f"Failed to fetch entries: {str(e)}") status_code=500, detail=f"Failed to fetch entries: {str(e)}")
@@ -293,7 +307,10 @@ async def get_entries_by_month(user_id: str, year: int, month: int, limit: int =
try: try:
user_oid = ObjectId(user_id) user_oid = ObjectId(user_id)
except InvalidId:
raise HTTPException(status_code=400, detail="Invalid user ID format")
try:
if not (1 <= month <= 12): if not (1 <= month <= 12):
raise HTTPException( raise HTTPException(
status_code=400, detail="Month must be between 1 and 12") status_code=400, detail="Month must be between 1 and 12")
@@ -325,10 +342,9 @@ async def get_entries_by_month(user_id: str, year: int, month: int, limit: int =
} }
except ValueError: except ValueError:
raise HTTPException(status_code=400, detail="Invalid year or month") raise HTTPException(status_code=400, detail="Invalid year or month")
except HTTPException:
raise
except Exception as e: except Exception as e:
if "invalid ObjectId" in str(e).lower():
raise HTTPException(
status_code=400, detail="Invalid user ID format")
raise HTTPException( raise HTTPException(
status_code=500, detail=f"Failed to fetch entries: {str(e)}") status_code=500, detail=f"Failed to fetch entries: {str(e)}")
@@ -347,6 +363,8 @@ async def convert_utc_to_ist(data: dict):
"utc": utc_timestamp, "utc": utc_timestamp,
"ist": ist_timestamp "ist": ist_timestamp
} }
except HTTPException:
raise
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:

View File

@@ -0,0 +1,78 @@
"""Notification routes — FCM token registration and reminder settings."""
from fastapi import APIRouter, HTTPException
from db import get_database
from pydantic import BaseModel
from typing import Optional
from bson import ObjectId
from bson.errors import InvalidId
from datetime import datetime
router = APIRouter()
class FcmTokenRequest(BaseModel):
userId: str
fcmToken: str
class ReminderSettingsRequest(BaseModel):
time: Optional[str] = None # "HH:MM" in 24-hour format
enabled: bool
timezone: Optional[str] = None # IANA timezone, e.g. "Asia/Kolkata"
@router.post("/fcm-token", response_model=dict)
async def register_fcm_token(body: FcmTokenRequest):
"""
Register (or refresh) an FCM device token for a user.
Stores unique tokens per user — duplicate tokens are ignored.
"""
db = get_database()
try:
user_oid = ObjectId(body.userId)
except InvalidId:
raise HTTPException(status_code=400, detail="Invalid user ID")
user = db.users.find_one({"_id": user_oid})
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Add token to set (avoid duplicates)
db.users.update_one(
{"_id": user_oid},
{
"$addToSet": {"fcmTokens": body.fcmToken},
"$set": {"updatedAt": datetime.utcnow()},
}
)
return {"message": "FCM token registered"}
@router.put("/reminder/{user_id}", response_model=dict)
async def update_reminder(user_id: str, settings: ReminderSettingsRequest):
"""
Save or update daily reminder settings for a user.
"""
db = get_database()
try:
user_oid = ObjectId(user_id)
except InvalidId:
raise HTTPException(status_code=400, detail="Invalid user ID")
user = db.users.find_one({"_id": user_oid})
if not user:
raise HTTPException(status_code=404, detail="User not found")
reminder_update: dict = {"reminder.enabled": settings.enabled}
if settings.time is not None:
reminder_update["reminder.time"] = settings.time
if settings.timezone is not None:
reminder_update["reminder.timezone"] = settings.timezone
db.users.update_one(
{"_id": user_oid},
{"$set": {**reminder_update, "updatedAt": datetime.utcnow()}}
)
return {"message": "Reminder settings updated"}

View File

@@ -1,11 +1,11 @@
"""User management routes""" """User management routes"""
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from pymongo.errors import DuplicateKeyError, WriteError
from db import get_database from db import get_database
from models import UserCreate, UserUpdate, User from models import UserCreate, UserUpdate, User
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional
from bson import ObjectId from bson import ObjectId
from bson.errors import InvalidId
router = APIRouter() router = APIRouter()
@@ -52,10 +52,15 @@ async def register_user(user_data: UserCreate):
"displayName": user["displayName"], "displayName": user["displayName"],
"photoURL": user.get("photoURL"), "photoURL": user.get("photoURL"),
"theme": user.get("theme", "light"), "theme": user.get("theme", "light"),
"backgroundImage": user.get("backgroundImage"),
"backgroundImages": user.get("backgroundImages", []),
"reminder": user.get("reminder"),
"createdAt": user["createdAt"].isoformat(), "createdAt": user["createdAt"].isoformat(),
"updatedAt": user["updatedAt"].isoformat(), "updatedAt": user["updatedAt"].isoformat(),
"message": "User registered successfully" if result.upserted_id else "User already exists" "message": "User registered successfully" if result.upserted_id else "User already exists"
} }
except HTTPException:
raise
except Exception as e: except Exception as e:
raise HTTPException( raise HTTPException(
status_code=500, detail=f"Registration failed: {str(e)}") status_code=500, detail=f"Registration failed: {str(e)}")
@@ -77,9 +82,15 @@ async def get_user_by_email(email: str):
"displayName": user.get("displayName"), "displayName": user.get("displayName"),
"photoURL": user.get("photoURL"), "photoURL": user.get("photoURL"),
"theme": user.get("theme", "light"), "theme": user.get("theme", "light"),
"backgroundImage": user.get("backgroundImage"),
"backgroundImages": user.get("backgroundImages", []),
"reminder": user.get("reminder"),
"tutorial": user.get("tutorial"),
"createdAt": user["createdAt"].isoformat(), "createdAt": user["createdAt"].isoformat(),
"updatedAt": user["updatedAt"].isoformat() "updatedAt": user["updatedAt"].isoformat()
} }
except HTTPException:
raise
except Exception as e: except Exception as e:
raise HTTPException( raise HTTPException(
status_code=500, detail=f"Failed to fetch user: {str(e)}") status_code=500, detail=f"Failed to fetch user: {str(e)}")
@@ -91,7 +102,12 @@ async def get_user_by_id(user_id: str):
db = get_database() db = get_database()
try: try:
user = db.users.find_one({"_id": ObjectId(user_id)}) user_oid = ObjectId(user_id)
except InvalidId:
raise HTTPException(status_code=400, detail="Invalid user ID format")
try:
user = db.users.find_one({"_id": user_oid})
if not user: if not user:
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
@@ -101,13 +117,14 @@ async def get_user_by_id(user_id: str):
"displayName": user.get("displayName"), "displayName": user.get("displayName"),
"photoURL": user.get("photoURL"), "photoURL": user.get("photoURL"),
"theme": user.get("theme", "light"), "theme": user.get("theme", "light"),
"backgroundImage": user.get("backgroundImage"),
"backgroundImages": user.get("backgroundImages", []),
"createdAt": user["createdAt"].isoformat(), "createdAt": user["createdAt"].isoformat(),
"updatedAt": user["updatedAt"].isoformat() "updatedAt": user["updatedAt"].isoformat()
} }
except HTTPException:
raise
except Exception as e: except Exception as e:
if "invalid ObjectId" in str(e).lower():
raise HTTPException(
status_code=400, detail="Invalid user ID format")
raise HTTPException( raise HTTPException(
status_code=500, detail=f"Failed to fetch user: {str(e)}") status_code=500, detail=f"Failed to fetch user: {str(e)}")
@@ -117,13 +134,18 @@ async def update_user(user_id: str, user_data: UserUpdate):
"""Update user profile.""" """Update user profile."""
db = get_database() db = get_database()
try:
user_oid = ObjectId(user_id)
except InvalidId:
raise HTTPException(status_code=400, detail="Invalid user ID format")
try: try:
# Prepare update data (exclude None values) # Prepare update data (exclude None values)
update_data = user_data.model_dump(exclude_unset=True) update_data = user_data.model_dump(exclude_unset=True)
update_data["updatedAt"] = datetime.utcnow() update_data["updatedAt"] = datetime.utcnow()
result = db.users.update_one( result = db.users.update_one(
{"_id": ObjectId(user_id)}, {"_id": user_oid},
{"$set": update_data} {"$set": update_data}
) )
@@ -131,21 +153,23 @@ async def update_user(user_id: str, user_data: UserUpdate):
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
# Fetch and return updated user # Fetch and return updated user
user = db.users.find_one({"_id": ObjectId(user_id)}) user = db.users.find_one({"_id": user_oid})
return { return {
"id": str(user["_id"]), "id": str(user["_id"]),
"email": user["email"], "email": user["email"],
"displayName": user.get("displayName"), "displayName": user.get("displayName"),
"photoURL": user.get("photoURL"), "photoURL": user.get("photoURL"),
"theme": user.get("theme", "light"), "theme": user.get("theme", "light"),
"backgroundImage": user.get("backgroundImage"),
"backgroundImages": user.get("backgroundImages", []),
"tutorial": user.get("tutorial"),
"createdAt": user["createdAt"].isoformat(), "createdAt": user["createdAt"].isoformat(),
"updatedAt": user["updatedAt"].isoformat(), "updatedAt": user["updatedAt"].isoformat(),
"message": "User updated successfully" "message": "User updated successfully"
} }
except HTTPException:
raise
except Exception as e: except Exception as e:
if "invalid ObjectId" in str(e).lower():
raise HTTPException(
status_code=400, detail="Invalid user ID format")
raise HTTPException(status_code=500, detail=f"Update failed: {str(e)}") raise HTTPException(status_code=500, detail=f"Update failed: {str(e)}")
@@ -154,33 +178,27 @@ async def delete_user(user_id: str):
"""Delete user account and all associated data.""" """Delete user account and all associated data."""
db = get_database() db = get_database()
try:
user_oid = ObjectId(user_id)
except InvalidId:
raise HTTPException(status_code=400, detail="Invalid user ID format")
try: try:
# Delete user # Delete user
user_result = db.users.delete_one({"_id": ObjectId(user_id)}) user_result = db.users.delete_one({"_id": user_oid})
if user_result.deleted_count == 0: if user_result.deleted_count == 0:
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
# Delete all user's entries # Delete all user's entries
entry_result = db.entries.delete_many({"userId": ObjectId(user_id)}) entry_result = db.entries.delete_many({"userId": user_oid})
return { return {
"message": "User deleted successfully", "message": "User deleted successfully",
"user_deleted": user_result.deleted_count, "user_deleted": user_result.deleted_count,
"entries_deleted": entry_result.deleted_count "entries_deleted": entry_result.deleted_count
} }
except HTTPException:
raise
except Exception as e: except Exception as e:
if "invalid ObjectId" in str(e).lower():
raise HTTPException(
status_code=400, detail="Invalid user ID format")
raise HTTPException( raise HTTPException(
status_code=500, detail=f"Deletion failed: {str(e)}") status_code=500, detail=f"Deletion failed: {str(e)}")
# Delete all entries by user
db.entries.delete_many({"userId": user_id})
# Delete user settings
db.settings.delete_one({"userId": user_id})
return {"message": "User and associated data deleted"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

202
backend/scheduler.py Normal file
View File

@@ -0,0 +1,202 @@
"""
Daily reminder scheduler.
Runs every minute. For each user with an enabled reminder:
- Converts current UTC time to the user's local timezone
- Checks if the current HH:MM matches their reminder time
- Checks if they already got a notification today (avoids duplicates)
- Checks if they have already written a journal entry today
- If not, sends an FCM push notification to all their registered devices
"""
import json
import logging
from datetime import datetime, timedelta
import pytz
import firebase_admin
from firebase_admin import credentials, messaging
from apscheduler.schedulers.background import BackgroundScheduler
from config import get_settings
from db import get_database
log = logging.getLogger(__name__)
_firebase_initialized = False
def init_firebase():
"""Initialize Firebase Admin SDK once using the service account JSON from env."""
global _firebase_initialized
if _firebase_initialized:
return
settings = get_settings()
if not settings.firebase_service_account_json:
log.warning("FIREBASE_SERVICE_ACCOUNT_JSON not set — push notifications disabled")
return
try:
sa_dict = json.loads(settings.firebase_service_account_json)
cred = credentials.Certificate(sa_dict)
firebase_admin.initialize_app(cred)
_firebase_initialized = True
log.info("Firebase Admin SDK initialized")
except Exception as e:
log.error(f"Failed to initialize Firebase Admin SDK: {e}")
def send_reminder_notifications():
"""Check all users and send reminders where due."""
if not _firebase_initialized:
log.warning("Reminder check skipped — Firebase not initialized")
return
db = get_database()
now_utc = datetime.utcnow().replace(second=0, microsecond=0)
candidates = list(db.users.find({
"reminder.enabled": True,
"fcmTokens": {"$exists": True, "$not": {"$size": 0}},
}))
log.debug(f"Reminder check at {now_utc.strftime('%H:%M')} UTC — {len(candidates)} candidate(s)")
for user in candidates:
try:
if user.get("reminder", {}).get("time"):
_process_user(db, user, now_utc)
_process_universal(db, user, now_utc)
except Exception as e:
log.error(f"Error processing reminder for user {user.get('_id')}: {e}")
def _get_user_local_time(now_utc: datetime, timezone_str: str):
"""Returns (now_local, today_str, user_tz)."""
try:
user_tz = pytz.timezone(timezone_str)
except pytz.UnknownTimeZoneError:
user_tz = pytz.utc
now_local = now_utc.replace(tzinfo=pytz.utc).astimezone(user_tz)
today_str = now_local.strftime("%Y-%m-%d")
return now_local, today_str, user_tz
def _wrote_today(db, user_id, now_local, user_tz) -> bool:
today_start_local = now_local.replace(hour=0, minute=0, second=0, microsecond=0)
today_start_utc = today_start_local.astimezone(pytz.utc).replace(tzinfo=None)
today_end_utc = today_start_utc + timedelta(days=1)
return db.entries.count_documents({
"userId": user_id,
"createdAt": {"$gte": today_start_utc, "$lt": today_end_utc},
}) > 0
def _process_user(db, user: dict, now_utc: datetime):
uid = user.get("_id")
reminder = user.get("reminder", {})
reminder_time_str = reminder.get("time")
timezone_str = reminder.get("timezone", "UTC")
fcm_tokens: list = user.get("fcmTokens", [])
if not reminder_time_str or not fcm_tokens:
return
now_local, today_str, user_tz = _get_user_local_time(now_utc, timezone_str)
current_hm = now_local.strftime("%H:%M")
if current_hm != reminder_time_str:
log.debug(f"User {uid}: skipped — current time {current_hm} != reminder time {reminder_time_str} ({timezone_str})")
return
if _wrote_today(db, uid, now_local, user_tz):
log.debug(f"User {uid}: skipped — already wrote today")
return
log.info(f"User {uid}: sending reminder (time={reminder_time_str}, tz={timezone_str})")
_send_push(uid, fcm_tokens, db)
def _process_universal(db, user: dict, now_utc: datetime):
"""Universal 11pm reminder — fires if enabled and no entry written today."""
uid = user.get("_id")
reminder = user.get("reminder", {})
timezone_str = reminder.get("timezone", "UTC")
fcm_tokens: list = user.get("fcmTokens", [])
if not fcm_tokens:
return
now_local, today_str, user_tz = _get_user_local_time(now_utc, timezone_str)
if now_local.strftime("%H:%M") != "23:00":
return
if reminder.get("lastUniversalDate") == today_str:
log.debug(f"User {uid}: universal reminder skipped — already sent today")
return
if _wrote_today(db, uid, now_local, user_tz):
log.debug(f"User {uid}: universal reminder skipped — already wrote today")
db.users.update_one({"_id": uid}, {"$set": {"reminder.lastUniversalDate": today_str}})
return
log.info(f"User {uid}: sending universal 11pm reminder (tz={timezone_str})")
_send_push(uid, fcm_tokens, db, universal=True)
db.users.update_one({"_id": uid}, {"$set": {"reminder.lastUniversalDate": today_str}})
def _send_push(user_id, tokens: list, db, universal: bool = False):
"""Send FCM multicast and prune stale tokens."""
title = "Last chance to journal today 🌙" if universal else "Time to journal 🌱"
message = messaging.MulticastMessage(
notification=messaging.Notification(
title=title,
body="You haven't written today yet. Take a moment to reflect.",
),
tokens=tokens,
android=messaging.AndroidConfig(priority="high"),
apns=messaging.APNSConfig(
payload=messaging.APNSPayload(
aps=messaging.Aps(sound="default")
)
),
webpush=messaging.WebpushConfig(
notification=messaging.WebpushNotification(
icon="/web-app-manifest-192x192.png",
badge="/favicon-96x96.png",
tag="gj-daily-reminder",
)
),
)
response = messaging.send_each_for_multicast(message)
log.info(f"Reminder sent to user {user_id}: {response.success_count} ok, {response.failure_count} failed")
stale_tokens = [
tokens[i] for i, r in enumerate(response.responses)
if not r.success and r.exception and "not-registered" in str(r.exception).lower()
]
if stale_tokens:
db.users.update_one(
{"_id": user_id},
{"$pullAll": {"fcmTokens": stale_tokens}}
)
log.info(f"Removed {len(stale_tokens)} stale FCM tokens for user {user_id}")
def start_scheduler() -> BackgroundScheduler:
"""Initialize Firebase and start the minute-by-minute scheduler."""
init_firebase()
scheduler = BackgroundScheduler(timezone="UTC")
scheduler.add_job(
send_reminder_notifications,
trigger="cron",
minute="*", # every minute
id="daily_reminders",
replace_existing=True,
)
scheduler.start()
log.info("Reminder scheduler started")
return scheduler

View File

41
backend/tests/conftest.py Normal file
View File

@@ -0,0 +1,41 @@
"""
Shared pytest fixtures for all backend tests.
Strategy:
- Use mongomock to create an in-memory MongoDB per test.
- Directly set MongoDB.db to the mock database so get_database() returns it.
- Patch MongoDB.connect_db / close_db so FastAPI's lifespan doesn't try
to connect to a real MongoDB server.
"""
import pytest
import mongomock
from unittest.mock import patch
from fastapi.testclient import TestClient
@pytest.fixture
def mock_db():
"""Fresh in-memory MongoDB database for each test."""
client = mongomock.MongoClient()
return client["test_grateful_journal"]
@pytest.fixture
def client(mock_db):
"""
FastAPI TestClient with MongoDB replaced by an in-memory mock.
Yields (TestClient, mock_db) so tests can inspect the database directly.
"""
from db import MongoDB
from main import app
with (
patch.object(MongoDB, "connect_db"),
patch.object(MongoDB, "close_db"),
):
MongoDB.db = mock_db
with TestClient(app) as c:
yield c, mock_db
MongoDB.db = None

View File

@@ -0,0 +1,454 @@
"""Tests for journal entry endpoints (/api/entries/*)."""
import pytest
# ---------------------------------------------------------------------------
# Shared helpers
# ---------------------------------------------------------------------------
VALID_ENCRYPTION = {
"encrypted": True,
"ciphertext": "dGVzdF9jaXBoZXJ0ZXh0", # base64("test_ciphertext")
"nonce": "dGVzdF9ub25jZQ==", # base64("test_nonce")
"algorithm": "XSalsa20-Poly1305",
}
@pytest.fixture
def user(client):
"""Register and return a test user."""
c, _ = client
response = c.post("/api/users/register", json={"email": "entry_test@example.com"})
return response.json()
@pytest.fixture
def entry(client, user):
"""Create and return a test entry."""
c, _ = client
response = c.post(f"/api/entries/{user['id']}", json={"encryption": VALID_ENCRYPTION})
assert response.status_code == 200
return response.json()
# ---------------------------------------------------------------------------
# POST /api/entries/{user_id}
# ---------------------------------------------------------------------------
class TestCreateEntry:
def test_create_encrypted_entry_returns_200(self, client, user):
c, _ = client
response = c.post(f"/api/entries/{user['id']}", json={"encryption": VALID_ENCRYPTION})
assert response.status_code == 200
def test_create_entry_returns_id_and_message(self, client, user):
c, _ = client
response = c.post(f"/api/entries/{user['id']}", json={"encryption": VALID_ENCRYPTION})
data = response.json()
assert "id" in data
assert data["message"] == "Entry created successfully"
def test_create_entry_with_mood(self, client, user):
c, _ = client
response = c.post(f"/api/entries/{user['id']}", json={
"encryption": VALID_ENCRYPTION,
"mood": "grateful",
})
assert response.status_code == 200
def test_create_entry_with_invalid_mood_returns_422(self, client, user):
c, _ = client
response = c.post(f"/api/entries/{user['id']}", json={
"encryption": VALID_ENCRYPTION,
"mood": "ecstatic", # Not in MoodEnum
})
assert response.status_code == 422
def test_create_entry_with_tags(self, client, user):
c, _ = client
response = c.post(f"/api/entries/{user['id']}", json={
"encryption": VALID_ENCRYPTION,
"tags": ["family", "gratitude"],
})
assert response.status_code == 200
def test_create_entry_missing_ciphertext_returns_400(self, client, user):
"""Encryption metadata without ciphertext must be rejected."""
c, _ = client
response = c.post(f"/api/entries/{user['id']}", json={
"encryption": {
"encrypted": True,
"nonce": "bm9uY2U=",
"algorithm": "XSalsa20-Poly1305",
# ciphertext intentionally missing
}
})
# Pydantic requires ciphertext field → 422
assert response.status_code == 422
def test_create_entry_encryption_missing_nonce_returns_400(self, client, user):
c, _ = client
response = c.post(f"/api/entries/{user['id']}", json={
"encryption": {
"encrypted": True,
"ciphertext": "dGVzdA==",
"algorithm": "XSalsa20-Poly1305",
# nonce intentionally missing
}
})
assert response.status_code == 422
def test_create_entry_for_nonexistent_user_returns_404(self, client):
c, _ = client
response = c.post("/api/entries/507f1f77bcf86cd799439011", json={"encryption": VALID_ENCRYPTION})
assert response.status_code == 404
assert "User not found" in response.json()["detail"]
def test_create_entry_with_invalid_user_id_returns_400(self, client):
c, _ = client
response = c.post("/api/entries/not-a-valid-id", json={"encryption": VALID_ENCRYPTION})
assert response.status_code == 400
def test_create_entry_with_specific_entry_date(self, client, user):
c, _ = client
response = c.post(f"/api/entries/{user['id']}", json={
"encryption": VALID_ENCRYPTION,
"entryDate": "2024-06-15T00:00:00",
})
assert response.status_code == 200
# ---------------------------------------------------------------------------
# GET /api/entries/{user_id}
# ---------------------------------------------------------------------------
class TestGetUserEntries:
def test_returns_entries_and_pagination(self, client, user, entry):
c, _ = client
response = c.get(f"/api/entries/{user['id']}")
assert response.status_code == 200
data = response.json()
assert "entries" in data
assert "pagination" in data
def test_returns_entry_that_was_created(self, client, user, entry):
c, _ = client
response = c.get(f"/api/entries/{user['id']}")
entries = response.json()["entries"]
assert len(entries) == 1
assert entries[0]["id"] == entry["id"]
def test_entry_includes_encryption_metadata(self, client, user, entry):
c, _ = client
response = c.get(f"/api/entries/{user['id']}")
fetched_entry = response.json()["entries"][0]
assert fetched_entry["encryption"]["ciphertext"] == VALID_ENCRYPTION["ciphertext"]
assert fetched_entry["encryption"]["nonce"] == VALID_ENCRYPTION["nonce"]
def test_empty_list_when_no_entries(self, client, user):
c, _ = client
response = c.get(f"/api/entries/{user['id']}")
assert response.status_code == 200
assert response.json()["entries"] == []
assert response.json()["pagination"]["total"] == 0
def test_pagination_limit(self, client, user):
c, _ = client
for _ in range(5):
c.post(f"/api/entries/{user['id']}", json={"encryption": VALID_ENCRYPTION})
response = c.get(f"/api/entries/{user['id']}?limit=2&skip=0")
assert response.status_code == 200
data = response.json()
assert len(data["entries"]) == 2
assert data["pagination"]["hasMore"] is True
assert data["pagination"]["total"] == 5
def test_pagination_skip(self, client, user):
c, _ = client
for _ in range(4):
c.post(f"/api/entries/{user['id']}", json={"encryption": VALID_ENCRYPTION})
response = c.get(f"/api/entries/{user['id']}?limit=10&skip=3")
assert len(response.json()["entries"]) == 1
def test_pagination_has_more_false_at_end(self, client, user):
c, _ = client
for _ in range(3):
c.post(f"/api/entries/{user['id']}", json={"encryption": VALID_ENCRYPTION})
response = c.get(f"/api/entries/{user['id']}?limit=10&skip=0")
assert response.json()["pagination"]["hasMore"] is False
def test_nonexistent_user_returns_404(self, client):
c, _ = client
response = c.get("/api/entries/507f1f77bcf86cd799439011")
assert response.status_code == 404
def test_invalid_user_id_returns_400(self, client):
c, _ = client
response = c.get("/api/entries/bad-id")
assert response.status_code == 400
# ---------------------------------------------------------------------------
# GET /api/entries/{user_id}/{entry_id}
# ---------------------------------------------------------------------------
class TestGetSingleEntry:
def test_returns_entry_by_id(self, client, user, entry):
c, _ = client
response = c.get(f"/api/entries/{user['id']}/{entry['id']}")
assert response.status_code == 200
assert response.json()["id"] == entry["id"]
def test_returned_entry_has_encryption_field(self, client, user, entry):
c, _ = client
response = c.get(f"/api/entries/{user['id']}/{entry['id']}")
data = response.json()
assert "encryption" in data
assert data["encryption"]["ciphertext"] == VALID_ENCRYPTION["ciphertext"]
def test_entry_belongs_to_correct_user(self, client, user, entry):
c, _ = client
response = c.get(f"/api/entries/{user['id']}/{entry['id']}")
assert response.json()["userId"] == user["id"]
def test_entry_from_different_user_returns_404(self, client, user, entry):
"""User isolation: another user cannot access this entry."""
c, _ = client
other = c.post("/api/users/register", json={"email": "other@example.com"}).json()
response = c.get(f"/api/entries/{other['id']}/{entry['id']}")
assert response.status_code == 404
def test_nonexistent_entry_returns_404(self, client, user):
c, _ = client
response = c.get(f"/api/entries/{user['id']}/507f1f77bcf86cd799439099")
assert response.status_code == 404
def test_invalid_entry_id_returns_400(self, client, user):
c, _ = client
response = c.get(f"/api/entries/{user['id']}/not-valid-id")
assert response.status_code == 400
def test_invalid_user_id_returns_400(self, client, entry):
c, _ = client
response = c.get(f"/api/entries/bad-user-id/{entry['id']}")
assert response.status_code == 400
# ---------------------------------------------------------------------------
# PUT /api/entries/{user_id}/{entry_id}
# ---------------------------------------------------------------------------
class TestUpdateEntry:
def test_update_mood(self, client, user, entry):
c, _ = client
response = c.put(f"/api/entries/{user['id']}/{entry['id']}", json={"mood": "happy"})
assert response.status_code == 200
assert response.json()["mood"] == "happy"
def test_update_encryption_ciphertext(self, client, user, entry):
c, _ = client
new_enc = {**VALID_ENCRYPTION, "ciphertext": "bmV3Y2lwaGVydGV4dA=="}
response = c.put(f"/api/entries/{user['id']}/{entry['id']}", json={"encryption": new_enc})
assert response.status_code == 200
assert response.json()["encryption"]["ciphertext"] == "bmV3Y2lwaGVydGV4dA=="
def test_update_persists(self, client, user, entry):
c, _ = client
c.put(f"/api/entries/{user['id']}/{entry['id']}", json={"mood": "sad"})
response = c.get(f"/api/entries/{user['id']}/{entry['id']}")
assert response.json()["mood"] == "sad"
def test_update_invalid_mood_returns_422(self, client, user, entry):
c, _ = client
response = c.put(f"/api/entries/{user['id']}/{entry['id']}", json={"mood": "furious"})
assert response.status_code == 422
def test_update_nonexistent_entry_returns_404(self, client, user):
c, _ = client
response = c.put(f"/api/entries/{user['id']}/507f1f77bcf86cd799439099", json={"mood": "happy"})
assert response.status_code == 404
def test_update_invalid_entry_id_returns_400(self, client, user):
c, _ = client
response = c.put(f"/api/entries/{user['id']}/bad-id", json={"mood": "happy"})
assert response.status_code == 400
# ---------------------------------------------------------------------------
# DELETE /api/entries/{user_id}/{entry_id}
# ---------------------------------------------------------------------------
class TestDeleteEntry:
def test_delete_entry_returns_200(self, client, user, entry):
c, _ = client
response = c.delete(f"/api/entries/{user['id']}/{entry['id']}")
assert response.status_code == 200
assert "deleted" in response.json()["message"].lower()
def test_deleted_entry_is_not_retrievable(self, client, user, entry):
c, _ = client
c.delete(f"/api/entries/{user['id']}/{entry['id']}")
response = c.get(f"/api/entries/{user['id']}/{entry['id']}")
assert response.status_code == 404
def test_deleted_entry_not_in_list(self, client, user, entry):
c, _ = client
c.delete(f"/api/entries/{user['id']}/{entry['id']}")
response = c.get(f"/api/entries/{user['id']}")
assert response.json()["entries"] == []
def test_delete_entry_wrong_user_returns_404(self, client, user, entry):
"""User isolation: another user cannot delete this entry."""
c, _ = client
other = c.post("/api/users/register", json={"email": "other_del@example.com"}).json()
response = c.delete(f"/api/entries/{other['id']}/{entry['id']}")
assert response.status_code == 404
def test_delete_nonexistent_entry_returns_404(self, client, user):
c, _ = client
response = c.delete(f"/api/entries/{user['id']}/507f1f77bcf86cd799439099")
assert response.status_code == 404
def test_delete_invalid_entry_id_returns_400(self, client, user):
c, _ = client
response = c.delete(f"/api/entries/{user['id']}/bad-id")
assert response.status_code == 400
# ---------------------------------------------------------------------------
# GET /api/entries/{user_id}/by-date/{date_str}
# ---------------------------------------------------------------------------
class TestGetEntriesByDate:
def test_returns_entry_for_matching_date(self, client, user):
c, _ = client
c.post(f"/api/entries/{user['id']}", json={
"encryption": VALID_ENCRYPTION,
"entryDate": "2024-06-15T00:00:00",
})
response = c.get(f"/api/entries/{user['id']}/by-date/2024-06-15")
assert response.status_code == 200
data = response.json()
assert data["count"] == 1
assert data["date"] == "2024-06-15"
def test_returns_empty_for_date_with_no_entries(self, client, user):
c, _ = client
response = c.get(f"/api/entries/{user['id']}/by-date/2020-01-01")
assert response.status_code == 200
assert response.json()["count"] == 0
def test_does_not_return_entries_from_other_dates(self, client, user):
c, _ = client
c.post(f"/api/entries/{user['id']}", json={
"encryption": VALID_ENCRYPTION,
"entryDate": "2024-06-15T00:00:00",
})
response = c.get(f"/api/entries/{user['id']}/by-date/2024-06-16") # Next day
assert response.json()["count"] == 0
def test_invalid_date_format_returns_400(self, client, user):
c, _ = client
response = c.get(f"/api/entries/{user['id']}/by-date/not-a-date")
assert response.status_code == 400
def test_invalid_date_13th_month_returns_400(self, client, user):
c, _ = client
response = c.get(f"/api/entries/{user['id']}/by-date/2024-13-01")
assert response.status_code == 400
def test_invalid_user_id_returns_400(self, client):
c, _ = client
response = c.get("/api/entries/bad-id/by-date/2024-06-15")
assert response.status_code == 400
# ---------------------------------------------------------------------------
# GET /api/entries/{user_id}/by-month/{year}/{month}
# ---------------------------------------------------------------------------
class TestGetEntriesByMonth:
def test_returns_entries_for_matching_month(self, client, user):
c, _ = client
c.post(f"/api/entries/{user['id']}", json={
"encryption": VALID_ENCRYPTION,
"entryDate": "2024-06-15T00:00:00",
})
response = c.get(f"/api/entries/{user['id']}/by-month/2024/6")
assert response.status_code == 200
data = response.json()
assert data["count"] == 1
assert data["year"] == 2024
assert data["month"] == 6
def test_does_not_return_entries_from_other_months(self, client, user):
c, _ = client
c.post(f"/api/entries/{user['id']}", json={
"encryption": VALID_ENCRYPTION,
"entryDate": "2024-05-10T00:00:00", # May, not June
})
response = c.get(f"/api/entries/{user['id']}/by-month/2024/6")
assert response.json()["count"] == 0
def test_december_january_rollover_works(self, client, user):
"""Month 12 boundary (year+1 rollover) must not crash."""
c, _ = client
response = c.get(f"/api/entries/{user['id']}/by-month/2024/12")
assert response.status_code == 200
def test_invalid_month_0_returns_400(self, client, user):
c, _ = client
response = c.get(f"/api/entries/{user['id']}/by-month/2024/0")
assert response.status_code == 400
def test_invalid_month_13_returns_400(self, client, user):
c, _ = client
response = c.get(f"/api/entries/{user['id']}/by-month/2024/13")
assert response.status_code == 400
def test_invalid_user_id_returns_400(self, client):
c, _ = client
response = c.get("/api/entries/bad-id/by-month/2024/6")
assert response.status_code == 400
# ---------------------------------------------------------------------------
# POST /api/entries/convert-timestamp/utc-to-ist
# ---------------------------------------------------------------------------
class TestConvertTimestamp:
def test_converts_utc_z_suffix_to_ist(self, client):
c, _ = client
response = c.post("/api/entries/convert-timestamp/utc-to-ist", json={
"timestamp": "2024-01-01T00:00:00Z"
})
assert response.status_code == 200
data = response.json()
assert "utc" in data
assert "ist" in data
assert "+05:30" in data["ist"]
def test_ist_is_5h30m_ahead_of_utc(self, client):
c, _ = client
response = c.post("/api/entries/convert-timestamp/utc-to-ist", json={
"timestamp": "2024-01-01T00:00:00Z"
})
assert "05:30:00+05:30" in response.json()["ist"]
def test_missing_timestamp_field_returns_400(self, client):
c, _ = client
response = c.post("/api/entries/convert-timestamp/utc-to-ist", json={})
assert response.status_code == 400
def test_invalid_timestamp_string_returns_400(self, client):
c, _ = client
response = c.post("/api/entries/convert-timestamp/utc-to-ist", json={
"timestamp": "not-a-date"
})
assert response.status_code == 400
def test_returns_original_utc_in_response(self, client):
c, _ = client
utc = "2024-06-15T12:00:00Z"
response = c.post("/api/entries/convert-timestamp/utc-to-ist", json={"timestamp": utc})
assert response.json()["utc"] == utc

View File

@@ -0,0 +1,196 @@
"""Tests for Pydantic data models (backend/models.py)."""
import pytest
from pydantic import ValidationError
from models import (
UserCreate,
UserUpdate,
EncryptionMetadata,
JournalEntryCreate,
JournalEntryUpdate,
MoodEnum,
)
# ---------------------------------------------------------------------------
# UserCreate
# ---------------------------------------------------------------------------
class TestUserCreate:
def test_requires_email(self):
with pytest.raises(ValidationError):
UserCreate()
def test_valid_email_only(self):
user = UserCreate(email="test@example.com")
assert user.email == "test@example.com"
def test_display_name_is_optional(self):
user = UserCreate(email="test@example.com")
assert user.displayName is None
def test_photo_url_is_optional(self):
user = UserCreate(email="test@example.com")
assert user.photoURL is None
def test_all_fields(self):
user = UserCreate(
email="test@example.com",
displayName="Alice",
photoURL="https://example.com/pic.jpg",
)
assert user.displayName == "Alice"
assert user.photoURL == "https://example.com/pic.jpg"
# ---------------------------------------------------------------------------
# UserUpdate
# ---------------------------------------------------------------------------
class TestUserUpdate:
def test_all_fields_optional(self):
update = UserUpdate()
assert update.displayName is None
assert update.photoURL is None
assert update.theme is None
def test_update_only_theme(self):
update = UserUpdate(theme="dark")
assert update.theme == "dark"
assert update.displayName is None
def test_update_only_display_name(self):
update = UserUpdate(displayName="New Name")
assert update.displayName == "New Name"
assert update.theme is None
def test_model_dump_excludes_unset(self):
update = UserUpdate(theme="dark")
dumped = update.model_dump(exclude_unset=True)
assert "theme" in dumped
assert "displayName" not in dumped
# ---------------------------------------------------------------------------
# EncryptionMetadata
# ---------------------------------------------------------------------------
class TestEncryptionMetadata:
def test_requires_ciphertext(self):
with pytest.raises(ValidationError):
EncryptionMetadata(nonce="abc")
def test_requires_nonce(self):
with pytest.raises(ValidationError):
EncryptionMetadata(ciphertext="abc")
def test_requires_both_ciphertext_and_nonce(self):
with pytest.raises(ValidationError):
EncryptionMetadata()
def test_default_algorithm_is_xsalsa20(self):
meta = EncryptionMetadata(ciphertext="abc", nonce="xyz")
assert meta.algorithm == "XSalsa20-Poly1305"
def test_default_encrypted_is_true(self):
meta = EncryptionMetadata(ciphertext="abc", nonce="xyz")
assert meta.encrypted is True
def test_valid_full_metadata(self):
meta = EncryptionMetadata(
encrypted=True,
ciphertext="dGVzdA==",
nonce="bm9uY2U=",
algorithm="XSalsa20-Poly1305",
)
assert meta.ciphertext == "dGVzdA=="
assert meta.nonce == "bm9uY2U="
def test_custom_algorithm_accepted(self):
meta = EncryptionMetadata(ciphertext="abc", nonce="xyz", algorithm="AES-256-GCM")
assert meta.algorithm == "AES-256-GCM"
# ---------------------------------------------------------------------------
# JournalEntryCreate
# ---------------------------------------------------------------------------
class TestJournalEntryCreate:
def test_all_fields_optional(self):
entry = JournalEntryCreate()
assert entry.title is None
assert entry.content is None
assert entry.encryption is None
assert entry.mood is None
def test_encrypted_entry_has_no_plaintext(self):
"""Encrypted entries legitimately have no title or content."""
entry = JournalEntryCreate(
encryption=EncryptionMetadata(ciphertext="abc", nonce="xyz")
)
assert entry.title is None
assert entry.content is None
assert entry.encryption is not None
def test_valid_mood_values(self):
for mood in ("happy", "sad", "neutral", "anxious", "grateful"):
entry = JournalEntryCreate(mood=mood)
assert entry.mood == mood
def test_invalid_mood_raises_validation_error(self):
with pytest.raises(ValidationError):
JournalEntryCreate(mood="ecstatic")
def test_default_is_public_is_false(self):
entry = JournalEntryCreate()
assert entry.isPublic is False
def test_tags_default_is_none(self):
entry = JournalEntryCreate()
assert entry.tags is None
def test_tags_list_accepted(self):
entry = JournalEntryCreate(tags=["family", "work", "health"])
assert entry.tags == ["family", "work", "health"]
# ---------------------------------------------------------------------------
# JournalEntryUpdate
# ---------------------------------------------------------------------------
class TestJournalEntryUpdate:
def test_all_fields_optional(self):
update = JournalEntryUpdate()
assert update.title is None
assert update.mood is None
def test_update_mood_only(self):
update = JournalEntryUpdate(mood="happy")
dumped = update.model_dump(exclude_unset=True)
assert dumped == {"mood": MoodEnum.happy}
def test_invalid_mood_raises_error(self):
with pytest.raises(ValidationError):
JournalEntryUpdate(mood="angry")
def test_update_encryption(self):
update = JournalEntryUpdate(
encryption=EncryptionMetadata(ciphertext="new_ct", nonce="new_nonce")
)
assert update.encryption.ciphertext == "new_ct"
# ---------------------------------------------------------------------------
# MoodEnum
# ---------------------------------------------------------------------------
class TestMoodEnum:
def test_all_enum_values(self):
assert MoodEnum.happy == "happy"
assert MoodEnum.sad == "sad"
assert MoodEnum.neutral == "neutral"
assert MoodEnum.anxious == "anxious"
assert MoodEnum.grateful == "grateful"
def test_enum_used_in_entry_create(self):
entry = JournalEntryCreate(mood=MoodEnum.grateful)
assert entry.mood == "grateful"

236
backend/tests/test_users.py Normal file
View File

@@ -0,0 +1,236 @@
"""Tests for user management endpoints (/api/users/*)."""
import pytest
# ---------------------------------------------------------------------------
# Shared fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def registered_user(client):
"""Register a test user and return the API response data."""
c, _ = client
response = c.post("/api/users/register", json={
"email": "test@example.com",
"displayName": "Test User",
"photoURL": "https://example.com/photo.jpg",
})
assert response.status_code == 200
return response.json()
# ---------------------------------------------------------------------------
# POST /api/users/register
# ---------------------------------------------------------------------------
class TestRegisterUser:
def test_register_new_user_returns_200(self, client):
c, _ = client
response = c.post("/api/users/register", json={"email": "new@example.com", "displayName": "New User"})
assert response.status_code == 200
def test_register_returns_user_fields(self, client):
c, _ = client
response = c.post("/api/users/register", json={"email": "new@example.com", "displayName": "New User"})
data = response.json()
assert data["email"] == "new@example.com"
assert data["displayName"] == "New User"
assert "id" in data
assert "createdAt" in data
assert "updatedAt" in data
def test_register_returns_registered_message(self, client):
c, _ = client
response = c.post("/api/users/register", json={"email": "brand_new@example.com"})
assert response.json()["message"] == "User registered successfully"
def test_register_existing_user_is_idempotent(self, client):
c, _ = client
payload = {"email": "existing@example.com"}
c.post("/api/users/register", json=payload)
response = c.post("/api/users/register", json=payload)
assert response.status_code == 200
assert response.json()["message"] == "User already exists"
def test_register_idempotent_returns_same_id(self, client):
c, _ = client
payload = {"email": "same@example.com"}
r1 = c.post("/api/users/register", json=payload).json()
r2 = c.post("/api/users/register", json=payload).json()
assert r1["id"] == r2["id"]
def test_register_uses_email_prefix_as_default_display_name(self, client):
c, _ = client
response = c.post("/api/users/register", json={"email": "johndoe@example.com"})
assert response.json()["displayName"] == "johndoe"
def test_register_default_theme_is_light(self, client):
c, _ = client
response = c.post("/api/users/register", json={"email": "x@example.com"})
assert response.json()["theme"] == "light"
def test_register_missing_email_returns_422(self, client):
c, _ = client
response = c.post("/api/users/register", json={"displayName": "No Email"})
assert response.status_code == 422
def test_register_without_optional_fields(self, client):
c, _ = client
response = c.post("/api/users/register", json={"email": "minimal@example.com"})
assert response.status_code == 200
assert response.json()["photoURL"] is None
# ---------------------------------------------------------------------------
# GET /api/users/by-email/{email}
# ---------------------------------------------------------------------------
class TestGetUserByEmail:
def test_returns_existing_user(self, client, registered_user):
c, _ = client
email = registered_user["email"]
response = c.get(f"/api/users/by-email/{email}")
assert response.status_code == 200
assert response.json()["email"] == email
def test_returns_all_user_fields(self, client, registered_user):
c, _ = client
response = c.get(f"/api/users/by-email/{registered_user['email']}")
data = response.json()
for field in ("id", "email", "displayName", "theme", "createdAt", "updatedAt"):
assert field in data
def test_nonexistent_email_returns_404(self, client):
c, _ = client
response = c.get("/api/users/by-email/ghost@example.com")
assert response.status_code == 404
assert "User not found" in response.json()["detail"]
# ---------------------------------------------------------------------------
# GET /api/users/{user_id}
# ---------------------------------------------------------------------------
class TestGetUserById:
def test_returns_existing_user(self, client, registered_user):
c, _ = client
user_id = registered_user["id"]
response = c.get(f"/api/users/{user_id}")
assert response.status_code == 200
assert response.json()["id"] == user_id
def test_invalid_object_id_format_returns_400(self, client):
c, _ = client
response = c.get("/api/users/not-a-valid-objectid")
assert response.status_code == 400
def test_nonexistent_valid_id_returns_404(self, client):
c, _ = client
response = c.get("/api/users/507f1f77bcf86cd799439011")
assert response.status_code == 404
# ---------------------------------------------------------------------------
# PUT /api/users/{user_id}
# ---------------------------------------------------------------------------
class TestUpdateUser:
def test_update_display_name(self, client, registered_user):
c, _ = client
user_id = registered_user["id"]
response = c.put(f"/api/users/{user_id}", json={"displayName": "Updated Name"})
assert response.status_code == 200
assert response.json()["displayName"] == "Updated Name"
def test_update_theme_to_dark(self, client, registered_user):
c, _ = client
user_id = registered_user["id"]
response = c.put(f"/api/users/{user_id}", json={"theme": "dark"})
assert response.status_code == 200
assert response.json()["theme"] == "dark"
def test_update_photo_url(self, client, registered_user):
c, _ = client
user_id = registered_user["id"]
new_url = "https://new-photo.example.com/pic.jpg"
response = c.put(f"/api/users/{user_id}", json={"photoURL": new_url})
assert response.status_code == 200
assert response.json()["photoURL"] == new_url
def test_update_persists_to_database(self, client, registered_user):
c, _ = client
user_id = registered_user["id"]
c.put(f"/api/users/{user_id}", json={"displayName": "Persisted Name"})
response = c.get(f"/api/users/{user_id}")
assert response.json()["displayName"] == "Persisted Name"
def test_partial_update_does_not_clear_other_fields(self, client, registered_user):
c, _ = client
user_id = registered_user["id"]
# Update only theme
c.put(f"/api/users/{user_id}", json={"theme": "dark"})
response = c.get(f"/api/users/{user_id}")
data = response.json()
assert data["theme"] == "dark"
assert data["displayName"] == "Test User" # original value preserved
def test_update_nonexistent_user_returns_404(self, client):
c, _ = client
response = c.put("/api/users/507f1f77bcf86cd799439011", json={"displayName": "X"})
assert response.status_code == 404
def test_update_invalid_id_format_returns_400(self, client):
c, _ = client
response = c.put("/api/users/bad-id", json={"displayName": "X"})
assert response.status_code == 400
# ---------------------------------------------------------------------------
# DELETE /api/users/{user_id}
# ---------------------------------------------------------------------------
class TestDeleteUser:
def test_delete_user_returns_200(self, client, registered_user):
c, _ = client
response = c.delete(f"/api/users/{registered_user['id']}")
assert response.status_code == 200
def test_delete_user_returns_deletion_counts(self, client, registered_user):
c, _ = client
response = c.delete(f"/api/users/{registered_user['id']}")
data = response.json()
assert data["user_deleted"] == 1
assert "entries_deleted" in data
def test_delete_user_makes_them_unretrievable(self, client, registered_user):
c, _ = client
user_id = registered_user["id"]
c.delete(f"/api/users/{user_id}")
response = c.get(f"/api/users/{user_id}")
assert response.status_code == 404
def test_delete_user_also_deletes_their_entries(self, client, registered_user):
c, _ = client
user_id = registered_user["id"]
# Create 2 entries for this user
for _ in range(2):
c.post(f"/api/entries/{user_id}", json={
"encryption": {
"encrypted": True,
"ciphertext": "dGVzdA==",
"nonce": "bm9uY2U=",
"algorithm": "XSalsa20-Poly1305",
}
})
response = c.delete(f"/api/users/{user_id}")
assert response.json()["entries_deleted"] == 2
def test_delete_nonexistent_user_returns_404(self, client):
c, _ = client
response = c.delete("/api/users/507f1f77bcf86cd799439011")
assert response.status_code == 404
def test_delete_invalid_id_format_returns_400(self, client):
c, _ = client
response = c.delete("/api/users/bad-id")
assert response.status_code == 400

View File

@@ -0,0 +1,89 @@
"""Tests for utility functions (backend/utils.py)."""
import pytest
from datetime import datetime, timezone, timedelta
from utils import utc_to_ist, format_ist_timestamp
IST = timezone(timedelta(hours=5, minutes=30))
class TestUtcToIst:
def test_midnight_utc_becomes_530_ist(self):
utc = datetime(2024, 1, 1, 0, 0, 0)
ist = utc_to_ist(utc)
assert ist.hour == 5
assert ist.minute == 30
def test_adds_five_hours_thirty_minutes(self):
utc = datetime(2024, 6, 15, 10, 0, 0)
ist = utc_to_ist(utc)
assert ist.hour == 15
assert ist.minute == 30
def test_rolls_over_to_next_day(self):
utc = datetime(2024, 1, 1, 22, 0, 0) # 22:00 UTC → 03:30 next day IST
ist = utc_to_ist(utc)
assert ist.day == 2
assert ist.hour == 3
assert ist.minute == 30
def test_rolls_over_to_next_month(self):
utc = datetime(2024, 1, 31, 23, 0, 0) # Jan 31 → Feb 1 IST
ist = utc_to_ist(utc)
assert ist.month == 2
assert ist.day == 1
def test_output_has_ist_timezone_offset(self):
utc = datetime(2024, 1, 1, 12, 0, 0)
ist = utc_to_ist(utc)
assert ist.utcoffset() == timedelta(hours=5, minutes=30)
def test_preserves_seconds(self):
utc = datetime(2024, 3, 15, 8, 45, 30)
ist = utc_to_ist(utc)
assert ist.second == 30
def test_noon_utc_is_1730_ist(self):
utc = datetime(2024, 7, 4, 12, 0, 0)
ist = utc_to_ist(utc)
assert ist.hour == 17
assert ist.minute == 30
class TestFormatIstTimestamp:
def test_converts_z_suffix_timestamp(self):
result = format_ist_timestamp("2024-01-01T00:00:00Z")
assert "+05:30" in result
def test_converts_explicit_utc_offset_timestamp(self):
result = format_ist_timestamp("2024-01-01T00:00:00+00:00")
assert "+05:30" in result
def test_midnight_utc_produces_0530_ist(self):
result = format_ist_timestamp("2024-01-01T00:00:00Z")
assert "05:30:00+05:30" in result
def test_noon_utc_produces_1730_ist(self):
result = format_ist_timestamp("2024-01-01T12:00:00Z")
assert "17:30:00+05:30" in result
def test_returns_iso_format_string(self):
result = format_ist_timestamp("2024-06-15T08:00:00Z")
# Should be parseable as ISO datetime
parsed = datetime.fromisoformat(result)
assert parsed is not None
def test_invalid_text_raises_value_error(self):
with pytest.raises(ValueError):
format_ist_timestamp("not-a-date")
def test_invalid_month_raises_value_error(self):
with pytest.raises(ValueError):
format_ist_timestamp("2024-13-01T00:00:00Z")
def test_empty_string_raises_value_error(self):
with pytest.raises(ValueError):
format_ist_timestamp("")
def test_slash_separated_date_raises_value_error(self):
with pytest.raises(ValueError):
format_ist_timestamp("2024/01/01T00:00:00") # Slashes not valid ISO format

2
deploy.sh Normal file
View File

@@ -0,0 +1,2 @@
#!/bin/bash
git pull && docker-compose down && docker-compose up -d --build

View File

@@ -10,6 +10,7 @@ services:
VITE_FIREBASE_STORAGE_BUCKET: ${VITE_FIREBASE_STORAGE_BUCKET} VITE_FIREBASE_STORAGE_BUCKET: ${VITE_FIREBASE_STORAGE_BUCKET}
VITE_FIREBASE_MESSAGING_SENDER_ID: ${VITE_FIREBASE_MESSAGING_SENDER_ID} VITE_FIREBASE_MESSAGING_SENDER_ID: ${VITE_FIREBASE_MESSAGING_SENDER_ID}
VITE_FIREBASE_APP_ID: ${VITE_FIREBASE_APP_ID} VITE_FIREBASE_APP_ID: ${VITE_FIREBASE_APP_ID}
VITE_FIREBASE_VAPID_KEY: ${VITE_FIREBASE_VAPID_KEY}
VITE_API_URL: ${VITE_API_URL:-/api} VITE_API_URL: ${VITE_API_URL:-/api}
depends_on: depends_on:
backend: backend:
@@ -18,7 +19,10 @@ services:
- "127.0.0.1:8000:80" - "127.0.0.1:8000:80"
restart: unless-stopped restart: unless-stopped
networks: networks:
- app_net app_net:
workspace_web:
aliases:
- gratefuljournal-app
backend: backend:
build: build:
@@ -37,11 +41,14 @@ services:
mongo: mongo:
image: mongo:6 image: mongo:6
command: ["mongod", "--bind_ip", "0.0.0.0"] command: ["mongod", "--bind_ip", "0.0.0.0", "--auth"]
environment:
MONGO_INITDB_ROOT_USERNAME: ${MONGO_USERNAME}
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_PASSWORD}
volumes: volumes:
- mongo_data:/data/db - mongo_data:/data/db
healthcheck: healthcheck:
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"] test: ["CMD", "mongosh", "--quiet", "-u", "${MONGO_USERNAME}", "-p", "${MONGO_PASSWORD}", "--authenticationDatabase", "admin", "--eval", "db.adminCommand('ping').ok"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
@@ -56,3 +63,5 @@ volumes:
networks: networks:
app_net: app_net:
driver: bridge driver: bridge
workspace_web:
external: true

View File

@@ -1,16 +1,196 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en" style="background-color:#eef6ee">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="manifest" href="/manifest.json" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="Grateful Journal" />
<meta name="theme-color" content="#16a34a" />
<meta <meta
name="viewport" name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover" content="width=device-width, initial-scale=1.0, viewport-fit=cover"
/> />
<title>Grateful Journal</title>
<!-- SEO -->
<title>Private Gratitude Journal App | Grateful Journal</title>
<meta name="description" content="A private, end-to-end encrypted gratitude journal. No feeds, no noise — just you and your thoughts. Grow your gratitude one moment at a time." />
<meta name="keywords" content="gratitude journal, private journal, encrypted journal, daily gratitude, mindfulness, reflection" />
<meta name="robots" content="index, follow, max-snippet:160, max-image-preview:large" />
<link rel="canonical" href="https://gratefuljournal.online/" />
<!-- Open Graph (WhatsApp, Facebook, LinkedIn previews) -->
<meta property="og:type" content="website" />
<meta property="og:locale" content="en_US" />
<meta property="og:url" content="https://gratefuljournal.online/" />
<meta property="og:title" content="Private Gratitude Journal App | Grateful Journal" />
<meta property="og:description" content="A private, end-to-end encrypted gratitude journal. No feeds, no noise — just you and your thoughts." />
<meta property="og:image" content="https://gratefuljournal.online/web-app-manifest-512x512.png" />
<meta property="og:image:width" content="512" />
<meta property="og:image:height" content="512" />
<meta property="og:image:alt" content="Grateful Journal logo - a green sprout" />
<meta property="og:site_name" content="Grateful Journal" />
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Private Gratitude Journal App | Grateful Journal" />
<meta name="twitter:description" content="A private, end-to-end encrypted gratitude journal. No feeds, no noise — just you and your thoughts." />
<meta name="twitter:image" content="https://gratefuljournal.online/web-app-manifest-512x512.png" />
<meta name="twitter:image:alt" content="Grateful Journal logo - a green sprout" />
<!-- JSON-LD: WebSite -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebSite",
"name": "Grateful Journal",
"url": "https://gratefuljournal.online/",
"potentialAction": {
"@type": "SearchAction",
"target": "https://gratefuljournal.online/?q={search_term_string}",
"query-input": "required name=search_term_string"
}
}
</script>
<!-- JSON-LD: Organization -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Organization",
"name": "Grateful Journal",
"url": "https://gratefuljournal.online/",
"logo": {
"@type": "ImageObject",
"url": "https://gratefuljournal.online/web-app-manifest-512x512.png",
"width": 512,
"height": 512
},
"description": "A private, end-to-end encrypted gratitude journal. No feeds, no noise — just you and your thoughts.",
"sameAs": []
}
</script>
<!-- JSON-LD: WebApplication -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebApplication",
"name": "Grateful Journal",
"url": "https://gratefuljournal.online/",
"description": "A private, end-to-end encrypted gratitude journal. No feeds, no noise — just you and your thoughts.",
"applicationCategory": "LifestyleApplication",
"operatingSystem": "Web, Android, iOS",
"browserRequirements": "Requires JavaScript. Requires HTML5.",
"offers": {
"@type": "Offer",
"price": "0",
"priceCurrency": "USD"
},
"featureList": "End-to-end encrypted journal entries, Daily gratitude prompts, Private and secure — no ads no tracking, Works offline as a PWA"
}
</script>
<!-- JSON-LD: FAQ -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "FAQPage",
"mainEntity": [
{
"@type": "Question",
"name": "Is Grateful Journal free?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Yes, Grateful Journal is completely free to use. There is no subscription or paywall."
}
},
{
"@type": "Question",
"name": "Are my journal entries private?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Yes. Your entries are end-to-end encrypted before leaving your device. Even we cannot read them."
}
},
{
"@type": "Question",
"name": "Does Grateful Journal work offline?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Yes. Grateful Journal is a Progressive Web App (PWA) and can be installed on Android, iOS, and desktop. It works offline once installed."
}
},
{
"@type": "Question",
"name": "Do you sell my data or show ads?",
"acceptedAnswer": {
"@type": "Answer",
"text": "No. We do not sell your data, show ads, or use tracking pixels. Your privacy is the foundation of what we built."
}
}
]
}
</script>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<noscript>
<main style="font-family:sans-serif;max-width:680px;margin:4rem auto;padding:1rem 1.5rem;color:#1a1a1a;line-height:1.7">
<h1 style="color:#15803d">Grateful Journal - Your Private Gratitude Journal</h1>
<p style="font-size:1.1rem">A free, private, end-to-end encrypted gratitude journal. No feeds, no noise — just you and your thoughts. Grow your gratitude one moment at a time.</p>
<h2>What is Grateful Journal?</h2>
<p>Grateful Journal is a daily gratitude journaling app built for people who value privacy. You write a few things you're grateful for each day, and over time you build a private record of the good in your life — visible only to you. No social pressure, no algorithms, no distractions.</p>
<h2>Key Features</h2>
<ul>
<li><strong>End-to-end encrypted entries</strong> — your journal content is encrypted on your device before it reaches our servers. We cannot read it.</li>
<li><strong>No ads, no tracking</strong> — we do not sell your data, show ads, or use tracking pixels of any kind.</li>
<li><strong>Works offline</strong> — installable as a Progressive Web App (PWA) on Android, iOS, and desktop. Write even without an internet connection.</li>
<li><strong>Daily gratitude prompts</strong> — gentle nudges to keep your reflection practice consistent.</li>
<li><strong>History view</strong> — browse past entries and see how far you've come.</li>
<li><strong>Completely free</strong> — no subscription, no paywall, no hidden fees.</li>
</ul>
<h2>Why a Private Gratitude Journal?</h2>
<p>Research consistently shows that a regular gratitude practice improves mood, reduces stress, and builds resilience. But most journaling apps either sell your data or make your entries visible in social feeds. Grateful Journal gives you the simplest possible tool to build the gratitude habit — with your privacy as a non-negotiable foundation.</p>
<h2>How Encryption Works</h2>
<p>Your journal entries are encrypted using XSalsa20-Poly1305 before leaving your device. The encryption key is derived from your account and never sent to our servers. We store only ciphertext — even a database breach would expose nothing readable. App preferences like your display name and theme are stored as plain settings, not journal content.</p>
<h2>Who Is It For?</h2>
<ul>
<li>Privacy-conscious users who want a digital journal without surveillance</li>
<li>People building a daily gratitude or mindfulness practice</li>
<li>Anyone who wants a distraction-free space for daily reflection</li>
<li>Users looking for a free, encrypted alternative to Day One or Notion</li>
</ul>
<h2>Frequently Asked Questions</h2>
<dl>
<dt><strong>Is Grateful Journal free?</strong></dt>
<dd>Yes, completely free. No subscription, no paywall.</dd>
<dt><strong>Are my entries private?</strong></dt>
<dd>Yes. Entries are end-to-end encrypted. Even we cannot read them.</dd>
<dt><strong>Does it work offline?</strong></dt>
<dd>Yes. Install it as a PWA on Android, iOS, or desktop for offline access.</dd>
<dt><strong>Do you sell data or show ads?</strong></dt>
<dd>No. We do not sell data, show ads, or use any tracking.</dd>
</dl>
<p><a href="https://gratefuljournal.online/" style="color:#15803d;font-weight:bold">Get started — it's free</a></p>
<nav>
<a href="/about">About</a> ·
<a href="/privacy">Privacy Policy</a> ·
<a href="/termsofservice">Terms of Service</a>
</nav>
</main>
</noscript>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
</html> </html>

122
liquidglass.md Normal file
View File

@@ -0,0 +1,122 @@
# Liquid Glass Theme Implementation
## Overview
Replaces solid white/dark card surfaces with a unified glassmorphism effect using CSS `backdrop-filter`. No library needed — pure CSS. Works identically on both light and dark themes with only variable overrides per theme.
---
## 1. `src/index.css` changes
### `:root` — replace `--card-bg-opacity` + `--color-surface` with:
```css
--glass-bg: rgba(255, 255, 255, 0.55);
--glass-blur: blur(18px) saturate(160%);
--glass-border: 1px solid rgba(255, 255, 255, 0.5);
--glass-shadow: 0 8px 32px rgba(0, 0, 0, 0.08);
--color-surface: var(--glass-bg);
```
### `[data-theme="dark"]` — replace `--color-surface: rgb(26 26 26 / ...)` with:
```css
--glass-bg: rgba(255, 255, 255, 0.07);
--glass-border: 1px solid rgba(255, 255, 255, 0.12);
--glass-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
--color-surface: var(--glass-bg);
```
> `--glass-blur` is NOT redeclared in dark — it inherits the same blur from `:root`.
---
## 2. `src/App.css` additions
### Add this block BEFORE the `SHARED PAGE SHELL` section (~line 403):
```css
/* ============================
LIQUID GLASS applied to all card/surface elements
============================ */
.journal-card,
.calendar-card,
.entry-card,
.entry-modal,
.confirm-modal,
.settings-profile,
.settings-card,
.settings-tutorial-btn,
.settings-clear-btn,
.settings-signout-btn,
.bottom-nav,
.lp__form {
backdrop-filter: var(--glass-blur);
-webkit-backdrop-filter: var(--glass-blur);
border: var(--glass-border);
box-shadow: var(--glass-shadow);
}
```
### Remove individual `box-shadow` from these classes (glass rule handles it):
- `.journal-card` — remove `box-shadow: 0 2px 12px rgba(0,0,0,0.07)`
- `.calendar-card` — remove `box-shadow: 0 2px 10px rgba(0,0,0,0.06)`
- `.entry-card` — remove `box-shadow: 0 2px 6px rgba(0,0,0,0.05)`
- `.settings-profile` — remove `box-shadow: 0 2px 10px rgba(0,0,0,0.06)`
- `.settings-card` — remove `box-shadow: 0 2px 10px rgba(0,0,0,0.06)`
---
## 3. `src/App.css` dark mode cleanup
### Remove entire block (now redundant — glass vars handle background + shadow):
```css
/* -- Cards & surfaces -- */
[data-theme="dark"] .journal-card,
[data-theme="dark"] .calendar-card,
[data-theme="dark"] .settings-card,
[data-theme="dark"] .settings-profile,
[data-theme="dark"] .entry-card {
background: var(--color-surface);
border-color: rgba(74, 222, 128, 0.12);
box-shadow:
0 2px 16px rgba(0, 0, 0, 0.4),
0 0 0 1px rgba(74, 222, 128, 0.06);
}
```
### Collapse settings buttons dark overrides to color-only:
```css
/* -- Settings buttons -- */
[data-theme="dark"] .settings-clear-btn { color: #f87171; }
[data-theme="dark"] .settings-signout-btn { color: #9ca3af; }
[data-theme="dark"] .settings-signout-btn:hover { color: #d1d5db; }
```
> Remove the full blocks that were setting `background: var(--color-surface)` and `box-shadow` for `.settings-tutorial-btn`, `.settings-clear-btn`, `.settings-signout-btn`.
### Entry modal dark override — keep only the border accent:
```css
[data-theme="dark"] .entry-modal {
border-top-color: #4ade80;
}
```
> Remove the `background` and `box-shadow` lines.
### Remove entirely:
```css
[data-theme="dark"] .delete-confirm-modal { background: var(--color-surface); }
[data-theme="dark"] .confirm-modal { background: var(--color-surface); box-shadow: ...; }
```
### History search button — keep only color:
```css
[data-theme="dark"] .history-search-btn { color: #7a8a7a; }
```
> Remove `background` and `border-color` lines.
---
## Tuning
| Variable | What it controls |
|---|---|
| `--glass-bg` opacity | How transparent the cards are (0.55 = light, 0.07 = dark) |
| `--glass-blur` value | How much the background blurs through |
| `--glass-border` opacity | Strength of the frosted edge highlight |
To make glass more/less opaque: change the alpha in `--glass-bg` in `:root` / `[data-theme="dark"]`.

View File

@@ -1,3 +1,24 @@
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_min_length 256;
gzip_types
text/plain
text/css
text/xml
text/javascript
application/javascript
application/x-javascript
application/json
application/xml
application/rss+xml
application/atom+xml
image/svg+xml
font/truetype
font/opentype
application/vnd.ms-fontobject;
server { server {
listen 80; listen 80;
server_name _; server_name _;
@@ -5,7 +26,22 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
# Cache hashed static assets (JS/CSS/fonts) for 1 year — Vite adds content hashes
location ~* \.(js|css|woff|woff2|ttf|eot|otf)$ {
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}
# Cache images for 30 days
location ~* \.(png|jpg|jpeg|gif|ico|svg|webp|avif)$ {
expires 30d;
add_header Cache-Control "public, max-age=2592000";
try_files $uri =404;
}
location /api/ { location /api/ {
client_max_body_size 5m;
proxy_pass http://backend:8001/api/; proxy_pass http://backend:8001/api/;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Host $host; proxy_set_header Host $host;
@@ -23,7 +59,31 @@ server {
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} }
# Homepage
location = / {
try_files /index.html =404;
}
# Pre-rendered public pages — each gets its own HTML with correct meta tags
location ~ ^/about(/|$) {
try_files /about.html =404;
}
location ~ ^/privacy(/|$) {
try_files /privacy.html =404;
}
location ~ ^/termsofservice(/|$) {
try_files /termsofservice.html =404;
}
# Protected SPA routes — serve index.html (React handles auth redirect)
location ~ ^/(write|history|settings)(/|$) {
try_files /index.html =404;
}
# Static assets — serve directly, 404 if missing
location / { location / {
try_files $uri $uri/ /index.html; try_files $uri $uri/ =404;
} }
} }

1285
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,10 @@
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview" "preview": "vite preview",
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage"
}, },
"dependencies": { "dependencies": {
"driver.js": "^1.4.0", "driver.js": "^1.4.0",
@@ -19,16 +22,22 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.1", "@eslint/js": "^9.39.1",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.5.2",
"@types/node": "^24.10.1", "@types/node": "^24.10.1",
"@types/react": "^19.2.7", "@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1", "@vitejs/plugin-react": "^5.1.1",
"@vitest/coverage-v8": "^3.2.0",
"eslint": "^9.39.1", "eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24", "eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0", "globals": "^16.5.0",
"happy-dom": "^17.4.4",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"typescript-eslint": "^8.48.0", "typescript-eslint": "^8.48.0",
"vite": "^7.3.1" "vite": "^7.3.1",
"vitest": "^3.2.0"
} }
} }

103
privacy.html Normal file
View File

@@ -0,0 +1,103 @@
<!doctype html>
<html lang="en" style="background-color:#eef6ee">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="manifest" href="/manifest.json" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="Grateful Journal" />
<meta name="theme-color" content="#16a34a" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
/>
<!-- SEO -->
<title>Privacy Policy | Grateful Journal</title>
<meta name="description" content="Grateful Journal's privacy policy. Your journal entries are end-to-end encrypted — we cannot read them. No ads, no tracking, no data selling." />
<meta name="keywords" content="grateful journal privacy policy, encrypted journal, private journal app, data privacy" />
<meta name="robots" content="index, follow, max-snippet:160, max-image-preview:large" />
<link rel="canonical" href="https://gratefuljournal.online/privacy" />
<!-- Open Graph -->
<meta property="og:type" content="website" />
<meta property="og:locale" content="en_US" />
<meta property="og:url" content="https://gratefuljournal.online/privacy" />
<meta property="og:title" content="Privacy Policy | Grateful Journal" />
<meta property="og:description" content="Your journal entries are end-to-end encrypted and private. App preferences like background images are stored unencrypted. No ads, no tracking, no data selling." />
<meta property="og:image" content="https://gratefuljournal.online/web-app-manifest-512x512.png" />
<meta property="og:image:width" content="512" />
<meta property="og:image:height" content="512" />
<meta property="og:image:alt" content="Grateful Journal logo - a green sprout" />
<meta property="og:site_name" content="Grateful Journal" />
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Privacy Policy | Grateful Journal" />
<meta name="twitter:description" content="Your journal entries are end-to-end encrypted. No ads, no tracking, no data selling." />
<meta name="twitter:image" content="https://gratefuljournal.online/web-app-manifest-512x512.png" />
<meta name="twitter:image:alt" content="Grateful Journal logo - a green sprout" />
<!-- JSON-LD: WebPage -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebPage",
"name": "Privacy Policy",
"url": "https://gratefuljournal.online/privacy",
"description": "Grateful Journal's privacy policy. Your journal entries are end-to-end encrypted — we cannot read them.",
"isPartOf": {
"@type": "WebSite",
"name": "Grateful Journal",
"url": "https://gratefuljournal.online/"
}
}
</script>
</head>
<body>
<div id="root"></div>
<noscript>
<main style="font-family:sans-serif;max-width:680px;margin:4rem auto;padding:1rem 1.5rem;color:#1a1a1a;line-height:1.7">
<nav style="margin-bottom:2rem"><a href="/" style="color:#15803d">&#8592; Grateful Journal</a></nav>
<h1 style="color:#15803d">Privacy Policy</h1>
<p><em>Last updated: April 14, 2026</em></p>
<p>Grateful Journal is built on a simple promise: your journal entries are yours alone. We designed the app so that we cannot read your entries even if we wanted to.</p>
<h2>What we collect</h2>
<ul>
<li><strong>Account info</strong> — your name and email address via Google Sign-In, used solely to identify your account.</li>
<li><strong>Journal entries</strong> — stored encrypted in our database. We do not have access to the content of your entries.</li>
<li><strong>App preferences</strong> — your display name, profile photo, background images, and theme are stored unencrypted as account settings.</li>
<li><strong>Usage data</strong> — no analytics, no tracking pixels, no third-party advertising SDKs.</li>
</ul>
<h2>Encryption</h2>
<ul>
<li><strong>Journal entries — end-to-end encrypted.</strong> Entries are encrypted on your device using XSalsa20-Poly1305 before being sent to our servers. We store only ciphertext. We cannot read your entries.</li>
<li><strong>App preferences — not encrypted.</strong> Your display name, profile photo, background images, and theme setting are stored as plain data.</li>
</ul>
<h2>Data sharing</h2>
<p>We do not sell, share, or rent your personal data to any third party. We use Firebase (Google) for authentication only.</p>
<h2>Data deletion</h2>
<p>You can delete your account and all associated data at any time from the Settings page. Deletion is permanent and irreversible.</p>
<h2>Cookies</h2>
<p>We use a single session cookie to keep you signed in. No advertising or tracking cookies are used.</p>
<nav style="margin-top:2rem">
<a href="/">&#8592; Back to Grateful Journal</a> ·
<a href="/about">About</a>
</nav>
</main>
</noscript>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

BIN
public/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

BIN
public/favicon-96x96.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

5
public/favicon.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 27 KiB

BIN
public/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

9
public/icon.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 26 KiB

24
public/manifest.json Normal file
View File

@@ -0,0 +1,24 @@
{
"name": "Grateful Journal",
"short_name": "Grateful Journal",
"description": "Your private, encrypted gratitude journal",
"start_url": "/",
"display": "standalone",
"background_color": "#eef6ee",
"theme_color": "#16a34a",
"orientation": "portrait",
"icons": [
{
"src": "/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}

4
public/robots.txt Normal file
View File

@@ -0,0 +1,4 @@
User-agent: *
Disallow:
Sitemap: https://gratefuljournal.online/sitemap.xml

27
public/sitemap.xml Normal file
View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://gratefuljournal.online/</loc>
<lastmod>2026-04-16</lastmod>
<changefreq>monthly</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://gratefuljournal.online/about</loc>
<lastmod>2026-04-16</lastmod>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://gratefuljournal.online/privacy</loc>
<lastmod>2026-04-16</lastmod>
<changefreq>yearly</changefreq>
<priority>0.5</priority>
</url>
<url>
<loc>https://gratefuljournal.online/termsofservice</loc>
<lastmod>2026-04-16</lastmod>
<changefreq>yearly</changefreq>
<priority>0.4</priority>
</url>
</urlset>

66
public/sw.js Normal file
View File

@@ -0,0 +1,66 @@
// Firebase Messaging — handles background push notifications
importScripts('https://www.gstatic.com/firebasejs/10.12.0/firebase-app-compat.js')
importScripts('https://www.gstatic.com/firebasejs/10.12.0/firebase-messaging-compat.js')
firebase.initializeApp({
apiKey: '__VITE_FIREBASE_API_KEY__',
authDomain: '__VITE_FIREBASE_AUTH_DOMAIN__',
projectId: '__VITE_FIREBASE_PROJECT_ID__',
messagingSenderId: '__VITE_FIREBASE_MESSAGING_SENDER_ID__',
appId: '__VITE_FIREBASE_APP_ID__',
})
const messaging = firebase.messaging()
messaging.onBackgroundMessage((payload) => {
const title = payload.notification?.title || 'Grateful Journal 🌱'
const body = payload.notification?.body || "You haven't written today yet. Take a moment to reflect."
self.registration.showNotification(title, {
body,
icon: '/web-app-manifest-192x192.png',
badge: '/favicon-96x96.png',
tag: 'gj-daily-reminder',
})
})
// Cache management
const CACHE = 'gj-__BUILD_TIME__'
self.addEventListener('install', (e) => {
e.waitUntil(
caches.open(CACHE).then((cache) =>
cache.addAll(['/', '/manifest.json', '/icon.svg'])
)
)
self.skipWaiting()
})
self.addEventListener('activate', (e) => {
e.waitUntil(
caches.keys().then((keys) =>
Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))
)
)
self.clients.claim()
})
self.addEventListener('notificationclick', (e) => {
e.notification.close()
e.waitUntil(
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clients) => {
if (clients.length > 0) {
clients[0].focus()
clients[0].navigate('/')
} else {
self.clients.openWindow('/')
}
})
)
})
self.addEventListener('fetch', (e) => {
if (e.request.method !== 'GET' || e.request.url.includes('/api/')) return
e.respondWith(
caches.match(e.request).then((cached) => cached || fetch(e.request))
)
})

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

15
skills-lock.json Normal file
View File

@@ -0,0 +1,15 @@
{
"version": 1,
"skills": {
"seo": {
"source": "addyosmani/web-quality-skills",
"sourceType": "github",
"computedHash": "f1fed683b76913d26fbf1aa1e008e6932f7771701fc3a79925b042236aa4681a"
},
"seo-audit": {
"source": "coreyhaines31/marketingskills",
"sourceType": "github",
"computedHash": "1eef04180a5278a6869fab117c75fa2acf512bfda0a4b16569409b88b7bcb343"
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,19 +1,33 @@
import { lazy, Suspense } from 'react'
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { AuthProvider } from './contexts/AuthContext' import { AuthProvider } from './contexts/AuthContext'
import { ProtectedRoute } from './components/ProtectedRoute' import { ProtectedRoute } from './components/ProtectedRoute'
import HomePage from './pages/HomePage' import { useSwipeNav } from './hooks/useSwipeNav'
import HistoryPage from './pages/HistoryPage'
import SettingsPage from './pages/SettingsPage'
import LoginPage from './pages/LoginPage'
import './App.css' import './App.css'
function SwipeNavHandler() {
useSwipeNav()
return null
}
const HomePage = lazy(() => import('./pages/HomePage'))
const HistoryPage = lazy(() => import('./pages/HistoryPage'))
const SettingsPage = lazy(() => import('./pages/SettingsPage'))
const LoginPage = lazy(() => import('./pages/LoginPage'))
const PrivacyPage = lazy(() => import('./pages/PrivacyPage'))
const AboutPage = lazy(() => import('./pages/AboutPage'))
const TermsOfServicePage = lazy(() => import('./pages/TermsOfServicePage'))
function App() { function App() {
return ( return (
<AuthProvider> <AuthProvider>
<BrowserRouter> <BrowserRouter>
<SwipeNavHandler />
<Suspense fallback={null}>
<Routes> <Routes>
<Route path="/" element={<LoginPage />} />
<Route <Route
path="/" path="/write"
element={ element={
<ProtectedRoute> <ProtectedRoute>
<HomePage /> <HomePage />
@@ -36,9 +50,12 @@ function App() {
</ProtectedRoute> </ProtectedRoute>
} }
/> />
<Route path="/login" element={<LoginPage />} /> <Route path="/privacy" element={<PrivacyPage />} />
<Route path="*" element={<Navigate to="/login" replace />} /> <Route path="/about" element={<AboutPage />} />
<Route path="/termsofservice" element={<TermsOfServicePage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
</Suspense>
</BrowserRouter> </BrowserRouter>
</AuthProvider> </AuthProvider>
) )

308
src/__tests__/api.test.ts Normal file
View File

@@ -0,0 +1,308 @@
/**
* Tests for the API service layer (src/lib/api.ts)
*
* All HTTP calls are intercepted by mocking global.fetch.
* Tests verify correct URL construction, headers, methods, and error handling.
*/
import { describe, it, expect, vi, afterEach } from 'vitest'
import {
registerUser,
getUserByEmail,
updateUserProfile,
deleteUser,
createEntry,
getUserEntries,
getEntry,
updateEntry,
deleteEntry,
convertUTCToIST,
} from '../lib/api'
const TOKEN = 'firebase-id-token'
const USER_ID = '507f1f77bcf86cd799439011'
const ENTRY_ID = '507f1f77bcf86cd799439022'
// ---------------------------------------------------------------------------
// Fetch mock helpers
// ---------------------------------------------------------------------------
function mockFetch(body: unknown, status = 200) {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: status >= 200 && status < 300,
status,
statusText: status === 200 ? 'OK' : 'Error',
json: () => Promise.resolve(body),
}))
}
function mockFetchError(detail: string, status: number) {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: false,
status,
statusText: 'Error',
json: () => Promise.resolve({ detail }),
}))
}
function mockFetchNetworkError() {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error')))
}
afterEach(() => {
vi.unstubAllGlobals()
})
// ---------------------------------------------------------------------------
// User Endpoints
// ---------------------------------------------------------------------------
describe('registerUser', () => {
it('sends POST to /users/register', async () => {
mockFetch({ id: USER_ID, email: 'a@b.com', message: 'User registered successfully' })
await registerUser({ email: 'a@b.com' }, TOKEN)
expect(fetch).toHaveBeenCalledWith(
expect.stringContaining('/users/register'),
expect.objectContaining({ method: 'POST' })
)
})
it('includes Authorization Bearer token in headers', async () => {
mockFetch({})
await registerUser({ email: 'a@b.com' }, TOKEN)
expect(fetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
headers: expect.objectContaining({ Authorization: `Bearer ${TOKEN}` }),
})
)
})
it('sends displayName and photoURL in body', async () => {
mockFetch({})
await registerUser({ email: 'a@b.com', displayName: 'Alice', photoURL: 'https://pic.url' }, TOKEN)
const body = JSON.parse((fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].body)
expect(body).toMatchObject({ email: 'a@b.com', displayName: 'Alice' })
})
it('returns the parsed response', async () => {
const response = { id: USER_ID, email: 'a@b.com', message: 'User registered successfully' }
mockFetch(response)
const result = await registerUser({ email: 'a@b.com' }, TOKEN)
expect(result).toEqual(response)
})
})
describe('getUserByEmail', () => {
it('sends GET to /users/by-email/{email}', async () => {
mockFetch({ id: USER_ID, email: 'test@example.com' })
await getUserByEmail('test@example.com', TOKEN)
expect(fetch).toHaveBeenCalledWith(
expect.stringContaining('/users/by-email/test@example.com'),
expect.any(Object)
)
})
it('throws "User not found" on 404', async () => {
mockFetchError('User not found', 404)
await expect(getUserByEmail('ghost@example.com', TOKEN)).rejects.toThrow('User not found')
})
})
describe('updateUserProfile', () => {
it('sends PUT to /users/{userId}', async () => {
mockFetch({ id: USER_ID, theme: 'dark', message: 'User updated successfully' })
await updateUserProfile(USER_ID, { theme: 'dark' }, TOKEN)
expect(fetch).toHaveBeenCalledWith(
expect.stringContaining(`/users/${USER_ID}`),
expect.objectContaining({ method: 'PUT' })
)
})
it('sends only the provided fields', async () => {
mockFetch({})
await updateUserProfile(USER_ID, { displayName: 'New Name' }, TOKEN)
const body = JSON.parse((fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].body)
expect(body).toMatchObject({ displayName: 'New Name' })
})
})
describe('deleteUser', () => {
it('sends DELETE to /users/{userId}', async () => {
mockFetch({ message: 'User deleted successfully', user_deleted: 1, entries_deleted: 3 })
await deleteUser(USER_ID, TOKEN)
expect(fetch).toHaveBeenCalledWith(
expect.stringContaining(`/users/${USER_ID}`),
expect.objectContaining({ method: 'DELETE' })
)
})
it('returns deletion counts', async () => {
mockFetch({ message: 'User deleted successfully', user_deleted: 1, entries_deleted: 5 })
const result = await deleteUser(USER_ID, TOKEN)
expect(result).toMatchObject({ user_deleted: 1, entries_deleted: 5 })
})
})
// ---------------------------------------------------------------------------
// Entry Endpoints
// ---------------------------------------------------------------------------
describe('createEntry', () => {
const encryptedEntry = {
encryption: {
encrypted: true,
ciphertext: 'dGVzdA==',
nonce: 'bm9uY2U=',
algorithm: 'XSalsa20-Poly1305',
},
}
it('sends POST to /entries/{userId}', async () => {
mockFetch({ id: ENTRY_ID, message: 'Entry created successfully' })
await createEntry(USER_ID, encryptedEntry, TOKEN)
expect(fetch).toHaveBeenCalledWith(
expect.stringContaining(`/entries/${USER_ID}`),
expect.objectContaining({ method: 'POST' })
)
})
it('returns entry id and message', async () => {
mockFetch({ id: ENTRY_ID, message: 'Entry created successfully' })
const result = await createEntry(USER_ID, encryptedEntry, TOKEN)
expect(result).toMatchObject({ id: ENTRY_ID })
})
it('throws on 404 when user not found', async () => {
mockFetchError('User not found', 404)
await expect(createEntry('nonexistent-user', encryptedEntry, TOKEN)).rejects.toThrow('User not found')
})
})
describe('getUserEntries', () => {
it('sends GET to /entries/{userId} with default pagination', async () => {
mockFetch({ entries: [], total: 0 })
await getUserEntries(USER_ID, TOKEN)
expect(fetch).toHaveBeenCalledWith(
expect.stringContaining(`/entries/${USER_ID}?limit=50&skip=0`),
expect.any(Object)
)
})
it('respects custom limit and skip', async () => {
mockFetch({ entries: [], total: 0 })
await getUserEntries(USER_ID, TOKEN, 10, 20)
expect(fetch).toHaveBeenCalledWith(
expect.stringContaining('limit=10&skip=20'),
expect.any(Object)
)
})
it('returns entries and total', async () => {
mockFetch({ entries: [{ id: ENTRY_ID }], total: 1 })
const result = await getUserEntries(USER_ID, TOKEN)
expect(result).toMatchObject({ total: 1 })
})
})
describe('getEntry', () => {
it('sends GET to /entries/{userId}/{entryId}', async () => {
mockFetch({ id: ENTRY_ID, userId: USER_ID, createdAt: '2024-01-01', updatedAt: '2024-01-01' })
await getEntry(USER_ID, ENTRY_ID, TOKEN)
expect(fetch).toHaveBeenCalledWith(
expect.stringContaining(`/entries/${USER_ID}/${ENTRY_ID}`),
expect.any(Object)
)
})
it('throws "Entry not found" on 404', async () => {
mockFetchError('Entry not found', 404)
await expect(getEntry(USER_ID, 'bad-id', TOKEN)).rejects.toThrow('Entry not found')
})
})
describe('updateEntry', () => {
it('sends PUT to /entries/{userId}/{entryId}', async () => {
mockFetch({ id: ENTRY_ID })
await updateEntry(USER_ID, ENTRY_ID, { mood: 'happy' }, TOKEN)
expect(fetch).toHaveBeenCalledWith(
expect.stringContaining(`/entries/${USER_ID}/${ENTRY_ID}`),
expect.objectContaining({ method: 'PUT' })
)
})
})
describe('deleteEntry', () => {
it('sends DELETE to /entries/{userId}/{entryId}', async () => {
mockFetch({ message: 'Entry deleted successfully' })
await deleteEntry(USER_ID, ENTRY_ID, TOKEN)
expect(fetch).toHaveBeenCalledWith(
expect.stringContaining(`/entries/${USER_ID}/${ENTRY_ID}`),
expect.objectContaining({ method: 'DELETE' })
)
})
})
describe('convertUTCToIST', () => {
it('sends POST to /entries/convert-timestamp/utc-to-ist', async () => {
const utc = '2024-01-01T00:00:00Z'
mockFetch({ utc, ist: '2024-01-01T05:30:00+05:30' })
await convertUTCToIST(utc)
expect(fetch).toHaveBeenCalledWith(
expect.stringContaining('/convert-timestamp/utc-to-ist'),
expect.objectContaining({ method: 'POST' })
)
})
it('returns both utc and ist fields', async () => {
const utc = '2024-01-01T00:00:00Z'
mockFetch({ utc, ist: '2024-01-01T05:30:00+05:30' })
const result = await convertUTCToIST(utc)
expect(result).toMatchObject({ utc, ist: expect.stringContaining('+05:30') })
})
})
// ---------------------------------------------------------------------------
// Generic Error Handling
// ---------------------------------------------------------------------------
describe('API error handling', () => {
it('throws the error detail from response body', async () => {
mockFetchError('Specific backend error message', 400)
await expect(getUserByEmail('x@x.com', TOKEN)).rejects.toThrow('Specific backend error message')
})
it('falls back to "API error: {statusText}" when body has no detail', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: false,
status: 500,
statusText: 'Internal Server Error',
json: () => Promise.reject(new Error('no JSON')),
}))
await expect(getUserByEmail('x@x.com', TOKEN)).rejects.toThrow('API error: Internal Server Error')
})
it('propagates network errors', async () => {
mockFetchNetworkError()
await expect(getUserByEmail('x@x.com', TOKEN)).rejects.toThrow('Network error')
})
it('includes credentials: include in all requests', async () => {
mockFetch({})
await getUserByEmail('x@x.com', TOKEN)
expect(fetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ credentials: 'include' })
)
})
it('sets Content-Type: application/json on all requests', async () => {
mockFetch({})
await getUserByEmail('x@x.com', TOKEN)
expect(fetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
headers: expect.objectContaining({ 'Content-Type': 'application/json' }),
})
)
})
})

View File

@@ -0,0 +1,284 @@
/**
* Tests for client-side encryption utilities (src/lib/crypto.ts)
*
* Uses a self-consistent XOR-based sodium mock so tests run without
* WebAssembly (libsodium) in the Node/happy-dom environment.
* The real PBKDF2 key derivation (Web Crypto API) is tested as-is.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
deriveSecretKey,
generateDeviceKey,
encryptEntry,
decryptEntry,
encryptSecretKey,
decryptSecretKey,
generateSalt,
getSalt,
saveSalt,
saveDeviceKey,
getDeviceKey,
clearDeviceKey,
} from '../lib/crypto'
// ---------------------------------------------------------------------------
// Self-consistent sodium mock (XOR cipher + 16-byte auth tag)
// encrypt(msg, key) = tag(16 zeros) || xor(msg, key)
// decrypt(ct, key) = xor(ct[16:], key)
// Wrong-key behavior is tested by overriding crypto_secretbox_open_easy to throw.
// ---------------------------------------------------------------------------
function xorBytes(data: Uint8Array, key: Uint8Array): Uint8Array {
return data.map((byte, i) => byte ^ key[i % key.length])
}
const createMockSodium = (overrides: Record<string, unknown> = {}) => ({
randombytes_buf: (size: number) => new Uint8Array(size).fill(42),
crypto_secretbox_NONCEBYTES: 24,
crypto_secretbox_easy: (msg: Uint8Array, _nonce: Uint8Array, key: Uint8Array) => {
const tag = new Uint8Array(16)
const encrypted = xorBytes(msg, key)
const result = new Uint8Array(tag.length + encrypted.length)
result.set(tag)
result.set(encrypted, tag.length)
return result
},
crypto_secretbox_open_easy: (ct: Uint8Array, _nonce: Uint8Array, key: Uint8Array) => {
if (ct.length < 16) throw new Error('invalid ciphertext length')
return xorBytes(ct.slice(16), key)
},
to_base64: (data: Uint8Array) => Buffer.from(data).toString('base64'),
from_base64: (str: string) => new Uint8Array(Buffer.from(str, 'base64')),
from_string: (str: string) => new TextEncoder().encode(str),
to_string: (data: Uint8Array) => new TextDecoder().decode(data),
...overrides,
})
vi.mock('../utils/sodium', () => ({
getSodium: vi.fn(),
}))
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('crypto utilities', () => {
beforeEach(async () => {
const { getSodium } = await import('../utils/sodium')
vi.mocked(getSodium).mockResolvedValue(createMockSodium() as never)
localStorage.clear()
})
// ── deriveSecretKey ──────────────────────────────────────────────────────
describe('deriveSecretKey', () => {
it('returns a 32-byte Uint8Array', async () => {
const key = await deriveSecretKey('test-uid-123', 'test-salt')
expect(key).toBeInstanceOf(Uint8Array)
expect(key.length).toBe(32)
})
it('is deterministic — same inputs always produce the same key', async () => {
const key1 = await deriveSecretKey('uid-abc', 'salt-xyz')
const key2 = await deriveSecretKey('uid-abc', 'salt-xyz')
expect(key1).toEqual(key2)
})
it('different UIDs produce different keys', async () => {
const key1 = await deriveSecretKey('uid-1', 'same-salt')
const key2 = await deriveSecretKey('uid-2', 'same-salt')
expect(key1).not.toEqual(key2)
})
it('different salts produce different keys', async () => {
const key1 = await deriveSecretKey('same-uid', 'salt-a')
const key2 = await deriveSecretKey('same-uid', 'salt-b')
expect(key1).not.toEqual(key2)
})
it('handles empty UID string', async () => {
const key = await deriveSecretKey('', 'some-salt')
expect(key).toBeInstanceOf(Uint8Array)
expect(key.length).toBe(32)
})
})
// ── generateDeviceKey ────────────────────────────────────────────────────
describe('generateDeviceKey', () => {
it('returns a 32-byte Uint8Array', async () => {
const key = await generateDeviceKey()
expect(key).toBeInstanceOf(Uint8Array)
expect(key.length).toBe(32)
})
it('generates unique keys each time (random)', async () => {
const key1 = await generateDeviceKey()
const key2 = await generateDeviceKey()
// Two random 256-bit arrays should be different
expect(key1).not.toEqual(key2)
})
})
// ── encryptEntry / decryptEntry ──────────────────────────────────────────
describe('encryptEntry / decryptEntry', () => {
const secretKey = new Uint8Array(32).fill(1)
it('roundtrip: decrypting an encrypted entry returns original content', async () => {
const content = 'Today I am grateful for my family.'
const { ciphertext, nonce } = await encryptEntry(content, secretKey)
const decrypted = await decryptEntry(ciphertext, nonce, secretKey)
expect(decrypted).toBe(content)
})
it('returns base64-encoded strings for ciphertext and nonce', async () => {
const { ciphertext, nonce } = await encryptEntry('test content', secretKey)
expect(() => Buffer.from(ciphertext, 'base64')).not.toThrow()
expect(() => Buffer.from(nonce, 'base64')).not.toThrow()
// Valid base64 only contains these characters
expect(ciphertext).toMatch(/^[A-Za-z0-9+/=]+$/)
})
it('handles empty string content', async () => {
const { ciphertext, nonce } = await encryptEntry('', secretKey)
const decrypted = await decryptEntry(ciphertext, nonce, secretKey)
expect(decrypted).toBe('')
})
it('handles unicode and emoji content', async () => {
const content = 'Grateful for 🌟 life! नमस्ते 日本語'
const { ciphertext, nonce } = await encryptEntry(content, secretKey)
const decrypted = await decryptEntry(ciphertext, nonce, secretKey)
expect(decrypted).toBe(content)
})
it('handles very long content (10,000 chars)', async () => {
const content = 'a'.repeat(10000)
const { ciphertext, nonce } = await encryptEntry(content, secretKey)
const decrypted = await decryptEntry(ciphertext, nonce, secretKey)
expect(decrypted).toBe(content)
})
it('different plaintext produces different ciphertext', async () => {
const { ciphertext: ct1 } = await encryptEntry('hello world', secretKey)
const { ciphertext: ct2 } = await encryptEntry('goodbye world', secretKey)
expect(ct1).not.toBe(ct2)
})
it('decryptEntry throws "Failed to decrypt entry" on bad ciphertext', async () => {
const { getSodium } = await import('../utils/sodium')
vi.mocked(getSodium).mockResolvedValueOnce(createMockSodium({
crypto_secretbox_open_easy: () => { throw new Error('invalid mac') },
}) as never)
await expect(decryptEntry('notvalidbase64!!', 'nonce', secretKey))
.rejects.toThrow('Failed to decrypt entry')
})
it('decryptEntry throws when called with wrong key', async () => {
// Simulate libsodium authentication failure with wrong key
const { getSodium } = await import('../utils/sodium')
vi.mocked(getSodium)
.mockResolvedValueOnce(createMockSodium() as never) // for encrypt
.mockResolvedValueOnce(createMockSodium({ // for decrypt (wrong key throws)
crypto_secretbox_open_easy: () => { throw new Error('incorrect key') },
}) as never)
const { ciphertext, nonce } = await encryptEntry('secret', secretKey)
const wrongKey = new Uint8Array(32).fill(99)
await expect(decryptEntry(ciphertext, nonce, wrongKey))
.rejects.toThrow('Failed to decrypt entry')
})
})
// ── encryptSecretKey / decryptSecretKey ──────────────────────────────────
describe('encryptSecretKey / decryptSecretKey', () => {
it('roundtrip: encrypts and decrypts master key back to original', async () => {
const masterKey = new Uint8Array(32).fill(99)
const deviceKey = new Uint8Array(32).fill(55)
const { ciphertext, nonce } = await encryptSecretKey(masterKey, deviceKey)
const decrypted = await decryptSecretKey(ciphertext, nonce, deviceKey)
expect(decrypted).toEqual(masterKey)
})
it('returns base64 strings', async () => {
const masterKey = new Uint8Array(32).fill(1)
const deviceKey = new Uint8Array(32).fill(2)
const { ciphertext, nonce } = await encryptSecretKey(masterKey, deviceKey)
expect(typeof ciphertext).toBe('string')
expect(typeof nonce).toBe('string')
})
it('decryptSecretKey throws "Failed to decrypt secret key" on wrong device key', async () => {
const { getSodium } = await import('../utils/sodium')
vi.mocked(getSodium).mockResolvedValueOnce(createMockSodium({
crypto_secretbox_open_easy: () => { throw new Error('decryption failed') },
}) as never)
await expect(decryptSecretKey('fakeciphertext', 'fakenonce', new Uint8Array(32)))
.rejects.toThrow('Failed to decrypt secret key')
})
})
// ── salt functions ───────────────────────────────────────────────────────
describe('generateSalt / saveSalt / getSalt', () => {
it('generateSalt returns the constant salt string', () => {
expect(generateSalt()).toBe('grateful-journal-v1')
})
it('generateSalt is idempotent', () => {
expect(generateSalt()).toBe(generateSalt())
})
it('saveSalt and getSalt roundtrip', () => {
saveSalt('my-custom-salt')
expect(getSalt()).toBe('my-custom-salt')
})
it('getSalt returns null when nothing stored', () => {
localStorage.clear()
expect(getSalt()).toBeNull()
})
it('overwriting salt replaces old value', () => {
saveSalt('first')
saveSalt('second')
expect(getSalt()).toBe('second')
})
})
// ── device key localStorage ──────────────────────────────────────────────
describe('saveDeviceKey / getDeviceKey / clearDeviceKey', () => {
it('saves and retrieves device key correctly', async () => {
const key = new Uint8Array(32).fill(7)
await saveDeviceKey(key)
const retrieved = await getDeviceKey()
expect(retrieved).toEqual(key)
})
it('returns null when no device key is stored', async () => {
localStorage.clear()
const key = await getDeviceKey()
expect(key).toBeNull()
})
it('clearDeviceKey removes the stored key', async () => {
const key = new Uint8Array(32).fill(7)
await saveDeviceKey(key)
clearDeviceKey()
const retrieved = await getDeviceKey()
expect(retrieved).toBeNull()
})
it('overwriting device key stores the new key', async () => {
const key1 = new Uint8Array(32).fill(1)
const key2 = new Uint8Array(32).fill(2)
await saveDeviceKey(key1)
await saveDeviceKey(key2)
const retrieved = await getDeviceKey()
expect(retrieved).toEqual(key2)
})
})
})

3
src/__tests__/setup.ts Normal file
View File

@@ -0,0 +1,3 @@
// Global test setup
// happy-dom provides: crypto (Web Crypto API), localStorage, sessionStorage, IndexedDB, fetch
// No additional polyfills needed for this project

View File

@@ -0,0 +1,235 @@
import { useState, useRef, useCallback } from 'react'
type HandleType = 'move' | 'tl' | 'tr' | 'bl' | 'br'
interface CropBox { x: number; y: number; w: number; h: number }
interface Props {
imageSrc: string
aspectRatio: number // width / height of the target display area
onCrop: (dataUrl: string) => void
onCancel: () => void
}
const MIN_SIZE = 80
function clamp(v: number, lo: number, hi: number) {
return Math.max(lo, Math.min(hi, v))
}
export function BgImageCropper({ imageSrc, aspectRatio, onCrop, onCancel }: Props) {
const containerRef = useRef<HTMLDivElement>(null)
const imgRef = useRef<HTMLImageElement>(null)
// Keep crop box in both a ref (for event handlers, avoids stale closure) and state (for rendering)
const cropRef = useRef<CropBox | null>(null)
const [cropBox, setCropBox] = useState<CropBox | null>(null)
const drag = useRef<{
type: HandleType
startX: number
startY: number
startCrop: CropBox
} | null>(null)
const setBox = useCallback((b: CropBox) => {
cropRef.current = b
setCropBox(b)
}, [])
// Centre a crop box filling most of the displayed image at the target aspect ratio
const initCrop = useCallback(() => {
const c = containerRef.current
const img = imgRef.current
if (!c || !img) return
const cW = c.clientWidth
const cH = c.clientHeight
const scale = Math.min(cW / img.naturalWidth, cH / img.naturalHeight)
const dispW = img.naturalWidth * scale
const dispH = img.naturalHeight * scale
const imgX = (cW - dispW) / 2
const imgY = (cH - dispH) / 2
let w = dispW * 0.9
let h = w / aspectRatio
if (h > dispH * 0.9) { h = dispH * 0.9; w = h * aspectRatio }
setBox({
x: imgX + (dispW - w) / 2,
y: imgY + (dispH - h) / 2,
w,
h,
})
}, [aspectRatio, setBox])
const onPointerDown = useCallback((e: React.PointerEvent, type: HandleType) => {
if (!cropRef.current) return
e.preventDefault()
e.stopPropagation()
drag.current = {
type,
startX: e.clientX,
startY: e.clientY,
startCrop: { ...cropRef.current },
}
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
}, [])
const onPointerMove = useCallback((e: React.PointerEvent) => {
if (!drag.current || !containerRef.current) return
const c = containerRef.current
const cW = c.clientWidth
const cH = c.clientHeight
const dx = e.clientX - drag.current.startX
const dy = e.clientY - drag.current.startY
const sc = drag.current.startCrop
const t = drag.current.type
let x = sc.x, y = sc.y, w = sc.w, h = sc.h
if (t === 'move') {
x = clamp(sc.x + dx, 0, cW - sc.w)
y = clamp(sc.y + dy, 0, cH - sc.h)
} else {
// Resize: width driven by dx, height derived from aspect ratio
let newW: number
if (t === 'br' || t === 'tr') newW = clamp(sc.w + dx, MIN_SIZE, cW)
else newW = clamp(sc.w - dx, MIN_SIZE, cW)
const newH = newW / aspectRatio
if (t === 'br') { x = sc.x; y = sc.y }
else if (t === 'bl') { x = sc.x + sc.w - newW; y = sc.y }
else if (t === 'tr') { x = sc.x; y = sc.y + sc.h - newH }
else { x = sc.x + sc.w - newW; y = sc.y + sc.h - newH }
x = clamp(x, 0, cW - newW)
y = clamp(y, 0, cH - newH)
w = newW
h = newH
}
setBox({ x, y, w, h })
}, [aspectRatio, setBox])
const onPointerUp = useCallback(() => { drag.current = null }, [])
const handleCrop = useCallback(() => {
const img = imgRef.current
const c = containerRef.current
const cb = cropRef.current
if (!img || !c || !cb) return
const cW = c.clientWidth
const cH = c.clientHeight
const scale = Math.min(cW / img.naturalWidth, cH / img.naturalHeight)
const dispW = img.naturalWidth * scale
const dispH = img.naturalHeight * scale
const offX = (cW - dispW) / 2
const offY = (cH - dispH) / 2
// Map crop box back to source image coordinates
const srcX = (cb.x - offX) / scale
const srcY = (cb.y - offY) / scale
const srcW = cb.w / scale
const srcH = cb.h / scale
// Output resolution: screen size × device pixel ratio, capped at 1440px wide
// Then scale down resolution until the result is under 3MB (keeping quality at 0.92)
const MAX_BYTES = 1 * 1024 * 1024
const dpr = Math.min(window.devicePixelRatio || 1, 2)
let w = Math.min(Math.round(window.innerWidth * dpr), 1440)
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')!
let dataUrl: string
do {
const h = Math.round(w / aspectRatio)
canvas.width = w
canvas.height = h
ctx.drawImage(img, srcX, srcY, srcW, srcH, 0, 0, w, h)
dataUrl = canvas.toDataURL('image/jpeg', 0.92)
// base64 → approx byte size
const bytes = (dataUrl.length - dataUrl.indexOf(',') - 1) * 0.75
if (bytes <= MAX_BYTES) break
w = Math.round(w * 0.8)
} while (w > 200)
onCrop(dataUrl!)
}, [aspectRatio, onCrop])
return (
<div className="cropper-overlay">
<div className="cropper-header">
<button type="button" className="cropper-cancel-btn" onClick={onCancel}>
Cancel
</button>
<span className="cropper-title">Crop Background</span>
<button
type="button"
className="cropper-apply-btn"
onClick={handleCrop}
disabled={!cropBox}
>
Apply
</button>
</div>
<div
ref={containerRef}
className="cropper-container"
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerLeave={onPointerUp}
>
<img
ref={imgRef}
src={imageSrc}
className="cropper-image"
onLoad={initCrop}
alt=""
draggable={false}
/>
{cropBox && (
<>
{/* Darkened area outside crop box via box-shadow */}
<div
className="cropper-shade"
style={{
left: cropBox.x,
top: cropBox.y,
width: cropBox.w,
height: cropBox.h,
}}
/>
{/* Moveable crop box */}
<div
className="cropper-box"
style={{
left: cropBox.x,
top: cropBox.y,
width: cropBox.w,
height: cropBox.h,
}}
onPointerDown={(e) => onPointerDown(e, 'move')}
>
{/* Rule-of-thirds grid */}
<div className="cropper-grid" />
{/* Resize handles */}
<div className="cropper-handle cropper-handle-tl" onPointerDown={(e) => onPointerDown(e, 'tl')} />
<div className="cropper-handle cropper-handle-tr" onPointerDown={(e) => onPointerDown(e, 'tr')} />
<div className="cropper-handle cropper-handle-bl" onPointerDown={(e) => onPointerDown(e, 'bl')} />
<div className="cropper-handle cropper-handle-br" onPointerDown={(e) => onPointerDown(e, 'br')} />
</div>
</>
)}
</div>
<p className="cropper-hint">Drag to move · Drag corners to resize</p>
</div>
)
}

View File

@@ -1,8 +1,15 @@
import { useNavigate, useLocation } from 'react-router-dom' import { useNavigate, useLocation } from 'react-router-dom'
import { useState } from 'react'
import { useAuth } from '../contexts/AuthContext'
export default function BottomNav() { export default function BottomNav() {
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
const { user, mongoUser } = useAuth()
const displayName = mongoUser?.displayName || user?.displayName || 'U'
const mongoPhoto = mongoUser && 'photoURL' in mongoUser ? mongoUser.photoURL : null
const photoURL = (mongoPhoto?.startsWith('data:')) ? mongoPhoto : (user?.photoURL || null)
const [imgError, setImgError] = useState(false)
const isActive = (path: string) => location.pathname === path const isActive = (path: string) => location.pathname === path
@@ -14,8 +21,8 @@ export default function BottomNav() {
{/* Write */} {/* Write */}
<button <button
type="button" type="button"
className={`bottom-nav-btn ${isActive('/') ? 'bottom-nav-btn-active' : ''}`} className={`bottom-nav-btn ${isActive('/write') ? 'bottom-nav-btn-active' : ''}`}
onClick={() => navigate('/')} onClick={() => navigate('/write')}
aria-label="Write" aria-label="Write"
> >
{/* Pencil / edit icon */} {/* Pencil / edit icon */}
@@ -50,11 +57,13 @@ export default function BottomNav() {
onClick={() => navigate('/settings')} onClick={() => navigate('/settings')}
aria-label="Settings" aria-label="Settings"
> >
{/* Gear icon */} {photoURL && !imgError ? (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round"> <img src={photoURL} alt={displayName} className="bottom-nav-avatar" onError={() => setImgError(true)} />
<circle cx="12" cy="12" r="3" /> ) : (
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" /> <div className="bottom-nav-avatar bottom-nav-avatar-placeholder">
</svg> {displayName.charAt(0).toUpperCase()}
</div>
)}
<span>Settings</span> <span>Settings</span>
</button> </button>
</nav> </nav>

View File

@@ -0,0 +1,274 @@
import { useState, useRef, useCallback, useEffect } from 'react'
interface Props {
value: string // "HH:MM" 24-hour format
onChange: (value: string) => void
disabled?: boolean
}
const SIZE = 240
const CENTER = SIZE / 2
const CLOCK_RADIUS = 108
const NUM_RADIUS = 82
const HAND_RADIUS = 74
const TIP_RADIUS = 16
function polarToXY(angleDeg: number, radius: number) {
const rad = ((angleDeg - 90) * Math.PI) / 180
return {
x: CENTER + radius * Math.cos(rad),
y: CENTER + radius * Math.sin(rad),
}
}
function parseValue(v: string): { h: number; m: number } {
const [h, m] = v.split(':').map(Number)
return { h: isNaN(h) ? 8 : h, m: isNaN(m) ? 0 : m }
}
export default function ClockTimePicker({ value, onChange, disabled }: Props) {
const { h: initH, m: initM } = parseValue(value)
const [mode, setMode] = useState<'hours' | 'minutes'>('hours')
const [hour24, setHour24] = useState(initH)
const [minute, setMinute] = useState(initM)
const svgRef = useRef<SVGSVGElement>(null)
const isDragging = useRef(false)
// Keep mutable refs for use inside native event listeners
const modeRef = useRef(mode)
const isPMRef = useRef(initH >= 12)
const hour24Ref = useRef(initH)
const minuteRef = useRef(initM)
// Keep refs in sync with state
useEffect(() => { modeRef.current = mode }, [mode])
useEffect(() => { isPMRef.current = hour24 >= 12 }, [hour24])
useEffect(() => { hour24Ref.current = hour24 }, [hour24])
useEffect(() => { minuteRef.current = minute }, [minute])
// Sync when value prop changes externally
useEffect(() => {
const { h, m } = parseValue(value)
setHour24(h)
setMinute(m)
}, [value])
const isPM = hour24 >= 12
const hour12 = hour24 === 0 ? 12 : hour24 > 12 ? hour24 - 12 : hour24
const emit = useCallback(
(h24: number, m: number) => {
onChange(`${h24.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}`)
},
[onChange]
)
const handleAmPm = (pm: boolean) => {
if (disabled) return
let newH = hour24
if (pm && hour24 < 12) newH = hour24 + 12
else if (!pm && hour24 >= 12) newH = hour24 - 12
setHour24(newH)
emit(newH, minute)
}
const applyAngle = useCallback(
(angle: number, currentMode: 'hours' | 'minutes') => {
if (currentMode === 'hours') {
const h12 = Math.round(angle / 30) % 12 || 12
const pm = isPMRef.current
const newH24 = pm ? (h12 === 12 ? 12 : h12 + 12) : (h12 === 12 ? 0 : h12)
setHour24(newH24)
emit(newH24, minuteRef.current)
} else {
const m = Math.round(angle / 6) % 60
setMinute(m)
emit(hour24Ref.current, m)
}
},
[emit]
)
const getSVGAngle = (clientX: number, clientY: number): number => {
if (!svgRef.current) return 0
const rect = svgRef.current.getBoundingClientRect()
const scale = rect.width / SIZE
const x = clientX - rect.left - CENTER * scale
const y = clientY - rect.top - CENTER * scale
return ((Math.atan2(y, x) * 180) / Math.PI + 90 + 360) % 360
}
// Mouse handlers (mouse events don't need passive:false)
const handleMouseDown = (e: React.MouseEvent<SVGSVGElement>) => {
if (disabled) return
isDragging.current = true
applyAngle(getSVGAngle(e.clientX, e.clientY), modeRef.current)
}
const handleMouseMove = (e: React.MouseEvent<SVGSVGElement>) => {
if (!isDragging.current || disabled) return
applyAngle(getSVGAngle(e.clientX, e.clientY), modeRef.current)
}
const handleMouseUp = (e: React.MouseEvent<SVGSVGElement>) => {
if (!isDragging.current) return
isDragging.current = false
applyAngle(getSVGAngle(e.clientX, e.clientY), modeRef.current)
if (modeRef.current === 'hours') setTimeout(() => setMode('minutes'), 120)
}
const handleMouseLeave = () => { isDragging.current = false }
// Attach non-passive touch listeners imperatively to avoid the passive warning
useEffect(() => {
const svg = svgRef.current
if (!svg) return
const onTouchStart = (e: TouchEvent) => {
if (disabled) return
e.preventDefault()
isDragging.current = true
const t = e.touches[0]
applyAngle(getSVGAngle(t.clientX, t.clientY), modeRef.current)
}
const onTouchMove = (e: TouchEvent) => {
if (!isDragging.current || disabled) return
e.preventDefault()
const t = e.touches[0]
applyAngle(getSVGAngle(t.clientX, t.clientY), modeRef.current)
}
const onTouchEnd = (e: TouchEvent) => {
if (!isDragging.current) return
e.preventDefault()
isDragging.current = false
const t = e.changedTouches[0]
applyAngle(getSVGAngle(t.clientX, t.clientY), modeRef.current)
if (modeRef.current === 'hours') setTimeout(() => setMode('minutes'), 120)
}
svg.addEventListener('touchstart', onTouchStart, { passive: false })
svg.addEventListener('touchmove', onTouchMove, { passive: false })
svg.addEventListener('touchend', onTouchEnd, { passive: false })
return () => {
svg.removeEventListener('touchstart', onTouchStart)
svg.removeEventListener('touchmove', onTouchMove)
svg.removeEventListener('touchend', onTouchEnd)
}
}, [applyAngle, disabled])
const handAngle = mode === 'hours' ? (hour12 / 12) * 360 : (minute / 60) * 360
const handTip = polarToXY(handAngle, HAND_RADIUS)
const displayH = hour12.toString()
const displayM = minute.toString().padStart(2, '0')
const selectedNum = mode === 'hours' ? hour12 : minute
const hourPositions = Array.from({ length: 12 }, (_, i) => {
const h = i + 1
return { h, ...polarToXY((h / 12) * 360, NUM_RADIUS) }
})
const minutePositions = Array.from({ length: 12 }, (_, i) => {
const m = i * 5
return { m, ...polarToXY((m / 60) * 360, NUM_RADIUS) }
})
return (
<div className="clock-picker">
{/* Time display */}
<div className="clock-picker__display">
<button
type="button"
className={`clock-picker__seg${mode === 'hours' ? ' clock-picker__seg--active' : ''}`}
onClick={() => !disabled && setMode('hours')}
>
{displayH}
</button>
<span className="clock-picker__colon">:</span>
<button
type="button"
className={`clock-picker__seg${mode === 'minutes' ? ' clock-picker__seg--active' : ''}`}
onClick={() => !disabled && setMode('minutes')}
>
{displayM}
</button>
<div className="clock-picker__ampm">
<button
type="button"
className={`clock-picker__ampm-btn${!isPM ? ' clock-picker__ampm-btn--active' : ''}`}
onClick={() => handleAmPm(false)}
disabled={disabled}
>AM</button>
<button
type="button"
className={`clock-picker__ampm-btn${isPM ? ' clock-picker__ampm-btn--active' : ''}`}
onClick={() => handleAmPm(true)}
disabled={disabled}
>PM</button>
</div>
</div>
{/* Clock face */}
<svg
ref={svgRef}
viewBox={`0 0 ${SIZE} ${SIZE}`}
className="clock-picker__face"
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseLeave}
style={{ cursor: disabled ? 'default' : 'pointer', touchAction: 'none', userSelect: 'none' }}
>
<circle cx={CENTER} cy={CENTER} r={CLOCK_RADIUS} className="clock-picker__bg" />
{/* Shaded sector */}
{(() => {
const start = polarToXY(0, HAND_RADIUS)
const end = polarToXY(handAngle, HAND_RADIUS)
const large = handAngle > 180 ? 1 : 0
return (
<path
d={`M ${CENTER} ${CENTER} L ${start.x} ${start.y} A ${HAND_RADIUS} ${HAND_RADIUS} 0 ${large} 1 ${end.x} ${end.y} Z`}
className="clock-picker__sector"
/>
)
})()}
<line x1={CENTER} y1={CENTER} x2={handTip.x} y2={handTip.y} className="clock-picker__hand" />
<circle cx={CENTER} cy={CENTER} r={4} className="clock-picker__center-dot" />
<circle cx={handTip.x} cy={handTip.y} r={TIP_RADIUS} className="clock-picker__hand-tip" />
{mode === 'hours' && hourPositions.map(({ h, x, y }) => (
<text key={h} x={x} y={y} textAnchor="middle" dominantBaseline="central"
className={`clock-picker__num${h === selectedNum ? ' clock-picker__num--selected' : ''}`}
>{h}</text>
))}
{mode === 'minutes' && minutePositions.map(({ m, x, y }) => (
<text key={m} x={x} y={y} textAnchor="middle" dominantBaseline="central"
className={`clock-picker__num${m === selectedNum ? ' clock-picker__num--selected' : ''}`}
>{m.toString().padStart(2, '0')}</text>
))}
{mode === 'minutes' && Array.from({ length: 60 }, (_, i) => {
if (i % 5 === 0) return null
const angle = (i / 60) * 360
const inner = polarToXY(angle, CLOCK_RADIUS - 10)
const outer = polarToXY(angle, CLOCK_RADIUS - 4)
return <line key={i} x1={inner.x} y1={inner.y} x2={outer.x} y2={outer.y} className="clock-picker__tick" />
})}
</svg>
{/* Mode pills */}
<div className="clock-picker__modes">
<button type="button"
className={`clock-picker__mode-btn${mode === 'hours' ? ' clock-picker__mode-btn--active' : ''}`}
onClick={() => !disabled && setMode('hours')}
>Hours</button>
<button type="button"
className={`clock-picker__mode-btn${mode === 'minutes' ? ' clock-picker__mode-btn--active' : ''}`}
onClick={() => !disabled && setMode('minutes')}
>Minutes</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,26 @@
export function PageLoader({ transparent }: { transparent?: boolean }) {
return (
<div className={`page-loader${transparent ? ' page-loader--transparent' : ''}`} role="status" aria-label="Loading">
<svg
className="page-loader__tree"
viewBox="0 0 60 90"
width="72"
height="72"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
{/* Trunk */}
<rect x="26" y="58" width="8" height="28" rx="4" fill="#A0722A" />
{/* Side canopy depth */}
<circle cx="14" cy="52" r="14" fill="#16a34a" />
<circle cx="46" cy="52" r="14" fill="#16a34a" />
{/* Main canopy */}
<circle cx="30" cy="37" r="22" fill="#22c55e" />
{/* Light highlight */}
<circle cx="20" cy="27" r="10" fill="#4ade80" opacity="0.6" />
{/* Top tip */}
<circle cx="30" cy="17" r="10" fill="#4ade80" />
</svg>
</div>
)
}

View File

@@ -1,27 +1,45 @@
import { type ReactNode } from 'react' import { type ReactNode, Suspense, useState, useEffect } from 'react'
import { Navigate, useLocation } from 'react-router-dom' import { Navigate, useLocation } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
import { PageLoader } from './PageLoader'
type Props = { // Mounts only once Suspense has resolved (chunk is ready).
children: ReactNode // Signals the parent to hide the loader and reveal content.
function ContentReady({ onReady }: { onReady: () => void }) {
useEffect(() => {
onReady()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return null
} }
type Props = { children: ReactNode }
export function ProtectedRoute({ children }: Props) { export function ProtectedRoute({ children }: Props) {
const { user, loading } = useAuth() const { user, loading } = useAuth()
const location = useLocation() const location = useLocation()
if (loading) { // On page refresh: loading starts true → contentReady=false → loader shows throughout.
// On in-app navigation: loading is already false → contentReady=true → no loader shown.
const [contentReady, setContentReady] = useState(() => !loading)
if (!loading && !user) {
return <Navigate to="/" state={{ from: location }} replace />
}
const showLoader = loading || !contentReady
return ( return (
<div className="protected-route__loading" aria-live="polite"> <>
<span className="protected-route__spinner" aria-hidden /> {showLoader && <PageLoader />}
<p>Loading</p> {!loading && user && (
<div style={{ display: contentReady ? 'contents' : 'none' }}>
<Suspense fallback={null}>
<ContentReady onReady={() => setContentReady(true)} />
{children}
</Suspense>
</div> </div>
)}
</>
) )
} }
if (!user) {
return <Navigate to="/login" state={{ from: location }} replace />
}
return <>{children}</>
}

View File

@@ -0,0 +1,72 @@
import { useEffect } from 'react'
export function SaveBookAnimation({ onDone }: { onDone: () => void }) {
useEffect(() => {
const t = setTimeout(onDone, 2900)
return () => clearTimeout(t)
}, [onDone])
return (
<div className="sba-overlay" aria-hidden="true">
<div className="sba-wrap">
<svg viewBox="0 0 260 185" fill="none" xmlns="http://www.w3.org/2000/svg" className="sba-svg">
{/* Drop shadow */}
<ellipse className="sba-shadow" cx="130" cy="172" rx="74" ry="9" fill="rgba(34,197,94,0.14)" />
{/* LEFT PAGE */}
<g className="sba-left-group">
<rect x="22" y="18" width="98" height="140" rx="4" fill="#ffffff" stroke="#d4e8d4" strokeWidth="1.5" />
<line x1="34" y1="50" x2="108" y2="50" stroke="#edf7ed" strokeWidth="1" />
<line x1="34" y1="66" x2="108" y2="66" stroke="#edf7ed" strokeWidth="1" />
<line x1="34" y1="82" x2="108" y2="82" stroke="#edf7ed" strokeWidth="1" />
<line x1="34" y1="98" x2="108" y2="98" stroke="#edf7ed" strokeWidth="1" />
<line x1="34" y1="114" x2="108" y2="114" stroke="#edf7ed" strokeWidth="1" />
<line x1="34" y1="130" x2="108" y2="130" stroke="#edf7ed" strokeWidth="1" />
</g>
{/* SPINE */}
<g className="sba-spine">
<rect x="119" y="16" width="7" height="144" rx="2.5" fill="#22c55e" opacity="0.45" />
</g>
{/* RIGHT PAGE (writing lines live here — folds independently) */}
<g className="sba-right-group">
<rect x="126" y="18" width="98" height="140" rx="4" fill="#f7fdf5" stroke="#d4e8d4" strokeWidth="1.5" />
<line className="sba-line sba-line-1" x1="138" y1="50" x2="212" y2="50" stroke="#22c55e" strokeWidth="2.5" strokeLinecap="round" />
<line className="sba-line sba-line-2" x1="138" y1="72" x2="212" y2="72" stroke="#22c55e" strokeWidth="2.5" strokeLinecap="round" />
<line className="sba-line sba-line-3" x1="138" y1="94" x2="202" y2="94" stroke="#16a34a" strokeWidth="2.5" strokeLinecap="round" />
<line className="sba-line sba-line-4" x1="138" y1="116" x2="195" y2="116" stroke="#16a34a" strokeWidth="2.5" strokeLinecap="round" />
</g>
{/* PEN — independent so it doesn't fold with the page */}
<g className="sba-pen">
{/* body */}
<rect x="-3.5" y="-24" width="7" height="22" rx="2.5" fill="#374151" />
{/* metal band */}
<rect x="-3.5" y="-5" width="7" height="3" fill="#9ca3af" />
{/* nib */}
<polygon points="-3.5,-2 3.5,-2 0,7" fill="#f59e0b" />
{/* ink dot */}
<circle cx="0" cy="7" r="1.8" fill="#15803d" />
</g>
{/* CLOSED BOOK — hidden until pages fold away */}
<g className="sba-closed-book">
{/* spine side */}
<rect x="55" y="18" width="150" height="140" rx="7" fill="#15803d" />
{/* cover face */}
<rect x="63" y="18" width="135" height="140" rx="5" fill="#22c55e" />
{/* spine shadow */}
<rect x="55" y="18" width="10" height="140" rx="4" fill="rgba(0,0,0,0.18)" />
{/* decorative ruled lines */}
<line x1="83" y1="76" x2="183" y2="76" stroke="rgba(255,255,255,0.22)" strokeWidth="1.5" />
<line x1="83" y1="93" x2="183" y2="93" stroke="rgba(255,255,255,0.22)" strokeWidth="1.5" />
<line x1="83" y1="110" x2="170" y2="110" stroke="rgba(255,255,255,0.22)" strokeWidth="1.5" />
{/* checkmark */}
<path className="sba-check" d="M96 90 L115 109 L162 62" stroke="white" strokeWidth="6" strokeLinecap="round" strokeLinejoin="round" />
</g>
</svg>
</div>
</div>
)
}

View File

@@ -0,0 +1,153 @@
const LEAVES = [
// Left low cluster (b1 tip ~40,308)
{ cx: 34, cy: 302, r: 18, fill: '#22c55e', delay: '1.65s' },
{ cx: 14, cy: 295, r: 15, fill: '#16a34a', delay: '1.70s' },
{ cx: 26, cy: 280, r: 16, fill: '#4ade80', delay: '1.68s' },
{ cx: 48, cy: 290, r: 13, fill: '#15803d', delay: '1.72s' },
{ cx: 8, cy: 312, r: 12, fill: '#22c55e', delay: '1.75s' },
// Right low cluster (b2 tip ~240,302)
{ cx: 246, cy: 296, r: 18, fill: '#22c55e', delay: '1.75s' },
{ cx: 266, cy: 290, r: 15, fill: '#16a34a', delay: '1.80s' },
{ cx: 254, cy: 275, r: 16, fill: '#4ade80', delay: '1.78s' },
{ cx: 234, cy: 286, r: 13, fill: '#15803d', delay: '1.82s' },
{ cx: 270, cy: 308, r: 12, fill: '#22c55e', delay: '1.85s' },
// sb3/sb4 mid-tips
{ cx: 50, cy: 270, r: 13, fill: '#4ade80', delay: '1.80s' },
{ cx: 228, cy: 267, r: 13, fill: '#4ade80', delay: '1.85s' },
// sb1/sb2 outer tips
{ cx: 8, cy: 255, r: 14, fill: '#4ade80', delay: '1.90s' },
{ cx: 270, cy: 251, r: 14, fill: '#4ade80', delay: '1.90s' },
// Left mid cluster (b3 tip ~44,258)
{ cx: 38, cy: 252, r: 16, fill: '#22c55e', delay: '2.05s' },
{ cx: 18, cy: 246, r: 13, fill: '#4ade80', delay: '2.10s' },
{ cx: 30, cy: 232, r: 14, fill: '#16a34a', delay: '2.08s' },
{ cx: 52, cy: 240, r: 11, fill: '#86efac', delay: '2.12s' },
{ cx: 12, cy: 264, r: 10, fill: '#22c55e', delay: '2.15s' },
// Right mid cluster (b4 tip ~236,255)
{ cx: 242, cy: 248, r: 16, fill: '#22c55e', delay: '2.10s' },
{ cx: 262, cy: 242, r: 13, fill: '#4ade80', delay: '2.15s' },
{ cx: 250, cy: 228, r: 14, fill: '#16a34a', delay: '2.12s' },
{ cx: 230, cy: 238, r: 11, fill: '#86efac', delay: '2.18s' },
{ cx: 266, cy: 260, r: 10, fill: '#22c55e', delay: '2.20s' },
// sb5/sb6 outer tips (~16,214 and ~262,210)
{ cx: 12, cy: 208, r: 13, fill: '#86efac', delay: '2.30s' },
{ cx: 266, cy: 206, r: 13, fill: '#86efac', delay: '2.30s' },
// Left upper cluster (b5 tip ~86,218)
{ cx: 80, cy: 212, r: 17, fill: '#4ade80', delay: '2.45s' },
{ cx: 62, cy: 202, r: 14, fill: '#22c55e', delay: '2.50s' },
{ cx: 90, cy: 196, r: 12, fill: '#86efac', delay: '2.48s' },
{ cx: 68, cy: 188, r: 13, fill: '#4ade80', delay: '2.52s' },
// Right upper cluster (b6 tip ~194,214)
{ cx: 200, cy: 208, r: 17, fill: '#4ade80', delay: '2.48s' },
{ cx: 218, cy: 198, r: 14, fill: '#22c55e', delay: '2.52s' },
{ cx: 192, cy: 193, r: 12, fill: '#86efac', delay: '2.50s' },
{ cx: 210, cy: 185, r: 13, fill: '#4ade80', delay: '2.55s' },
// Top center canopy (b7 tip ~128,196)
{ cx: 120, cy: 188, r: 16, fill: '#4ade80', delay: '2.60s' },
{ cx: 140, cy: 176, r: 21, fill: '#22c55e', delay: '2.65s' },
{ cx: 160, cy: 188, r: 16, fill: '#4ade80', delay: '2.62s' },
{ cx: 126, cy: 166, r: 13, fill: '#16a34a', delay: '2.68s' },
{ cx: 154, cy: 164, r: 14, fill: '#86efac', delay: '2.72s' },
{ cx: 140, cy: 154, r: 18, fill: '#22c55e', delay: '2.75s' },
{ cx: 134, cy: 142, r: 12, fill: '#4ade80', delay: '2.78s' },
{ cx: 148, cy: 140, r: 11, fill: '#86efac', delay: '2.80s' },
]
const PARTICLES = [
{ cx: 45, cy: 420, r: 5, fill: '#4ade80', delay: '3.5s', dur: '7s' },
{ cx: 235, cy: 415, r: 3, fill: '#86efac', delay: '5.0s', dur: '9s' },
{ cx: 88, cy: 425, r: 4, fill: '#22c55e', delay: '4.0s', dur: '8s' },
{ cx: 192, cy: 418, r: 5, fill: '#4ade80', delay: '6.0s', dur: '10s' },
{ cx: 140, cy: 422, r: 3, fill: '#86efac', delay: '3.8s', dur: '6s' },
{ cx: 115, cy: 416, r: 4, fill: '#22c55e', delay: '7.0s', dur: '8s' },
{ cx: 165, cy: 424, r: 3, fill: '#4ade80', delay: '4.5s', dur: '7s' },
]
export function TreeAnimation() {
return (
<div className="tree-wrap">
<svg
className="tree-svg"
viewBox="0 115 280 325"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
{/* Floating leaf particles */}
{PARTICLES.map((p, i) => (
<circle
key={i}
className="t-particle"
cx={p.cx} cy={p.cy} r={p.r} fill={p.fill}
style={{ animationDelay: p.delay, animationDuration: p.dur }}
/>
))}
{/* Roots */}
<path className="t-root" style={{ animationDelay: '1.00s' }}
d="M 134 408 C 108 414 80 412 56 418" stroke="#C4954A" strokeWidth="5" strokeLinecap="round" />
<path className="t-root" style={{ animationDelay: '1.05s' }}
d="M 146 408 C 172 414 200 412 224 418" stroke="#C4954A" strokeWidth="5" strokeLinecap="round" />
<path className="t-root" style={{ animationDelay: '1.02s' }}
d="M 140 410 C 138 422 134 430 128 436" stroke="#C4954A" strokeWidth="4" strokeLinecap="round" />
<path className="t-root" style={{ animationDelay: '1.08s' }}
d="M 140 410 C 142 422 146 430 152 436" stroke="#C4954A" strokeWidth="4" strokeLinecap="round" />
{/* Trunk — two overlapping strokes for depth */}
<path className="t-trunk" style={{ animationDelay: '0.20s' }}
d="M 133 410 L 133 265" stroke="#8B6120" strokeWidth="17" strokeLinecap="round" />
<path className="t-trunk" style={{ animationDelay: '0.28s' }}
d="M 147 410 L 147 265" stroke="#C4954A" strokeWidth="7" strokeLinecap="round" />
{/* Level-1 branches */}
<path className="t-branch" style={{ animationDelay: '1.00s' }}
d="M 136 356 C 104 336 70 322 40 308" stroke="#A0732A" strokeWidth="8" strokeLinecap="round" />
<path className="t-branch" style={{ animationDelay: '1.10s' }}
d="M 144 348 C 176 328 210 314 240 302" stroke="#A0732A" strokeWidth="8" strokeLinecap="round" />
{/* Level-2 branches */}
<path className="t-branch" style={{ animationDelay: '1.50s' }}
d="M 136 310 C 104 292 70 276 44 258" stroke="#9B6D28" strokeWidth="6" strokeLinecap="round" />
<path className="t-branch" style={{ animationDelay: '1.60s' }}
d="M 144 304 C 176 286 210 270 236 255" stroke="#9B6D28" strokeWidth="6" strokeLinecap="round" />
{/* Level-3 branches */}
<path className="t-branch" style={{ animationDelay: '1.90s' }}
d="M 136 272 C 115 253 100 237 86 218" stroke="#9B6D28" strokeWidth="5" strokeLinecap="round" />
<path className="t-branch" style={{ animationDelay: '2.00s' }}
d="M 144 268 C 165 249 180 233 194 214" stroke="#9B6D28" strokeWidth="5" strokeLinecap="round" />
<path className="t-branch" style={{ animationDelay: '2.10s' }}
d="M 140 252 C 136 232 132 215 128 196" stroke="#9B6D28" strokeWidth="4" strokeLinecap="round" />
{/* Sub-branches off level-1 */}
<path className="t-branch" style={{ animationDelay: '1.55s' }}
d="M 40 308 C 24 292 16 276 12 260" stroke="#8B6520" strokeWidth="4" strokeLinecap="round" />
<path className="t-branch" style={{ animationDelay: '1.65s' }}
d="M 240 302 C 256 286 262 270 266 255" stroke="#8B6520" strokeWidth="4" strokeLinecap="round" />
<path className="t-branch" style={{ animationDelay: '1.45s' }}
d="M 74 326 C 60 308 54 292 52 276" stroke="#8B6520" strokeWidth="3" strokeLinecap="round" />
<path className="t-branch" style={{ animationDelay: '1.55s' }}
d="M 206 320 C 220 302 224 286 224 271" stroke="#8B6520" strokeWidth="3" strokeLinecap="round" />
{/* Sub-branches off level-2 */}
<path className="t-branch" style={{ animationDelay: '2.05s' }}
d="M 44 258 C 28 242 20 228 16 214" stroke="#8B6520" strokeWidth="3" strokeLinecap="round" />
<path className="t-branch" style={{ animationDelay: '2.15s' }}
d="M 236 255 C 252 239 258 225 262 210" stroke="#8B6520" strokeWidth="3" strokeLinecap="round" />
{/* Leaves — inside a group so the whole canopy can sway */}
<g className="t-canopy">
{LEAVES.map((l, i) => (
<circle
key={i}
className="t-leaf"
cx={l.cx} cy={l.cy} r={l.r}
fill={l.fill}
style={{ animationDelay: l.delay }}
/>
))}
</g>
</svg>
</div>
)
}

View File

@@ -10,6 +10,8 @@ import {
onAuthStateChanged, onAuthStateChanged,
setPersistence, setPersistence,
signInWithPopup, signInWithPopup,
signInWithRedirect,
getRedirectResult,
signOut as firebaseSignOut, signOut as firebaseSignOut,
type User, type User,
} from 'firebase/auth' } from 'firebase/auth'
@@ -28,6 +30,7 @@ import {
saveEncryptedSecretKey, saveEncryptedSecretKey,
getEncryptedSecretKey, getEncryptedSecretKey,
} from '../lib/crypto' } from '../lib/crypto'
import { REMINDER_TIME_KEY, REMINDER_ENABLED_KEY } from '../hooks/useReminder'
type MongoUser = { type MongoUser = {
id: string id: string
@@ -35,6 +38,14 @@ type MongoUser = {
displayName?: string displayName?: string
photoURL?: string photoURL?: string
theme?: string theme?: string
tutorial?: boolean
backgroundImage?: string | null
backgroundImages?: string[]
reminder?: {
enabled: boolean
time?: string
timezone?: string
}
} }
type AuthContextValue = { type AuthContextValue = {
@@ -43,6 +54,7 @@ type AuthContextValue = {
mongoUser: MongoUser | null mongoUser: MongoUser | null
loading: boolean loading: boolean
secretKey: Uint8Array | null secretKey: Uint8Array | null
authError: string | null
signInWithGoogle: () => Promise<void> signInWithGoogle: () => Promise<void>
signOut: () => Promise<void> signOut: () => Promise<void>
refreshMongoUser: () => Promise<void> refreshMongoUser: () => Promise<void>
@@ -56,6 +68,22 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [mongoUser, setMongoUser] = useState<MongoUser | null>(null) const [mongoUser, setMongoUser] = useState<MongoUser | null>(null)
const [secretKey, setSecretKey] = useState<Uint8Array | null>(null) const [secretKey, setSecretKey] = useState<Uint8Array | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [authError, setAuthError] = useState<string | null>(null)
// Apply custom background image whenever mongoUser changes
useEffect(() => {
const bg = mongoUser?.backgroundImage
if (bg) {
document.body.style.backgroundImage = `url(${bg})`
document.body.style.backgroundSize = 'cover'
document.body.style.backgroundPosition = 'center'
document.body.style.backgroundAttachment = 'fixed'
document.body.classList.add('gj-has-bg')
} else {
document.body.style.backgroundImage = ''
document.body.classList.remove('gj-has-bg')
}
}, [mongoUser?.backgroundImage])
// Initialize encryption keys on login // Initialize encryption keys on login
async function initializeEncryption(authUser: User) { async function initializeEncryption(authUser: User) {
@@ -113,6 +141,18 @@ export function AuthProvider({ children }: { children: ReactNode }) {
} }
} }
function syncReminderFromDb(mongoUser: MongoUser) {
const r = mongoUser.reminder
if (r) {
localStorage.setItem(REMINDER_ENABLED_KEY, r.enabled ? 'true' : 'false')
if (r.time) localStorage.setItem(REMINDER_TIME_KEY, r.time)
else localStorage.removeItem(REMINDER_TIME_KEY)
} else {
localStorage.setItem(REMINDER_ENABLED_KEY, 'false')
localStorage.removeItem(REMINDER_TIME_KEY)
}
}
// Register or fetch user from MongoDB // Register or fetch user from MongoDB
async function syncUserWithDatabase(authUser: User) { async function syncUserWithDatabase(authUser: User) {
try { try {
@@ -126,12 +166,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
try { try {
console.log('[Auth] Fetching user by email:', email) console.log('[Auth] Fetching user by email:', email)
const existingUser = await getUserByEmail(email, token) as MongoUser const existingUser = await getUserByEmail(email, token) as MongoUser
console.log('[Auth] Found existing user:', existingUser.id)
setUserId(existingUser.id) setUserId(existingUser.id)
setMongoUser(existingUser) setMongoUser(existingUser)
syncReminderFromDb(existingUser)
} catch (error) { } catch (error) {
console.warn('[Auth] User not found, registering...', error) console.warn('[Auth] User not found, registering...', error)
// User doesn't exist, register them
const newUser = await registerUser( const newUser = await registerUser(
{ {
email, email,
@@ -143,6 +182,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
console.log('[Auth] Registered new user:', newUser.id) console.log('[Auth] Registered new user:', newUser.id)
setUserId(newUser.id) setUserId(newUser.id)
setMongoUser(newUser) setMongoUser(newUser)
syncReminderFromDb(newUser)
} }
} catch (error) { } catch (error) {
console.error('[Auth] Error syncing user with database:', error) console.error('[Auth] Error syncing user with database:', error)
@@ -151,6 +191,12 @@ export function AuthProvider({ children }: { children: ReactNode }) {
} }
useEffect(() => { useEffect(() => {
// Handle returning from a redirect sign-in (mobile flow)
getRedirectResult(auth).catch((error) => {
console.error('[Auth] Redirect sign-in error:', error)
setAuthError(error instanceof Error ? error.message : 'Sign-in failed')
})
const unsubscribe = onAuthStateChanged(auth, async (u) => { const unsubscribe = onAuthStateChanged(auth, async (u) => {
setUser(u) setUser(u)
if (u) { if (u) {
@@ -170,8 +216,19 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}, []) }, [])
async function signInWithGoogle() { async function signInWithGoogle() {
setAuthError(null)
await setPersistence(auth, browserLocalPersistence) await setPersistence(auth, browserLocalPersistence)
try {
await signInWithPopup(auth, googleProvider) await signInWithPopup(auth, googleProvider)
} catch (err: unknown) {
const code = (err as { code?: string })?.code
if (code === 'auth/popup-blocked') {
// Popup was blocked (common on iOS Safari / Android WebViews) — fall back to redirect
await signInWithRedirect(auth, googleProvider)
} else {
throw err
}
}
} }
async function refreshMongoUser() { async function refreshMongoUser() {
@@ -187,14 +244,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
} }
async function signOut() { async function signOut() {
// Clear secret key from memory
setSecretKey(null) setSecretKey(null)
setMongoUser(null) setMongoUser(null)
// Reset onboarding so tour shows again on next login
localStorage.removeItem('gj-onboarding-done')
localStorage.removeItem('gj-tour-pending-step') localStorage.removeItem('gj-tour-pending-step')
// Keep device key and encrypted key for next login localStorage.removeItem(REMINDER_TIME_KEY)
// Do NOT clear localStorage or IndexedDB localStorage.removeItem(REMINDER_ENABLED_KEY)
await firebaseSignOut(auth) await firebaseSignOut(auth)
setUserId(null) setUserId(null)
} }
@@ -205,6 +259,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
mongoUser, mongoUser,
secretKey, secretKey,
loading, loading,
authError,
signInWithGoogle, signInWithGoogle,
signOut, signOut,
refreshMongoUser, refreshMongoUser,

43
src/hooks/reminderApi.ts Normal file
View File

@@ -0,0 +1,43 @@
/** API calls specific to FCM token registration and reminder settings. */
const BASE = import.meta.env.VITE_API_URL || 'http://localhost:8001/api'
async function post(url: string, body: unknown, token: string) {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
credentials: 'include',
body: JSON.stringify(body),
})
if (!res.ok) {
const err = await res.json().catch(() => ({}))
throw new Error(err.detail || res.statusText)
}
return res.json()
}
async function put(url: string, body: unknown, token: string) {
const res = await fetch(url, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
credentials: 'include',
body: JSON.stringify(body),
})
if (!res.ok) {
const err = await res.json().catch(() => ({}))
throw new Error(err.detail || res.statusText)
}
return res.json()
}
export function saveFcmToken(userId: string, fcmToken: string, authToken: string) {
return post(`${BASE}/notifications/fcm-token`, { userId, fcmToken }, authToken)
}
export function saveReminderSettings(
userId: string,
settings: { time?: string; enabled: boolean; timezone?: string },
authToken: string
) {
return put(`${BASE}/notifications/reminder/${userId}`, settings, authToken)
}

View File

@@ -1,19 +1,10 @@
import { useCallback, useRef } from 'react' import { useCallback, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { driver, type DriveStep } from 'driver.js' import { driver, type DriveStep } from 'driver.js'
import 'driver.js/dist/driver.css' import 'driver.js/dist/driver.css'
const ONBOARDING_KEY = 'gj-onboarding-done'
const TOUR_PENDING_KEY = 'gj-tour-pending-step' const TOUR_PENDING_KEY = 'gj-tour-pending-step'
export function hasSeenOnboarding(): boolean {
return localStorage.getItem(ONBOARDING_KEY) === 'true'
}
export function markOnboardingDone(): void {
localStorage.setItem(ONBOARDING_KEY, 'true')
}
export function hasPendingTourStep(): string | null { export function hasPendingTourStep(): string | null {
return localStorage.getItem(TOUR_PENDING_KEY) return localStorage.getItem(TOUR_PENDING_KEY)
} }
@@ -137,15 +128,17 @@ function getSettingsSteps(isMobile: boolean): DriveStep[] {
export function useOnboardingTour() { export function useOnboardingTour() {
const navigate = useNavigate() const navigate = useNavigate()
const driverRef = useRef<ReturnType<typeof driver> | null>(null) const driverRef = useRef<ReturnType<typeof driver> | null>(null)
const [isTourActive, setIsTourActive] = useState(false)
const startTour = useCallback(() => { const startTour = useCallback(() => {
const isMobile = window.innerWidth < 860 const isMobile = window.innerWidth < 860
setIsTourActive(true)
const driverObj = driver({ const driverObj = driver({
...driverDefaults(), ...driverDefaults(),
onDestroyStarted: () => { onDestroyStarted: () => {
markOnboardingDone()
clearPendingTourStep() clearPendingTourStep()
setIsTourActive(false)
driverObj.destroy() driverObj.destroy()
}, },
onNextClick: () => { onNextClick: () => {
@@ -155,6 +148,7 @@ export function useOnboardingTour() {
// Last home step → navigate to /history // Last home step → navigate to /history
if (activeIndex === steps.length - 1) { if (activeIndex === steps.length - 1) {
localStorage.setItem(TOUR_PENDING_KEY, 'history') localStorage.setItem(TOUR_PENDING_KEY, 'history')
setIsTourActive(false)
driverObj.destroy() driverObj.destroy()
navigate('/history') navigate('/history')
return return
@@ -175,7 +169,6 @@ export function useOnboardingTour() {
const driverObj = driver({ const driverObj = driver({
...driverDefaults(), ...driverDefaults(),
onDestroyStarted: () => { onDestroyStarted: () => {
markOnboardingDone()
clearPendingTourStep() clearPendingTourStep()
driverObj.destroy() driverObj.destroy()
}, },
@@ -206,7 +199,6 @@ export function useOnboardingTour() {
const driverObj = driver({ const driverObj = driver({
...driverDefaults(), ...driverDefaults(),
onDestroyStarted: () => { onDestroyStarted: () => {
markOnboardingDone()
clearPendingTourStep() clearPendingTourStep()
driverObj.destroy() driverObj.destroy()
}, },
@@ -216,10 +208,9 @@ export function useOnboardingTour() {
// Last settings step → navigate to / // Last settings step → navigate to /
if (activeIndex === steps.length - 1) { if (activeIndex === steps.length - 1) {
markOnboardingDone()
clearPendingTourStep() clearPendingTourStep()
driverObj.destroy() driverObj.destroy()
navigate('/') navigate('/write')
return return
} }
@@ -232,5 +223,5 @@ export function useOnboardingTour() {
setTimeout(() => driverObj.drive(), 300) setTimeout(() => driverObj.drive(), 300)
}, [navigate]) }, [navigate])
return { startTour, continueTourOnHistory, continueTourOnSettings } return { startTour, continueTourOnHistory, continueTourOnSettings, isTourActive }
} }

View File

@@ -0,0 +1,61 @@
import { useState, useEffect } from 'react'
interface BeforeInstallPromptEvent extends Event {
prompt: () => Promise<void>
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>
}
interface PWAInstall {
canInstall: boolean // Android/Chrome: native prompt available
isIOS: boolean // iOS Safari: must show manual instructions
isInstalled: boolean // Already running as installed PWA
triggerInstall: () => Promise<void>
}
export function usePWAInstall(): PWAInstall {
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null)
const [isInstalled, setIsInstalled] = useState(false)
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !(window as unknown as { MSStream?: unknown }).MSStream
useEffect(() => {
// Detect if already installed (standalone mode)
const mq = window.matchMedia('(display-mode: standalone)')
const iosStandalone = (navigator as unknown as { standalone?: boolean }).standalone === true
if (mq.matches || iosStandalone) {
setIsInstalled(true)
return
}
const handler = (e: Event) => {
e.preventDefault()
setDeferredPrompt(e as BeforeInstallPromptEvent)
}
window.addEventListener('beforeinstallprompt', handler)
window.addEventListener('appinstalled', () => {
setIsInstalled(true)
setDeferredPrompt(null)
})
return () => window.removeEventListener('beforeinstallprompt', handler)
}, [])
const triggerInstall = async () => {
if (!deferredPrompt) return
await deferredPrompt.prompt()
const { outcome } = await deferredPrompt.userChoice
if (outcome === 'accepted') {
setIsInstalled(true)
setDeferredPrompt(null)
}
}
return {
canInstall: !!deferredPrompt,
isIOS,
isInstalled,
triggerInstall,
}
}

43
src/hooks/usePageMeta.ts Normal file
View File

@@ -0,0 +1,43 @@
import { useEffect } from 'react'
interface PageMeta {
title: string
description: string
canonical: string
ogTitle?: string
ogDescription?: string
}
export function usePageMeta({ title, description, canonical, ogTitle, ogDescription }: PageMeta) {
useEffect(() => {
document.title = title
setMeta('name', 'description', description)
setMeta('property', 'og:title', ogTitle ?? title)
setMeta('property', 'og:description', ogDescription ?? description)
setMeta('property', 'og:url', canonical)
setMeta('name', 'twitter:title', ogTitle ?? title)
setMeta('name', 'twitter:description', ogDescription ?? description)
setLink('canonical', canonical)
}, [title, description, canonical, ogTitle, ogDescription])
}
function setMeta(attr: 'name' | 'property', key: string, value: string) {
let el = document.querySelector<HTMLMetaElement>(`meta[${attr}="${key}"]`)
if (!el) {
el = document.createElement('meta')
el.setAttribute(attr, key)
document.head.appendChild(el)
}
el.setAttribute('content', value)
}
function setLink(rel: string, href: string) {
let el = document.querySelector<HTMLLinkElement>(`link[rel="${rel}"]`)
if (!el) {
el = document.createElement('link')
el.setAttribute('rel', rel)
document.head.appendChild(el)
}
el.setAttribute('href', href)
}

134
src/hooks/useReminder.ts Normal file
View File

@@ -0,0 +1,134 @@
/**
* Daily reminder — uses Firebase Cloud Messaging (FCM) for true push notifications.
* Works even when the browser is fully closed (on mobile PWA).
*
* Flow:
* 1. User picks a time in Settings → enableReminder() is called
* 2. Browser notification permission is requested
* 3. FCM token is fetched via the firebase-messaging-sw.js service worker
* 4. Token + reminder settings are saved to the backend
* 5. Backend scheduler sends a push at the right time each day
*/
import { getToken, onMessage } from 'firebase/messaging'
import { messagingPromise } from '../lib/firebase'
import { saveFcmToken, saveReminderSettings } from './reminderApi'
const VAPID_KEY = import.meta.env.VITE_FIREBASE_VAPID_KEY
export const REMINDER_TIME_KEY = 'gj-reminder-time'
export const REMINDER_ENABLED_KEY = 'gj-reminder-enabled'
export function getSavedReminderTime(): string | null {
return localStorage.getItem(REMINDER_TIME_KEY)
}
export function isReminderEnabled(): boolean {
return localStorage.getItem(REMINDER_ENABLED_KEY) === 'true'
}
/** Get FCM token using the existing sw.js (which includes Firebase messaging). */
async function getFcmToken(): Promise<string | null> {
const messaging = await messagingPromise
if (!messaging) {
console.warn('[FCM] Firebase Messaging not supported in this browser')
return null
}
const swReg = await navigator.serviceWorker.ready
console.log('[FCM] Service worker ready:', swReg.active?.scriptURL)
const token = await getToken(messaging, { vapidKey: VAPID_KEY, serviceWorkerRegistration: swReg })
if (token) {
console.log('[FCM] Token obtained:', token.slice(0, 20) + '…')
} else {
console.warn('[FCM] getToken returned empty — VAPID key wrong or SW not registered?')
}
return token
}
/**
* Request permission, get FCM token, and save reminder settings to backend.
* Returns an error string on failure, or null on success.
*/
export async function enableReminder(
timeStr: string,
userId: string,
authToken: string
): Promise<string | null> {
if (!('Notification' in window)) {
return 'Notifications are not supported in this browser.'
}
let perm = Notification.permission
if (perm === 'default') {
perm = await Notification.requestPermission()
}
if (perm !== 'granted') {
return 'Permission denied. To enable reminders, allow notifications for this site in your browser settings.'
}
try {
const fcmToken = await getFcmToken()
if (!fcmToken) {
return 'Push notifications are not supported in this browser. Try Chrome or Edge.'
}
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
console.log('[FCM] Saving token and reminder settings:', { timeStr, timezone })
await saveFcmToken(userId, fcmToken, authToken)
console.log('[FCM] Token saved to backend')
await saveReminderSettings(userId, { time: timeStr, enabled: true, timezone }, authToken)
console.log('[FCM] Reminder settings saved to backend')
localStorage.setItem(REMINDER_TIME_KEY, timeStr)
localStorage.setItem(REMINDER_ENABLED_KEY, 'true')
return null
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
console.error('[FCM] Reminder setup failed:', msg)
return `Failed to set up reminder: ${msg}`
}
}
/** Pause the reminder (keeps the saved time). */
export async function disableReminder(userId: string, authToken: string): Promise<void> {
await saveReminderSettings(userId, { enabled: false }, authToken)
localStorage.setItem(REMINDER_ENABLED_KEY, 'false')
}
/** Re-enable using the previously saved time. Returns error string or null. */
export async function reenableReminder(userId: string, authToken: string): Promise<string | null> {
const time = localStorage.getItem(REMINDER_TIME_KEY)
if (!time) return 'No reminder time saved.'
return enableReminder(time, userId, authToken)
}
/**
* Listen for foreground FCM messages and show a manual notification.
* Call once after the app mounts. Returns an unsubscribe function.
*/
export async function listenForegroundMessages(): Promise<() => void> {
const messaging = await messagingPromise
if (!messaging) return () => {}
console.log('[FCM] Foreground message listener registered')
const unsubscribe = onMessage(messaging, (payload) => {
console.log('[FCM] Foreground message received:', payload)
const title = payload.notification?.title || 'Grateful Journal 🌱'
const body = payload.notification?.body || "You haven't written today yet."
if (Notification.permission !== 'granted') {
console.warn('[FCM] Notification permission not granted — cannot show notification')
return
}
new Notification(title, {
body,
icon: '/web-app-manifest-192x192.png',
tag: 'gj-daily-reminder',
})
})
return unsubscribe
}

83
src/hooks/useSwipeNav.ts Normal file
View File

@@ -0,0 +1,83 @@
import { useEffect } from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
const PAGES = ['/write', '/history', '/settings']
const SWIPE_THRESHOLD = 55 // minimum horizontal px to count as a swipe
const DESKTOP_BREAKPOINT = 860
/** Walk up the DOM and return true if any ancestor is horizontally scrollable */
function isInHScrollable(el: Element | null): boolean {
while (el && el !== document.body) {
const style = window.getComputedStyle(el)
const ox = style.overflowX
if ((ox === 'scroll' || ox === 'auto') && el.scrollWidth > el.clientWidth) {
return true
}
el = el.parentElement
}
return false
}
/** Swipe left/right to navigate between the three main pages (mobile only) */
export function useSwipeNav() {
const navigate = useNavigate()
const location = useLocation()
useEffect(() => {
let startX = 0
let startY = 0
let startTarget: Element | null = null
let cancelled = false
const onTouchStart = (e: TouchEvent) => {
startX = e.touches[0].clientX
startY = e.touches[0].clientY
startTarget = e.target as Element
cancelled = false
}
const onTouchMove = (e: TouchEvent) => {
// If vertical movement dominates early, cancel the swipe so we never
// accidentally navigate while the user is scrolling.
const dx = Math.abs(e.touches[0].clientX - startX)
const dy = Math.abs(e.touches[0].clientY - startY)
if (!cancelled && dy > dx && dy > 10) cancelled = true
}
const onTouchEnd = (e: TouchEvent) => {
if (cancelled) return
if (window.innerWidth >= DESKTOP_BREAKPOINT) return
const dx = e.changedTouches[0].clientX - startX
const dy = e.changedTouches[0].clientY - startY
// Must be predominantly horizontal
if (Math.abs(dx) <= Math.abs(dy)) return
// Must clear the distance threshold
if (Math.abs(dx) < SWIPE_THRESHOLD) return
// Don't swipe-navigate when inside a horizontal scroll container
if (isInHScrollable(startTarget)) return
// Don't swipe-navigate when a modal/overlay is open
if (document.querySelector('.confirm-modal-overlay, .cropper-overlay, .reminder-modal-overlay')) return
const idx = PAGES.indexOf(location.pathname)
if (idx === -1) return
if (dx < 0 && idx < PAGES.length - 1) {
navigate(PAGES[idx + 1]) // swipe left → next page
} else if (dx > 0 && idx > 0) {
navigate(PAGES[idx - 1]) // swipe right → previous page
}
}
document.addEventListener('touchstart', onTouchStart, { passive: true })
document.addEventListener('touchmove', onTouchMove, { passive: true })
document.addEventListener('touchend', onTouchEnd, { passive: true })
return () => {
document.removeEventListener('touchstart', onTouchStart)
document.removeEventListener('touchmove', onTouchMove)
document.removeEventListener('touchend', onTouchEnd)
}
}, [navigate, location.pathname])
}

View File

@@ -5,6 +5,11 @@
*::before, *::before,
*::after { *::after {
box-sizing: border-box; box-sizing: border-box;
user-select: none;
}
input, textarea {
user-select: text;
} }
:root { :root {
@@ -19,7 +24,8 @@
--color-primary: #22c55e; --color-primary: #22c55e;
--color-primary-hover: #16a34a; --color-primary-hover: #16a34a;
--color-bg-soft: #eef6ee; --color-bg-soft: #eef6ee;
--color-surface: #ffffff; --card-bg-opacity: 0.7;
--color-surface: rgb(255 255 255 / var(--card-bg-opacity));
--color-accent-light: #dcfce7; --color-accent-light: #dcfce7;
--color-text: #1a1a1a; --color-text: #1a1a1a;
--color-text-muted: #6b7280; --color-text-muted: #6b7280;
@@ -76,7 +82,7 @@ button:focus-visible {
--color-primary: #4ade80; --color-primary: #4ade80;
--color-primary-hover: #22c55e; --color-primary-hover: #22c55e;
--color-bg-soft: #0f0f0f; --color-bg-soft: #0f0f0f;
--color-surface: #1a1a1a; --color-surface: rgb(26 26 26 / var(--card-bg-opacity));
--color-accent-light: rgba(74, 222, 128, 0.12); --color-accent-light: rgba(74, 222, 128, 0.12);
--color-text: #e8f5e8; --color-text: #e8f5e8;
--color-text-muted: #7a8a7a; --color-text-muted: #7a8a7a;
@@ -90,3 +96,28 @@ button:focus-visible {
[data-theme="dark"] body { [data-theme="dark"] body {
background: #0a0a0a; background: #0a0a0a;
} }
/* ── Liquid Glass theme root overrides ───────────────────── */
[data-theme="liquid-glass"] {
--glass-bg: rgba(255, 255, 255, 0.18);
--glass-blur: blur(28px) saturate(200%) brightness(1.05);
--glass-border: 1px solid rgba(255, 255, 255, 0.55);
--glass-shadow: 0 8px 32px rgba(0, 0, 0, 0.12), 0 1px 0 rgba(255, 255, 255, 0.7) inset;
--color-primary: #16a34a;
--color-primary-hover: #15803d;
--color-bg-soft: transparent;
--color-surface: var(--glass-bg);
--color-accent-light: rgba(220, 252, 231, 0.4);
--color-text: #0f172a;
--color-text-muted: #334155;
--color-border: rgba(255, 255, 255, 0.4);
color: var(--color-text);
background-color: transparent;
caret-color: #16a34a;
}
/* Same bg as light theme when no custom image is set */
[data-theme="liquid-glass"] body:not(.gj-has-bg) {
background: #eef6ee;
}

View File

@@ -70,7 +70,7 @@ export async function getUserByEmail(email: string, token: string) {
export async function updateUserProfile( export async function updateUserProfile(
userId: string, userId: string,
updates: { displayName?: string; photoURL?: string; theme?: string }, updates: { displayName?: string; photoURL?: string; theme?: string; tutorial?: boolean; backgroundImage?: string | null; backgroundImages?: string[] },
token: string token: string
) { ) {
return apiCall(`/users/${userId}`, { return apiCall(`/users/${userId}`, {

View File

@@ -1,5 +1,6 @@
import { initializeApp } from 'firebase/app' import { initializeApp } from 'firebase/app'
import { getAuth, GoogleAuthProvider } from 'firebase/auth' import { getAuth, GoogleAuthProvider } from 'firebase/auth'
import { getMessaging, isSupported } from 'firebase/messaging'
const firebaseConfig = { const firebaseConfig = {
apiKey: import.meta.env.VITE_FIREBASE_API_KEY, apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
@@ -15,3 +16,6 @@ const app = initializeApp(firebaseConfig)
// Google Auth initialization // Google Auth initialization
export const auth = getAuth(app) export const auth = getAuth(app)
export const googleProvider = new GoogleAuthProvider() export const googleProvider = new GoogleAuthProvider()
// FCM Messaging — resolves to null in unsupported browsers (e.g. Firefox, older Safari)
export const messagingPromise = isSupported().then((yes) => (yes ? getMessaging(app) : null))

View File

@@ -2,6 +2,22 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import './index.css' import './index.css'
import App from './App.tsx' import App from './App.tsx'
import { listenForegroundMessages } from './hooks/useReminder'
// Apply saved theme immediately to avoid flash
const savedTheme = localStorage.getItem('gj-theme') || 'light'
document.documentElement.setAttribute('data-theme', savedTheme)
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js')
})
}
// Show FCM notifications when app is open in foreground
listenForegroundMessages().catch((err) => {
console.error('[FCM] Failed to set up foreground message listener:', err)
})
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>

65
src/pages/AboutPage.tsx Normal file
View File

@@ -0,0 +1,65 @@
import { Link } from 'react-router-dom'
import { usePageMeta } from '../hooks/usePageMeta'
export default function AboutPage() {
usePageMeta({
title: 'About Grateful Journal | Private, Encrypted Gratitude Journaling',
description: 'Learn about Grateful Journal — a free, end-to-end encrypted daily gratitude journal. No ads, no tracking, no social feed. Just you and your thoughts.',
canonical: 'https://gratefuljournal.online/about',
ogTitle: 'About Grateful Journal | Private, Encrypted Gratitude Journaling',
ogDescription: 'A free, private gratitude journal with end-to-end encryption. Learn how we built a distraction-free space for your daily reflection practice.',
})
return (
<div className="static-page">
<header className="static-page__header">
<Link to="/" className="static-page__logo">🌱 Grateful Journal</Link>
</header>
<main className="static-page__content">
<h1>About Grateful Journal</h1>
<p className="static-page__tagline">
A private space for gratitude and reflection. No feeds. No noise. Just you and your thoughts.
</p>
<h2>What is it?</h2>
<p>
Grateful Journal is a free, end-to-end encrypted daily journal focused on gratitude.
You write a few things you're grateful for each day, and over time you build a private
record of the good in your life — visible only to you.
</p>
<h2>Features</h2>
<ul>
<li><strong>End-to-end encrypted entries</strong> — your journal content is encrypted before leaving your device. We cannot read it.</li>
<li><strong>No ads, no tracking</strong> — we don't sell your data or show you ads.</li>
<li><strong>Works offline</strong> installable as a PWA on Android, iOS, and desktop.</li>
<li><strong>Daily prompts</strong> gentle nudges to keep your practice consistent.</li>
<li><strong>History view</strong> browse past entries and reflect on how far you've come.</li>
<li><strong>Free to use</strong> — no subscription, no paywall.</li>
</ul>
<h2>Why gratitude?</h2>
<p>
Research consistently shows that a regular gratitude practice improves mood, reduces stress,
and builds resilience. Grateful Journal gives you the simplest possible tool to build that habit —
without distractions or social pressure.
</p>
<h2>Privacy first</h2>
<p>
We built Grateful Journal because we believe your inner thoughts deserve a private space.
Your journal entries are end-to-end encrypted — only you can read them. App preferences
such as your display name, profile photo, and background images are stored as plain account
settings and are not encrypted. Read our full <Link to="/privacy">Privacy Policy</Link> for
a complete breakdown of what is and isn't encrypted.
</p>
</main>
<footer className="static-page__footer">
<Link to="/"> Back to Grateful Journal</Link>
<span>·</span>
<Link to="/privacy">Privacy Policy</Link>
</footer>
</div>
)
}

View File

@@ -1,10 +1,11 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
import { getUserEntries, type JournalEntry } from '../lib/api' import { getUserEntries, deleteEntry, updateEntry, type JournalEntry } from '../lib/api'
import { decryptEntry } from '../lib/crypto' import { decryptEntry, encryptEntry } from '../lib/crypto'
import { formatIST, getISTDateComponents } from '../lib/timezone' import { formatIST, getISTDateComponents } from '../lib/timezone'
import BottomNav from '../components/BottomNav' import BottomNav from '../components/BottomNav'
import { useOnboardingTour, hasPendingTourStep, clearPendingTourStep } from '../hooks/useOnboardingTour' import { useOnboardingTour, hasPendingTourStep, clearPendingTourStep } from '../hooks/useOnboardingTour'
import { PageLoader } from '../components/PageLoader'
interface DecryptedEntry extends JournalEntry { interface DecryptedEntry extends JournalEntry {
decryptedTitle?: string decryptedTitle?: string
@@ -19,6 +20,12 @@ export default function HistoryPage() {
const [entries, setEntries] = useState<DecryptedEntry[]>([]) const [entries, setEntries] = useState<DecryptedEntry[]>([])
const [loadingEntries, setLoadingEntries] = useState(false) const [loadingEntries, setLoadingEntries] = useState(false)
const [selectedEntry, setSelectedEntry] = useState<DecryptedEntry | null>(null) const [selectedEntry, setSelectedEntry] = useState<DecryptedEntry | null>(null)
const [entryToDelete, setEntryToDelete] = useState<DecryptedEntry | null>(null)
const [deleting, setDeleting] = useState(false)
const [entryToEdit, setEntryToEdit] = useState<DecryptedEntry | null>(null)
const [editTitle, setEditTitle] = useState('')
const [editContent, setEditContent] = useState('')
const [saving, setSaving] = useState(false)
const { continueTourOnHistory } = useOnboardingTour() const { continueTourOnHistory } = useOnboardingTour()
@@ -175,15 +182,78 @@ export default function HistoryPage() {
setSelectedDate(new Date(currentMonth.getFullYear(), currentMonth.getMonth(), day)) setSelectedDate(new Date(currentMonth.getFullYear(), currentMonth.getMonth(), day))
} }
if (loading) { const isEntryFromToday = (createdAt: string): boolean => {
const nowIST = new Date(new Date().getTime() + 5.5 * 60 * 60 * 1000)
const components = getISTDateComponents(createdAt)
return ( return (
<div className="history-page" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}> components.year === nowIST.getUTCFullYear() &&
<p style={{ color: '#9ca3af' }}>Loading</p> components.month === nowIST.getUTCMonth() &&
<BottomNav /> components.date === nowIST.getUTCDate()
</div>
) )
} }
const openEditModal = (entry: DecryptedEntry) => {
setEntryToEdit(entry)
setEditTitle(entry.decryptedTitle || '')
setEditContent(entry.decryptedContent || '')
}
const handleEditSave = async () => {
if (!entryToEdit || !user || !userId || !secretKey) return
setSaving(true)
try {
const token = await user.getIdToken()
const combined = `${editTitle.trim()}\n\n${editContent.trim()}`
const { ciphertext, nonce } = await encryptEntry(combined, secretKey)
await updateEntry(userId, entryToEdit.id, {
title: undefined,
content: undefined,
encryption: {
encrypted: true,
ciphertext,
nonce,
algorithm: 'XSalsa20-Poly1305',
},
}, token)
const updatedEntry: DecryptedEntry = {
...entryToEdit,
encryption: { encrypted: true, ciphertext, nonce, algorithm: 'XSalsa20-Poly1305' },
decryptedTitle: editTitle.trim(),
decryptedContent: editContent.trim(),
}
setEntries((prev) => prev.map((e) => e.id === entryToEdit.id ? updatedEntry : e))
if (selectedEntry?.id === entryToEdit.id) setSelectedEntry(updatedEntry)
setEntryToEdit(null)
} catch (error) {
console.error('Failed to update entry:', error)
} finally {
setSaving(false)
}
}
const handleDeleteConfirm = async () => {
if (!entryToDelete || !user || !userId) return
setDeleting(true)
try {
const token = await user.getIdToken()
await deleteEntry(userId, entryToDelete.id, token)
setEntries((prev) => prev.filter((e) => e.id !== entryToDelete.id))
if (selectedEntry?.id === entryToDelete.id) setSelectedEntry(null)
} catch (error) {
console.error('Failed to delete entry:', error)
} finally {
setDeleting(false)
setEntryToDelete(null)
}
}
if (loading) {
return <PageLoader />
}
return ( return (
<div className="history-page"> <div className="history-page">
<header className="history-header"> <header className="history-header">
@@ -256,32 +326,60 @@ export default function HistoryPage() {
</h3> </h3>
{loadingEntries ? ( {loadingEntries ? (
<p style={{ color: '#9ca3af', fontSize: '0.875rem', textAlign: 'center', padding: '1.5rem 0', fontFamily: '"Sniglet", system-ui' }}> <PageLoader transparent />
Loading entries
</p>
) : ( ) : (
<div className="entries-list"> <div className="entries-list">
{selectedDateEntries.length === 0 ? ( {selectedDateEntries.length === 0 ? (
<p style={{ color: '#9ca3af', fontSize: '0.875rem', textAlign: 'center', padding: '1.5rem 0', fontFamily: '"Sniglet", system-ui' }}> <p style={{ color: 'var(--color-text-muted)', fontSize: '0.875rem', textAlign: 'center', padding: '1.5rem 0', fontFamily: '"Sniglet", system-ui' }}>
No entries for this day yet. Start writing! No entries for this day yet. Start writing!
</p> </p>
) : ( ) : (
selectedDateEntries.map((entry) => ( selectedDateEntries.map((entry) => (
<button <div
key={entry.id} key={entry.id}
type="button"
className="entry-card" className="entry-card"
role="button"
tabIndex={0}
onClick={() => setSelectedEntry(entry)} onClick={() => setSelectedEntry(entry)}
onKeyDown={(e) => e.key === 'Enter' && setSelectedEntry(entry)}
> >
<div className="entry-header"> <div className="entry-header">
<span className="entry-date">{formatDate(entry.createdAt)}</span> <span className="entry-date">{formatDate(entry.createdAt)}</span>
<div className="entry-header-right">
<span className="entry-time">{formatTime(entry.createdAt)}</span> <span className="entry-time">{formatTime(entry.createdAt)}</span>
{isEntryFromToday(entry.createdAt) && (
<button
type="button"
className="entry-edit-btn"
title="Edit entry"
onClick={(e) => { e.stopPropagation(); openEditModal(entry) }}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
)}
<button
type="button"
className="entry-delete-btn"
title="Delete entry"
onClick={(e) => { e.stopPropagation(); setEntryToDelete(entry) }}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
<path d="M10 11v6M14 11v6" />
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
</svg>
</button>
</div>
</div> </div>
<h4 className="entry-title">{entry.decryptedTitle || entry.title || '[Untitled]'}</h4> <h4 className="entry-title">{entry.decryptedTitle || entry.title || '[Untitled]'}</h4>
{entry.decryptedContent && ( {entry.decryptedContent && (
<p className="entry-preview">{entry.decryptedContent}</p> <p className="entry-preview">{entry.decryptedContent}</p>
)} )}
</button> </div>
)) ))
)} )}
</div> </div>
@@ -303,6 +401,20 @@ export default function HistoryPage() {
<span className="entry-modal-date">{formatDate(selectedEntry.createdAt)}</span> <span className="entry-modal-date">{formatDate(selectedEntry.createdAt)}</span>
<span className="entry-modal-time">{formatTime(selectedEntry.createdAt)}</span> <span className="entry-modal-time">{formatTime(selectedEntry.createdAt)}</span>
</div> </div>
<div className="entry-modal-actions">
{isEntryFromToday(selectedEntry.createdAt) && (
<button
type="button"
className="entry-modal-edit"
onClick={() => { setSelectedEntry(null); openEditModal(selectedEntry) }}
title="Edit entry"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
)}
<button <button
type="button" type="button"
className="entry-modal-close" className="entry-modal-close"
@@ -315,6 +427,7 @@ export default function HistoryPage() {
</svg> </svg>
</button> </button>
</div> </div>
</div>
<h2 className="entry-modal-title"> <h2 className="entry-modal-title">
{selectedEntry.decryptedTitle || selectedEntry.title || '[Untitled]'} {selectedEntry.decryptedTitle || selectedEntry.title || '[Untitled]'}
@@ -352,6 +465,114 @@ export default function HistoryPage() {
</div> </div>
)} )}
{/* Edit Entry Modal */}
{entryToEdit && (
<div
className="entry-modal-overlay"
onClick={(e) => {
if (e.target === e.currentTarget && !saving) setEntryToEdit(null)
}}
>
<div className="entry-modal edit-entry-modal">
<div className="entry-modal-header">
<span className="entry-modal-date">Edit Entry</span>
<button
type="button"
className="entry-modal-close"
onClick={() => setEntryToEdit(null)}
disabled={saving}
title="Close"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
<div className="edit-entry-fields">
<input
className="edit-entry-title-input"
type="text"
placeholder="Title"
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
disabled={saving}
maxLength={200}
/>
<textarea
className="edit-entry-content-input"
placeholder="What are you grateful for today?"
value={editContent}
onChange={(e) => setEditContent(e.target.value)}
disabled={saving}
rows={8}
/>
</div>
<div className="edit-entry-actions">
<button
type="button"
className="delete-confirm-cancel"
onClick={() => setEntryToEdit(null)}
disabled={saving}
>
Cancel
</button>
<button
type="button"
className="edit-entry-save"
onClick={handleEditSave}
disabled={saving || (!editTitle.trim() && !editContent.trim())}
>
{saving ? 'Saving…' : 'Save'}
</button>
</div>
</div>
</div>
)}
{/* Delete Confirmation Modal */}
{entryToDelete && (
<div
className="entry-modal-overlay"
onClick={(e) => {
if (e.target === e.currentTarget && !deleting) setEntryToDelete(null)
}}
>
<div className="entry-modal delete-confirm-modal">
<div className="delete-confirm-icon">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
<path d="M10 11v6M14 11v6" />
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
</svg>
</div>
<h2 className="delete-confirm-title">Delete entry?</h2>
<p className="delete-confirm-body">
"{entryToDelete.decryptedTitle || entryToDelete.title || 'Untitled'}" will be permanently deleted and cannot be recovered.
</p>
<div className="delete-confirm-actions">
<button
type="button"
className="delete-confirm-cancel"
onClick={() => setEntryToDelete(null)}
disabled={deleting}
>
Cancel
</button>
<button
type="button"
className="delete-confirm-delete"
onClick={handleDeleteConfirm}
disabled={deleting}
>
{deleting ? 'Deleting…' : 'Delete'}
</button>
</div>
</div>
</div>
)}
<BottomNav /> <BottomNav />
</div> </div>
) )

View File

@@ -1,56 +1,92 @@
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
import { Link } from 'react-router-dom' import { Link, useNavigate } from 'react-router-dom'
import { useState, useRef, useEffect } from 'react' import { useState, useRef, useEffect } from 'react'
import { createEntry } from '../lib/api' import { createEntry, updateUserProfile } from '../lib/api'
import { encryptEntry } from '../lib/crypto' import { encryptEntry } from '../lib/crypto'
import BottomNav from '../components/BottomNav' import BottomNav from '../components/BottomNav'
import WelcomeModal from '../components/WelcomeModal' import WelcomeModal from '../components/WelcomeModal'
import { useOnboardingTour, hasSeenOnboarding, markOnboardingDone } from '../hooks/useOnboardingTour' import { SaveBookAnimation } from '../components/SaveBookAnimation'
import { useOnboardingTour } from '../hooks/useOnboardingTour'
import { PageLoader } from '../components/PageLoader'
const AFFIRMATIONS = [
'You showed up for yourself today 🌱',
'Another moment beautifully captured ✨',
'Gratitude logged. Keep growing 🌿',
'Small moments, big growth 🍃',
"You're building something beautiful 💚",
'One more grateful moment preserved 🌸',
'Your thoughts are safe and stored 🔒',
]
const SAVE_LEAVES = [
{ left: -80, dx: -30, rot: -25, delay: 0.0, emoji: '🌱' },
{ left: -45, dx: -12, rot: 15, delay: 0.08, emoji: '🌿' },
{ left: -15, dx: -22, rot: -10, delay: 0.04, emoji: '🌱' },
{ left: 8, dx: 18, rot: 20, delay: 0.12, emoji: '🍃' },
{ left: 38, dx: 27, rot: -18, delay: 0.06, emoji: '🌿' },
{ left: 68, dx: 38, rot: 12, delay: 0.18, emoji: '🌱' },
{ left: -62, dx: -33, rot: 28, delay: 0.22, emoji: '🍃' },
{ left: 25, dx: 15, rot: -22, delay: 0.28, emoji: '🌿' },
]
export default function HomePage() { export default function HomePage() {
const { user, userId, secretKey, loading } = useAuth() const { user, userId, mongoUser, secretKey, loading } = useAuth()
const navigate = useNavigate()
const [entry, setEntry] = useState('') const [entry, setEntry] = useState('')
const [title, setTitle] = useState('') const [title, setTitle] = useState('')
const [saving, setSaving] = useState(false) const [phase, setPhase] = useState<'idle' | 'saving' | 'book' | 'celebrate'>('idle')
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null) const [affirmation, setAffirmation] = useState('')
const [message, setMessage] = useState<{ type: 'error'; text: string } | null>(null)
const [showWelcome, setShowWelcome] = useState(false) const [showWelcome, setShowWelcome] = useState(false)
const titleInputRef = useRef<HTMLInputElement>(null) const titleInputRef = useRef<HTMLInputElement>(null)
const contentTextareaRef = useRef<HTMLTextAreaElement>(null) const contentTextareaRef = useRef<HTMLTextAreaElement>(null)
const { startTour } = useOnboardingTour() const { startTour, isTourActive } = useOnboardingTour()
// Check if onboarding should be shown after login // Check if onboarding should be shown after login
useEffect(() => { useEffect(() => {
if (!loading && user && userId && !hasSeenOnboarding()) { if (!loading && user && userId && mongoUser && !mongoUser.tutorial) {
setShowWelcome(true) setShowWelcome(true)
} }
}, [loading, user, userId]) }, [loading, user, userId, mongoUser])
// On-demand tour triggered from Settings (no DB read/write)
useEffect(() => {
if (!loading && user && localStorage.getItem('gj-force-tour') === 'true') {
localStorage.removeItem('gj-force-tour')
setTimeout(() => startTour(), 150)
}
}, [loading, user, startTour])
async function markTutorialDone() {
if (!user || !userId) return
const token = await user.getIdToken()
updateUserProfile(userId, { tutorial: true }, token).catch(console.error)
}
const handleStartTour = () => { const handleStartTour = () => {
setShowWelcome(false) setShowWelcome(false)
markTutorialDone()
startTour() startTour()
} }
const handleSkipTour = () => { const handleSkipTour = () => {
setShowWelcome(false) setShowWelcome(false)
markOnboardingDone() markTutorialDone()
} }
if (loading) { if (loading) {
return ( return <PageLoader />
<div className="home-page" style={{ alignItems: 'center', justifyContent: 'center' }}>
<p style={{ color: '#9ca3af' }}>Loading</p>
</div>
)
} }
if (!user) { if (!user) {
return ( return (
<div className="home-page" style={{ alignItems: 'center', justifyContent: 'center', gap: '1rem' }}> <div className="home-page" style={{ alignItems: 'center', justifyContent: 'center', gap: '1rem' }}>
<h1 style={{ fontFamily: '"Sniglet", system-ui', color: '#1a1a1a' }}>Grateful Journal</h1> <h1 style={{ fontFamily: '"Sniglet", system-ui', color: 'var(--color-text)' }}>Grateful Journal</h1>
<p style={{ color: '#6b7280' }}>Sign in to start your journal.</p> <p style={{ color: 'var(--color-text-muted)' }}>Sign in to start your journal.</p>
<Link to="/login" className="home-login-link">Go to login</Link> <Link to="/" className="home-login-link">Go to login</Link>
</div> </div>
) )
} }
@@ -61,6 +97,11 @@ export default function HomePage() {
.toLocaleDateString('en-US', { weekday: 'long', month: 'short', day: 'numeric' }) .toLocaleDateString('en-US', { weekday: 'long', month: 'short', day: 'numeric' })
.toUpperCase() .toUpperCase()
const handleBookDone = () => {
setPhase('celebrate')
setTimeout(() => navigate('/history'), 2500)
}
const handleTitleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { const handleTitleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && title.trim()) { if (e.key === 'Enter' && title.trim()) {
e.preventDefault() e.preventDefault()
@@ -79,7 +120,7 @@ export default function HomePage() {
return return
} }
setSaving(true) setPhase('saving')
setMessage(null) setMessage(null)
try { try {
@@ -112,17 +153,14 @@ export default function HomePage() {
token token
) )
setMessage({ type: 'success', text: 'Entry saved securely!' })
setTitle('') setTitle('')
setEntry('') setEntry('')
setAffirmation(AFFIRMATIONS[Math.floor(Math.random() * AFFIRMATIONS.length)])
// Clear success message after 3 seconds setPhase('book')
setTimeout(() => setMessage(null), 3000)
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to save entry' const errorMessage = error instanceof Error ? error.message : 'Failed to save entry'
setMessage({ type: 'error', text: errorMessage }) setMessage({ type: 'error', text: errorMessage })
} finally { setPhase('idle')
setSaving(false)
} }
} }
@@ -148,7 +186,7 @@ export default function HomePage() {
onKeyDown={handleTitleKeyDown} onKeyDown={handleTitleKeyDown}
enterKeyHint="next" enterKeyHint="next"
ref={titleInputRef} ref={titleInputRef}
disabled={saving} disabled={phase !== 'idle' || isTourActive}
/> />
<textarea <textarea
id="tour-content-textarea" id="tour-content-textarea"
@@ -158,37 +196,57 @@ export default function HomePage() {
onChange={(e) => setEntry(e.target.value)} onChange={(e) => setEntry(e.target.value)}
enterKeyHint="enter" enterKeyHint="enter"
ref={contentTextareaRef} ref={contentTextareaRef}
disabled={saving} disabled={phase !== 'idle' || isTourActive}
/> />
</div> </div>
{message && ( {message && (
<div style={{ <div className="alert-msg alert-msg--error" style={{ marginTop: '1rem' }}>
padding: '0.75rem',
marginTop: '1rem',
borderRadius: '8px',
fontSize: '0.875rem',
backgroundColor: message.type === 'success' ? '#f0fdf4' : '#fef2f2',
color: message.type === 'success' ? '#15803d' : '#b91c1c',
textAlign: 'center',
}}>
{message.text} {message.text}
</div> </div>
)} )}
<div style={{ marginTop: '1.5rem', display: 'flex', gap: '0.75rem' }}> <div style={{ marginTop: '1.5rem', position: 'relative' }}>
{phase === 'celebrate' && (
<>
<div className="save-leaves" aria-hidden>
{SAVE_LEAVES.map((leaf, i) => (
<span
key={i}
className="save-leaf"
style={{
left: `calc(50% + ${leaf.left}px)`,
animationDelay: `${leaf.delay}s`,
'--leaf-dx': `${leaf.dx}px`,
'--leaf-rot': `${leaf.rot}deg`,
} as React.CSSProperties}
>
{leaf.emoji}
</span>
))}
</div>
<div className="save-inline-quote" role="status" aria-live="polite">
{affirmation}
</div>
</>
)}
{phase !== 'celebrate' && (
<button <button
id="tour-save-btn" id="tour-save-btn"
className="journal-write-btn" className="journal-write-btn"
onClick={handleWrite} onClick={handleWrite}
disabled={saving || !title.trim() || !entry.trim()} disabled={phase !== 'idle' || isTourActive || !title.trim() || !entry.trim()}
> >
{saving ? 'Saving...' : 'Save Entry'} {phase === 'saving' ? 'Saving...' : 'Save Entry'}
</button> </button>
)}
</div> </div>
</div> </div>
</main> </main>
{phase === 'book' && <SaveBookAnimation onDone={handleBookDone} />}
<BottomNav /> <BottomNav />
</div> </div>
) )

View File

@@ -2,19 +2,24 @@ import { useAuth } from '../contexts/AuthContext'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { GoogleSignInButton } from '../components/GoogleSignInButton' import { GoogleSignInButton } from '../components/GoogleSignInButton'
import { LoginCard } from '../components/LoginCard' import { TreeAnimation } from '../components/TreeAnimation'
import { PageLoader } from '../components/PageLoader'
import { usePageMeta } from '../hooks/usePageMeta'
export default function LoginPage() { export default function LoginPage() {
const { user, loading, signInWithGoogle } = useAuth() usePageMeta({
title: 'Private Gratitude Journal App | Grateful Journal',
description: 'A private, end-to-end encrypted gratitude journal. No feeds, no noise — just you and your thoughts. Grow your gratitude one moment at a time.',
canonical: 'https://gratefuljournal.online/',
})
const { user, loading, signInWithGoogle, authError } = useAuth()
const navigate = useNavigate() const navigate = useNavigate()
const [signingIn, setSigningIn] = useState(false) const [signingIn, setSigningIn] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
useEffect(() => { useEffect(() => {
if (loading) return if (loading) return
if (user) { if (user) navigate('/write', { replace: true })
navigate('/', { replace: true })
}
}, [user, loading, navigate]) }, [user, loading, navigate])
async function handleGoogleSignIn() { async function handleGoogleSignIn() {
@@ -29,33 +34,46 @@ export default function LoginPage() {
} }
} }
if (loading) { // Keep showing the loader until the navigate effect fires.
return ( // Without the `user` check here, the login form flashes for one frame
<div className="login-page"> // between loading→false and the useEffect redirect.
<div className="login-page__loading" aria-live="polite"> if (loading || signingIn || user) {
<span className="login-page__spinner" aria-hidden /> return <PageLoader />
<p>Loading</p>
</div>
</div>
)
} }
return ( return (
<div className="login-page"> <div className="lp">
<LoginCard {/* ── Left: animated tree hero ─────────────────── */}
title="Grateful Journal" <div className="lp__hero">
tagline="A minimal, private space for gratitude and reflection. No feeds, no noise—just you and your thoughts." <TreeAnimation />
> <div className="lp__hero-words">
<GoogleSignInButton <p className="lp__quote">Grow your gratitude.</p>
loading={signingIn} <p className="lp__subquote">One small moment at a time.</p>
onClick={handleGoogleSignIn} </div>
/> </div>
{error && (
<p className="login-card__error" role="alert"> {/* ── Right: login panel ───────────────────────── */}
{error} <div className="lp__panel">
<div className="lp__form">
<div className="lp__brand">
<span className="lp__icon" aria-hidden>🌱</span>
<h1 className="lp__title">Grateful Journal</h1>
</div>
<p className="lp__tagline">
A private space for gratitude and reflection.<br />
No feeds. No noise. Just you and your thoughts.
</p> </p>
<div className="lp__actions">
<GoogleSignInButton loading={signingIn} onClick={handleGoogleSignIn} />
<p className="lp__privacy">🔒 End-to-end encrypted. We never read your entries.</p>
{(error || authError) && (
<p className="lp__error" role="alert">{error || authError}</p>
)} )}
</LoginCard> </div>
</div>
</div>
</div> </div>
) )
} }

78
src/pages/PrivacyPage.tsx Normal file
View File

@@ -0,0 +1,78 @@
import { Link } from 'react-router-dom'
import { usePageMeta } from '../hooks/usePageMeta'
export default function PrivacyPage() {
usePageMeta({
title: 'Privacy Policy | Grateful Journal',
description: 'Grateful Journal\'s privacy policy. Your journal entries are end-to-end encrypted — we cannot read them. No ads, no tracking, no data selling.',
canonical: 'https://gratefuljournal.online/privacy',
ogTitle: 'Privacy Policy | Grateful Journal',
ogDescription: 'Your journal entries are end-to-end encrypted and private. App preferences like background images are stored unencrypted. No ads, no tracking, no data selling.',
})
return (
<div className="static-page">
<header className="static-page__header">
<Link to="/" className="static-page__logo">🌱 Grateful Journal</Link>
</header>
<main className="static-page__content">
<h1>Privacy Policy</h1>
<p className="static-page__updated">Last updated: April 14, 2026</p>
<p>
Grateful Journal is built on a simple promise: your journal entries are yours alone.
We designed the app so that we cannot read your entries even if we wanted to.
</p>
<h2>What we collect</h2>
<ul>
<li><strong>Account info</strong> your name and email address via Google Sign-In, used solely to identify your account.</li>
<li><strong>Journal entries</strong> stored encrypted in our database. We do not have access to the content of your entries.</li>
<li><strong>App preferences</strong> your display name, profile photo, background images, and theme are stored unencrypted as account settings. See the Encryption section below for the full breakdown.</li>
<li><strong>Usage data</strong> no analytics, no tracking pixels, no third-party advertising SDKs.</li>
</ul>
<h2>Encryption</h2>
<p>
Encryption is applied selectively based on the sensitivity of each type of data:
</p>
<ul>
<li><strong>Journal entries end-to-end encrypted.</strong> Entries are encrypted on your device using XSalsa20-Poly1305 before being sent to our servers. We store only ciphertext. Decryption happens locally in your browser using a key derived from your account. We cannot read your entries.</li>
<li><strong>App preferences not encrypted.</strong> Your display name, profile photo, background images, and theme setting are stored as plain data. These are appearance and account settings, not personal journal content. They are accessible to us at the database level.</li>
</ul>
<p>
If you upload a personal photo as a background image, be aware that it is stored unencrypted on our servers.
For maximum privacy, use abstract or non-personal images as backgrounds.
</p>
<h2>Data sharing</h2>
<p>
We do not sell, share, or rent your personal data to any third party.
We use Firebase (Google) for authentication only.
</p>
<h2>Data deletion</h2>
<p>
You can delete your account and all associated data at any time from the Settings page.
Deletion is permanent and irreversible.
</p>
<h2>Cookies</h2>
<p>
We use a single session cookie to keep you signed in. No advertising or tracking cookies are used.
</p>
<h2>Contact</h2>
<p>
Questions about this policy? Reach us at the contact details on our <Link to="/about">About page</Link>.
</p>
</main>
<footer className="static-page__footer">
<Link to="/"> Back to Grateful Journal</Link>
<span>·</span>
<Link to="/about">About</Link>
</footer>
</div>
)
}

View File

@@ -1,12 +1,29 @@
import { useState, useEffect, useCallback, useRef } from 'react' import { useState, useEffect, useCallback, useRef } from 'react'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
import { deleteUser as deleteUserApi, updateUserProfile } from '../lib/api' import { deleteUser as deleteUserApi, updateUserProfile } from '../lib/api'
import { BgImageCropper } from '../components/BgImageCropper'
import { clearDeviceKey, clearEncryptedSecretKey } from '../lib/crypto' import { clearDeviceKey, clearEncryptedSecretKey } from '../lib/crypto'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import BottomNav from '../components/BottomNav' import BottomNav from '../components/BottomNav'
import { useOnboardingTour, hasPendingTourStep, clearPendingTourStep } from '../hooks/useOnboardingTour' import { useOnboardingTour, hasPendingTourStep, clearPendingTourStep } from '../hooks/useOnboardingTour'
import { PageLoader } from '../components/PageLoader'
import { usePWAInstall } from '../hooks/usePWAInstall'
import {
getSavedReminderTime, isReminderEnabled,
enableReminder, disableReminder,
} from '../hooks/useReminder'
import ClockTimePicker from '../components/ClockTimePicker'
const MAX_PHOTO_SIZE = 200 // px — resize to 200x200 const MAX_PHOTO_SIZE = 200 // px — resize to 200x200
const MAX_BG_HISTORY = 3
const MAX_BG_IMAGE_BYTES = 1 * 1024 * 1024 // 1 MB per image
const MAX_BG_PAYLOAD_BYTES = MAX_BG_HISTORY * MAX_BG_IMAGE_BYTES // 9 MB total
/** Approximate decoded byte size of a base64 data URL */
function dataUrlBytes(dataUrl: string): number {
const base64 = dataUrl.slice(dataUrl.indexOf(',') + 1)
return Math.round(base64.length * 0.75)
}
function resizeImage(file: File): Promise<string> { function resizeImage(file: File): Promise<string> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -37,8 +54,8 @@ export default function SettingsPage() {
const { user, userId, mongoUser, signOut, loading, refreshMongoUser } = useAuth() const { user, userId, mongoUser, signOut, loading, refreshMongoUser } = useAuth()
// const [passcodeEnabled, setPasscodeEnabled] = useState(false) // Passcode lock — disabled for now // const [passcodeEnabled, setPasscodeEnabled] = useState(false) // Passcode lock — disabled for now
// const [faceIdEnabled, setFaceIdEnabled] = useState(false) // Face ID — disabled for now // const [faceIdEnabled, setFaceIdEnabled] = useState(false) // Face ID — disabled for now
const [theme, setTheme] = useState<'light' | 'dark'>(() => { const [theme, setTheme] = useState<'light' | 'dark' | 'liquid-glass'>(() => {
return (localStorage.getItem('gj-theme') as 'light' | 'dark') || 'light' return (localStorage.getItem('gj-theme') as 'light' | 'dark' | 'liquid-glass') || 'light'
}) })
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null) const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
@@ -50,6 +67,16 @@ export default function SettingsPage() {
const { continueTourOnSettings } = useOnboardingTour() const { continueTourOnSettings } = useOnboardingTour()
const navigate = useNavigate() const navigate = useNavigate()
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
const { canInstall, isIOS, triggerInstall } = usePWAInstall()
const [installModal, setInstallModal] = useState<'ios' | 'chrome' | null>(null)
// Reminder state
const [reminderTime, setReminderTime] = useState<string | null>(() => getSavedReminderTime())
const [reminderEnabled, setReminderEnabled] = useState(() => isReminderEnabled())
const [showReminderModal, setShowReminderModal] = useState(false)
const [reminderPickedTime, setReminderPickedTime] = useState('08:00')
const [reminderError, setReminderError] = useState<string | null>(null)
const [reminderSaving, setReminderSaving] = useState(false)
// Edit profile modal state // Edit profile modal state
const [showEditModal, setShowEditModal] = useState(false) const [showEditModal, setShowEditModal] = useState(false)
@@ -57,6 +84,18 @@ export default function SettingsPage() {
const [editPhotoPreview, setEditPhotoPreview] = useState<string | null>(null) const [editPhotoPreview, setEditPhotoPreview] = useState<string | null>(null)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
// Background image state
const bgFileInputRef = useRef<HTMLInputElement>(null)
const [showBgModal, setShowBgModal] = useState(false)
const [cropperSrc, setCropperSrc] = useState<string | null>(null)
const [bgApplying, setBgApplying] = useState(false)
// Derived from mongoUser (no local state — always fresh after refreshMongoUser)
const bgImages: string[] = (mongoUser as { backgroundImages?: string[] } | null)?.backgroundImages ?? []
const activeImage: string | null = mongoUser?.backgroundImage ?? null
// Tile aspect ratio matches the actual screen so previews reflect real proportions
const screenAspect = `${window.innerWidth} / ${window.innerHeight}`
// Continue onboarding tour if navigated here from the history page tour // Continue onboarding tour if navigated here from the history page tour
useEffect(() => { useEffect(() => {
if (hasPendingTourStep() === 'settings') { if (hasPendingTourStep() === 'settings') {
@@ -67,14 +106,14 @@ export default function SettingsPage() {
}, []) }, [])
const handleSeeTutorial = () => { const handleSeeTutorial = () => {
localStorage.removeItem('gj-onboarding-done') localStorage.setItem('gj-force-tour', 'true')
localStorage.removeItem('gj-tour-pending-step') navigate('/write')
navigate('/')
} }
const displayName = mongoUser?.displayName || user?.displayName || 'User' const displayName = mongoUser?.displayName || user?.displayName || 'User'
// Prefer mongo photo; only fall back to Google photo if mongo has no photo set // Use custom uploaded photo (base64) if set, otherwise always use Firebase's fresh Google URL
const photoURL = (mongoUser && 'photoURL' in mongoUser) ? (mongoUser.photoURL || null) : (user?.photoURL || null) const mongoPhoto = mongoUser && 'photoURL' in mongoUser ? mongoUser.photoURL : null
const photoURL = (mongoPhoto?.startsWith('data:')) ? mongoPhoto : (user?.photoURL || null)
const openEditModal = () => { const openEditModal = () => {
setEditName(displayName) setEditName(displayName)
@@ -120,8 +159,77 @@ export default function SettingsPage() {
} }
} }
async function bgUpdate(updates: Parameters<typeof updateUserProfile>[1]) {
if (!user || !userId) return
setBgApplying(true)
try {
const token = await user.getIdToken()
await updateUserProfile(userId, updates, token)
await refreshMongoUser()
} catch (error) {
const msg = error instanceof Error ? error.message : 'Failed to update background'
setMessage({ type: 'error', text: msg })
} finally {
setBgApplying(false)
}
}
const handleApplyDefault = () => {
if (!activeImage) return // already on default
bgUpdate({ backgroundImage: null })
}
const handleApplyFromGallery = (img: string) => {
if (img === activeImage) return // already active
bgUpdate({ backgroundImage: img })
}
const handleDeleteBgImage = (img: string, e: React.MouseEvent) => {
e.stopPropagation()
const newHistory = bgImages.filter(i => i !== img)
// If the deleted image was active, clear it too
const updates: Parameters<typeof updateUserProfile>[1] = { backgroundImages: newHistory }
if (img === activeImage) updates.backgroundImage = null
bgUpdate(updates)
}
const handleBgFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
setShowBgModal(false)
setCropperSrc(URL.createObjectURL(file))
e.target.value = ''
}
const handleCropDone = async (dataUrl: string) => {
if (cropperSrc) URL.revokeObjectURL(cropperSrc)
setCropperSrc(null)
// Guard: individual image must be within limit (cropper already enforces this,
// but double-check in case of future code paths)
if (dataUrlBytes(dataUrl) > MAX_BG_IMAGE_BYTES) {
setMessage({ type: 'error', text: 'Image is too large. Please try a smaller photo.' })
return
}
// Prepend to history, deduplicate, cap at MAX_BG_HISTORY
let newHistory = [dataUrl, ...bgImages.filter(i => i !== dataUrl)].slice(0, MAX_BG_HISTORY)
// Guard: total payload must stay within limit — drop oldest images until it fits
while (newHistory.reduce((sum, img) => sum + dataUrlBytes(img), 0) > MAX_BG_PAYLOAD_BYTES) {
newHistory = newHistory.slice(0, -1)
}
await bgUpdate({ backgroundImage: dataUrl, backgroundImages: newHistory })
}
const handleCropCancel = () => {
if (cropperSrc) URL.revokeObjectURL(cropperSrc)
setCropperSrc(null)
}
// Apply theme to DOM // Apply theme to DOM
const applyTheme = useCallback((t: 'light' | 'dark') => { const applyTheme = useCallback((t: 'light' | 'dark' | 'liquid-glass') => {
document.documentElement.setAttribute('data-theme', t) document.documentElement.setAttribute('data-theme', t)
localStorage.setItem('gj-theme', t) localStorage.setItem('gj-theme', t)
}, []) }, [])
@@ -131,10 +239,11 @@ export default function SettingsPage() {
applyTheme(theme) applyTheme(theme)
}, [theme, applyTheme]) }, [theme, applyTheme])
const handleThemeChange = (newTheme: 'light' | 'dark') => { const handleThemeChange = (newTheme: 'light' | 'dark' | 'liquid-glass') => {
setTheme(newTheme) setTheme(newTheme)
applyTheme(newTheme) applyTheme(newTheme)
setMessage({ type: 'success', text: `Switched to ${newTheme === 'light' ? 'Light' : 'Dark'} theme` }) const label = newTheme === 'light' ? 'Light' : newTheme === 'dark' ? 'Dark' : 'Liquid Glass'
setMessage({ type: 'success', text: `Switched to ${label} theme` })
setTimeout(() => setMessage(null), 2000) setTimeout(() => setMessage(null), 2000)
} }
@@ -178,6 +287,30 @@ export default function SettingsPage() {
} }
} }
const handleOpenReminderModal = () => {
setReminderPickedTime(reminderTime || '08:00')
setReminderError(null)
setShowReminderModal(true)
}
const handleSaveReminder = async () => {
if (!user || !userId) return
setReminderSaving(true)
setReminderError(null)
const authToken = await user.getIdToken()
const error = await enableReminder(reminderPickedTime, userId, authToken)
setReminderSaving(false)
if (error) {
setReminderError(error)
} else {
setReminderTime(reminderPickedTime)
setReminderEnabled(true)
setShowReminderModal(false)
setMessage({ type: 'success', text: 'Reminder set!' })
setTimeout(() => setMessage(null), 2000)
}
}
const handleSignOut = async () => { const handleSignOut = async () => {
try { try {
await signOut() await signOut()
@@ -187,12 +320,7 @@ export default function SettingsPage() {
} }
if (loading) { if (loading) {
return ( return <PageLoader />
<div className="settings-page" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<p style={{ color: '#9ca3af' }}>Loading</p>
<BottomNav />
</div>
)
} }
return ( return (
@@ -207,11 +335,13 @@ export default function SettingsPage() {
<main className="settings-container"> <main className="settings-container">
{/* Profile Section */} {/* Profile Section */}
<div id="tour-edit-profile" className="settings-profile"> <div id="tour-edit-profile" className="settings-profile">
<div className="settings-avatar"> <div className="settings-avatar" onClick={openEditModal} style={{ cursor: 'pointer' }}>
{photoURL ? ( {photoURL ? (
<img src={photoURL} alt={displayName} className="settings-avatar-img" /> <img src={photoURL} alt={displayName} className="settings-avatar-img"
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
/>
) : ( ) : (
<div className="settings-avatar-placeholder" style={{ fontSize: '1.75rem', background: 'linear-gradient(135deg, #86efac 0%, #22c55e 100%)' }}> <div className="settings-avatar-placeholder" style={{ fontSize: '1.75rem' }}>
{displayName.charAt(0).toUpperCase()} {displayName.charAt(0).toUpperCase()}
</div> </div>
)} )}
@@ -286,6 +416,65 @@ export default function SettingsPage() {
</div> </div>
</section> </section>
{/* App */}
<section className="settings-section">
<h3 className="settings-section-title">APP</h3>
<div className="settings-card">
<button
type="button"
className="settings-item settings-item-button"
onClick={() => {
if (isIOS) {
setInstallModal('ios')
} else if (canInstall) {
triggerInstall()
} else {
setInstallModal('chrome')
}
}}
>
<div className="settings-item-icon settings-item-icon-purple">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="5" y="2" width="14" height="20" rx="2" ry="2" />
<line x1="12" y1="18" x2="12.01" y2="18" />
</svg>
</div>
<div className="settings-item-content">
<h4 className="settings-item-title">Add to Home Screen</h4>
<p className="settings-item-subtitle">Open as an app, no browser bar</p>
</div>
<svg className="settings-item-arrow" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="9 18 15 12 9 6" />
</svg>
</button>
<div className="settings-divider"></div>
{/* Daily Reminder */}
<button
type="button"
className="settings-item settings-item-button"
onClick={handleOpenReminderModal}
>
<div className="settings-item-icon settings-item-icon-orange">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
</svg>
</div>
<div className="settings-item-content">
<h4 className="settings-item-title">Daily Reminder</h4>
<p className="settings-item-subtitle">
{reminderEnabled && reminderTime ? `Set for ${reminderTime}` : 'Set a daily reminder' }
</p>
</div>
<svg className="settings-item-arrow" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="9 18 15 12 9 6" />
</svg>
</button>
</div>
</section>
{/* Data & Look */} {/* Data & Look */}
<section className="settings-section"> <section className="settings-section">
<h3 className="settings-section-title">DATA & LOOK</h3> <h3 className="settings-section-title">DATA & LOOK</h3>
@@ -312,6 +501,27 @@ export default function SettingsPage() {
<div className="settings-divider"></div> <div className="settings-divider"></div>
*/} */}
<button type="button" className="settings-item settings-item-button" onClick={() => setShowBgModal(true)}>
<div className="settings-item-icon settings-item-icon-blue">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<polyline points="21 15 16 10 5 21" />
</svg>
</div>
<div className="settings-item-content">
<h4 className="settings-item-title">Background</h4>
<p className="settings-item-subtitle">
{activeImage ? 'Custom image active' : bgImages.length > 0 ? `${bgImages.length} saved` : 'Default color'}
</p>
</div>
<svg className="settings-item-arrow" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="9 18 15 12 9 6" />
</svg>
</button>
<div className="settings-divider"></div>
<div id="tour-theme-switcher" className="settings-item"> <div id="tour-theme-switcher" className="settings-item">
<div className="settings-item-icon settings-item-icon-blue"> <div className="settings-item-icon settings-item-icon-blue">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
@@ -324,7 +534,9 @@ export default function SettingsPage() {
</div> </div>
<div className="settings-item-content"> <div className="settings-item-content">
<h4 className="settings-item-title">Theme</h4> <h4 className="settings-item-title">Theme</h4>
<p className="settings-item-subtitle">Currently: {theme === 'light' ? 'Light' : 'Dark'}</p> <p className="settings-item-subtitle">
Currently: {theme === 'light' ? 'Light' : theme === 'dark' ? 'Dark' : 'Liquid Glass'}
</p>
</div> </div>
<div className="settings-theme-colors"> <div className="settings-theme-colors">
<button <button
@@ -339,21 +551,19 @@ export default function SettingsPage() {
className={`settings-theme-dot settings-theme-dot-dark${theme === 'dark' ? ' settings-theme-dot-active' : ''}`} className={`settings-theme-dot settings-theme-dot-dark${theme === 'dark' ? ' settings-theme-dot-active' : ''}`}
title="Dark theme" title="Dark theme"
></button> ></button>
<button
type="button"
onClick={() => handleThemeChange('liquid-glass')}
className={`settings-theme-dot settings-theme-dot-glass${theme === 'liquid-glass' ? ' settings-theme-dot-active' : ''}`}
title="Liquid Glass theme"
></button>
</div> </div>
</div> </div>
</div> </div>
</section> </section>
{message && ( {message && (
<div style={{ <div className={`alert-msg alert-msg--${message.type}`} style={{ marginBottom: '1rem' }}>
padding: '0.75rem',
marginBottom: '1rem',
borderRadius: '8px',
fontSize: '0.875rem',
backgroundColor: message.type === 'success' ? '#f0fdf4' : '#fef2f2',
color: message.type === 'success' ? '#15803d' : '#b91c1c',
textAlign: 'center',
}}>
{message.text} {message.text}
</div> </div>
)} )}
@@ -440,9 +650,18 @@ export default function SettingsPage() {
<div className="confirm-modal" onClick={(e) => e.stopPropagation()}> <div className="confirm-modal" onClick={(e) => e.stopPropagation()}>
<h3 className="edit-modal-title">Edit Profile</h3> <h3 className="edit-modal-title">Edit Profile</h3>
<div className="edit-modal-avatar" onClick={() => fileInputRef.current?.click()}> <label className="edit-modal-avatar" style={{ cursor: 'pointer' }}>
<input
ref={fileInputRef}
type="file"
accept="image/*"
style={{ display: 'none' }}
onChange={handlePhotoSelect}
/>
{editPhotoPreview ? ( {editPhotoPreview ? (
<img src={editPhotoPreview} alt="Preview" className="edit-modal-avatar-img" /> <img src={editPhotoPreview} alt="Preview" className="edit-modal-avatar-img"
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }}
/>
) : ( ) : (
<div className="edit-modal-avatar-placeholder"> <div className="edit-modal-avatar-placeholder">
{editName.charAt(0).toUpperCase() || 'U'} {editName.charAt(0).toUpperCase() || 'U'}
@@ -454,14 +673,7 @@ export default function SettingsPage() {
<circle cx="12" cy="13" r="4" /> <circle cx="12" cy="13" r="4" />
</svg> </svg>
</div> </div>
<input </label>
ref={fileInputRef}
type="file"
accept="image/*"
style={{ display: 'none' }}
onChange={handlePhotoSelect}
/>
</div>
{editPhotoPreview && ( {editPhotoPreview && (
<button <button
type="button" type="button"
@@ -481,9 +693,8 @@ export default function SettingsPage() {
disabled={saving} disabled={saving}
maxLength={50} maxLength={50}
autoFocus autoFocus
style={{ borderColor: '#d1d5db' }} onFocus={(e) => (e.target.style.borderColor = 'var(--color-primary)')}
onFocus={(e) => (e.target.style.borderColor = '#22c55e')} onBlur={(e) => (e.target.style.borderColor = '')}
onBlur={(e) => (e.target.style.borderColor = '#d1d5db')}
/> />
<div className="confirm-modal-actions" style={{ marginTop: '1rem' }}> <div className="confirm-modal-actions" style={{ marginTop: '1rem' }}>
@@ -508,6 +719,292 @@ export default function SettingsPage() {
</div> </div>
)} )}
{/* Add to Home Screen instructions modal */}
{installModal && (
<div className="confirm-modal-overlay" onClick={() => setInstallModal(null)}>
<div className="confirm-modal ios-install-modal" onClick={(e) => e.stopPropagation()}>
<div className="ios-install-icon">🌱</div>
<h3 className="confirm-modal-title">Add to Home Screen</h3>
{installModal === 'ios' ? (
<>
<p className="ios-install-subtitle">Follow these steps in Safari:</p>
<ol className="ios-install-steps">
<li>
<span className="ios-install-step-icon">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8" />
<polyline points="16 6 12 2 8 6" />
<line x1="12" y1="2" x2="12" y2="15" />
</svg>
</span>
Tap the <strong>Share</strong> button at the bottom of Safari
</li>
<li>
<span className="ios-install-step-icon">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" />
<line x1="12" y1="8" x2="12" y2="16" />
<line x1="8" y1="12" x2="16" y2="12" />
</svg>
</span>
Scroll down and tap <strong>Add to Home Screen</strong>
</li>
<li>
<span className="ios-install-step-icon">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
</span>
Tap <strong>Add</strong> to confirm
</li>
</ol>
</>
) : (
<>
<p className="ios-install-subtitle">Follow these steps in Chrome:</p>
<ol className="ios-install-steps">
<li>
<span className="ios-install-step-icon">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="1" /><circle cx="19" cy="12" r="1" /><circle cx="5" cy="12" r="1" />
</svg>
</span>
Tap the <strong> menu</strong> in the top-right corner
</li>
<li>
<span className="ios-install-step-icon">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="5" y="2" width="14" height="20" rx="2" ry="2" />
<line x1="12" y1="18" x2="12.01" y2="18" />
</svg>
</span>
Tap <strong>Add to Home screen</strong>
</li>
<li>
<span className="ios-install-step-icon">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
</span>
Tap <strong>Add</strong> to confirm
</li>
</ol>
</>
)}
<button
type="button"
className="edit-modal-save"
style={{ width: '100%', marginTop: '1rem' }}
onClick={() => setInstallModal(null)}
>
Got it
</button>
</div>
</div>
)}
{/* Background Image Gallery Modal */}
{showBgModal && (
<div className="confirm-modal-overlay" onClick={() => !bgApplying && setShowBgModal(false)}>
<div className="bg-modal" onClick={(e) => e.stopPropagation()}>
<h3 className="edit-modal-title" style={{ marginBottom: '0.25rem' }}>Background</h3>
<p className="settings-item-subtitle" style={{ marginBottom: '1rem' }}>
Add new images or select from previously used ones:
</p>
{/* Hidden file input */}
<input
ref={bgFileInputRef}
type="file"
accept="image/*"
style={{ display: 'none' }}
onChange={handleBgFileSelect}
/>
{/* Fixed 4-tile grid: [+] [slot1] [slot2] [slot3] */}
<div className="bg-grid">
{/* Add new — always first tile */}
<button
type="button"
className="bg-grid-tile bg-grid-add"
style={{ aspectRatio: screenAspect }}
onClick={() => bgFileInputRef.current?.click()}
disabled={bgApplying}
title="Upload new image"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
</button>
{/* 3 image slots — filled or empty placeholder */}
{Array.from({ length: MAX_BG_HISTORY }).map((_, i) => {
const img = bgImages[i]
if (img) {
return (
<div key={i} className="bg-grid-wrapper">
<button
type="button"
className={`bg-grid-tile bg-grid-thumb${img === activeImage ? ' bg-grid-tile--active' : ''}`}
style={{ aspectRatio: screenAspect }}
onClick={() => handleApplyFromGallery(img)}
disabled={bgApplying}
title={`Background ${i + 1}`}
>
<img src={img} alt="" className="bg-gallery-thumb-img" />
{img === activeImage && (
<div className="bg-gallery-badge">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
</div>
)}
</button>
<button
type="button"
className="bg-tile-delete"
onClick={(e) => handleDeleteBgImage(img, e)}
disabled={bgApplying}
title="Remove"
>
<svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3.5" strokeLinecap="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
)
}
return (
<div key={i} className="bg-grid-tile bg-grid-empty" style={{ aspectRatio: screenAspect }} />
)
})}
</div>
{/* Revert to default — only shown when a custom bg is active */}
{activeImage && (
<button
type="button"
className="bg-default-btn"
onClick={handleApplyDefault}
disabled={bgApplying}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
</svg>
Revert to default color
</button>
)}
{bgApplying && (
<p className="settings-item-subtitle" style={{ textAlign: 'center', marginTop: '0.5rem' }}>
Saving
</p>
)}
<button
type="button"
className="bg-close-btn"
onClick={() => setShowBgModal(false)}
disabled={bgApplying}
>
Close
</button>
</div>
</div>
)}
{/* Fullscreen image cropper */}
{cropperSrc && (
<BgImageCropper
imageSrc={cropperSrc}
aspectRatio={window.innerWidth / window.innerHeight}
onCrop={handleCropDone}
onCancel={handleCropCancel}
/>
)}
{/* Daily Reminder Modal */}
{showReminderModal && (
<div className="confirm-modal-overlay reminder-modal-overlay" onClick={() => !reminderSaving && setShowReminderModal(false)}>
<div className="confirm-modal reminder-modal" onClick={(e) => e.stopPropagation()}>
<div style={{ fontSize: '1.75rem', textAlign: 'center', marginBottom: '0.25rem' }}>🔔</div>
<h3 className="edit-modal-title" style={{ marginBottom: '0.5rem' }}>
{reminderTime ? 'Edit Reminder' : 'Set Daily Reminder'}
</h3>
<ClockTimePicker
value={reminderPickedTime}
onChange={setReminderPickedTime}
disabled={reminderSaving}
/>
{reminderError && (
<p style={{
color: 'var(--color-error, #ef4444)',
fontSize: '0.8rem',
marginTop: '0.5rem',
textAlign: 'center',
lineHeight: 1.4,
}}>
{reminderError}
</p>
)}
<div className="confirm-modal-actions" style={{ marginTop: '0.75rem' }}>
<button
type="button"
className="confirm-modal-cancel"
onClick={() => setShowReminderModal(false)}
disabled={reminderSaving}
>
Cancel
</button>
<button
type="button"
className="edit-modal-save"
onClick={handleSaveReminder}
disabled={reminderSaving || !reminderPickedTime}
>
{reminderSaving ? 'Saving…' : 'Save'}
</button>
</div>
{reminderEnabled && reminderTime && (
<button
type="button"
onClick={async () => {
if (!user || !userId) return
setReminderSaving(true)
const authToken = await user.getIdToken()
await disableReminder(userId, authToken)
setReminderEnabled(false)
setReminderSaving(false)
setShowReminderModal(false)
setMessage({ type: 'success', text: 'Reminder disabled' })
setTimeout(() => setMessage(null), 2000)
}}
disabled={reminderSaving}
style={{
marginTop: '0.5rem',
width: '100%',
background: 'none',
border: 'none',
color: 'var(--color-error, #ef4444)',
fontSize: '0.85rem',
cursor: 'pointer',
padding: '0.4rem',
}}
>
Disable Reminder
</button>
)}
</div>
</div>
)}
<BottomNav /> <BottomNav />
</div> </div>
) )

View File

@@ -0,0 +1,108 @@
import { Link } from 'react-router-dom'
import { usePageMeta } from '../hooks/usePageMeta'
export default function TermsOfServicePage() {
usePageMeta({
title: 'Terms of Service | Grateful Journal',
description: 'Terms of Service for Grateful Journal — a free, private gratitude journal app. Read about the rules and guidelines for using the service.',
canonical: 'https://gratefuljournal.online/termsofservice',
ogTitle: 'Terms of Service | Grateful Journal',
ogDescription: 'Terms of Service for Grateful Journal — a free, private gratitude journal app. Read about the rules and guidelines for using the service.',
})
return (
<div className="static-page">
<header className="static-page__header">
<Link to="/" className="static-page__logo">🌱 Grateful Journal</Link>
</header>
<main className="static-page__content">
<h1>Terms of Service</h1>
<p className="static-page__updated">Last updated: April 14, 2026</p>
<p>
By using Grateful Journal, you agree to these Terms of Service. Please read them carefully.
</p>
<h2>1. Use of the Service</h2>
<p>
Grateful Journal is a personal journaling app. You may use it for your own personal,
non-commercial journaling purposes. You must be at least 13 years old to use the service.
</p>
<h2>2. Your Account</h2>
<p>
You are responsible for maintaining the security of your account. We use Google Sign-In for
authentication. You agree to provide accurate information and to keep your account credentials
confidential. Notify us immediately if you suspect unauthorized access to your account.
</p>
<h2>3. Your Content</h2>
<p>
You own all journal entries and content you create. We do not claim any ownership over your
content. Your journal entries are end-to-end encrypted and inaccessible to us. App preferences
such as your display name, profile photo, and background images are stored as plain account
settings and are accessible to us at the database level. You are solely responsible for the
content you store in the app, including any images you upload as backgrounds.
</p>
<h2>4. Prohibited Conduct</h2>
<p>You agree not to:</p>
<ul>
<li>Use the service for any unlawful purpose or in violation of any applicable laws.</li>
<li>Attempt to gain unauthorized access to any part of the service or its infrastructure.</li>
<li>Reverse-engineer, decompile, or otherwise attempt to extract the source code of the app.</li>
<li>Use the service to distribute malware or harmful code.</li>
<li>Abuse or overload the service in a way that impairs its operation for other users.</li>
</ul>
<h2>5. Service Availability</h2>
<p>
We strive to keep Grateful Journal available at all times, but we do not guarantee
uninterrupted access. We may perform maintenance, updates, or changes that temporarily
affect availability. We are not liable for any downtime or data loss.
</p>
<h2>6. Account Termination</h2>
<p>
You may delete your account at any time from the Settings page. Deletion permanently removes
your account and all associated data. We reserve the right to suspend or terminate accounts
that violate these terms.
</p>
<h2>7. Disclaimer of Warranties</h2>
<p>
Grateful Journal is provided "as is" without warranties of any kind, express or implied.
We do not warrant that the service will be error-free, secure, or continuously available.
Use of the service is at your own risk.
</p>
<h2>8. Limitation of Liability</h2>
<p>
To the maximum extent permitted by law, Grateful Journal and its creators shall not be
liable for any indirect, incidental, special, or consequential damages arising from your
use of the service, including loss of data.
</p>
<h2>9. Changes to These Terms</h2>
<p>
We may update these Terms of Service from time to time. We will indicate the date of the
last update at the top of this page. Continued use of the service after changes constitutes
acceptance of the updated terms.
</p>
<h2>10. Contact</h2>
<p>
Questions about these terms? Reach us at the contact details on our <Link to="/about">About page</Link>.
</p>
</main>
<footer className="static-page__footer">
<Link to="/"> Back to Grateful Journal</Link>
<span>·</span>
<Link to="/privacy">Privacy Policy</Link>
<span>·</span>
<Link to="/about">About</Link>
</footer>
</div>
)
}

67
start-all.bat Normal file
View File

@@ -0,0 +1,67 @@
@echo off
REM Grateful Journal - Start All Services (Windows)
REM Runs MongoDB, FastAPI backend, and Vite frontend in one command
setlocal enabledelayedexpansion
REM Color codes for output
set "GREEN=[92m"
set "YELLOW=[93m"
set "RED=[91m"
set "RESET=[0m"
echo.
echo Starting Grateful Journal...
echo.
REM Check if MongoDB is running on port 27017
echo Checking MongoDB...
netstat -ano | findstr :27017 >nul
if !errorlevel! equ 0 (
echo MongoDB already running on port 27017
) else (
echo MongoDB is not running. Please start MongoDB first.
echo You can start MongoDB using:
echo - MongoDB Compass GUI
echo - mongod.exe from command line
echo - MongoDB service (if installed as service^)
echo.
pause
exit /b 1
)
echo.
REM Start Backend (FastAPI with Python venv)
echo Starting FastAPI backend...
if not exist "venv\Scripts\activate.bat" (
echo Python venv not found. Creating virtual environment...
python -m venv venv
call venv\Scripts\activate.bat
pip install -r backend\requirements.txt
) else (
call venv\Scripts\activate.bat
)
start "Grateful Journal Backend" cmd /k "cd /d %CD% && venv\Scripts\python backend\main.py"
echo Backend running on http://localhost:8001
timeout /t 2 /nobreak
echo.
REM Start Frontend (Vite)
echo Starting Vite frontend...
start "Grateful Journal Frontend" cmd /k "cd /d %CD% && npm run dev -- --port 8000"
echo Frontend running on http://localhost:8000
timeout /t 2 /nobreak
echo.
echo All services started!
echo.
echo Frontend: http://localhost:8000
echo Backend: http://localhost:8001
echo API Docs: http://localhost:8001/docs
echo.
echo To stop services, close the command windows or press Ctrl+C in each window.
echo.
pause

View File

@@ -5,47 +5,57 @@
set -e set -e
echo "🚀 Starting Grateful Journal..." # Cleanup on Ctrl+C or exit
cleanup() {
echo ""
echo "Stopping all services..."
kill $BACKEND_PID $FRONTEND_PID 2>/dev/null || true
wait $BACKEND_PID $FRONTEND_PID 2>/dev/null || true
echo "All services stopped."
exit 0
}
trap cleanup INT TERM
echo "Starting Grateful Journal..."
echo "" echo ""
# Check if MongoDB is running # Check if MongoDB is running
echo "📦 Checking MongoDB..." echo "Checking MongoDB..."
if lsof -Pi :27017 -sTCP:LISTEN -t >/dev/null 2>&1 ; then if lsof -Pi :27017 -sTCP:LISTEN -t >/dev/null 2>&1 ; then
echo "MongoDB already running on port 27017" echo "MongoDB already running on port 27017"
else else
echo "Starting MongoDB..." echo "Starting MongoDB..."
brew services start mongodb-community brew services start mongodb-community
sleep 2 sleep 2
echo "MongoDB started on port 27017" echo "MongoDB started on port 27017"
fi fi
echo "" echo ""
# Start Backend (FastAPI with conda environment) # Start Backend (FastAPI with conda environment)
echo "🔄 Starting FastAPI backend..." echo "Starting FastAPI backend..."
# Activate conda and start backend
conda run -n yoyo python backend/main.py & conda run -n yoyo python backend/main.py &
BACKEND_PID=$! BACKEND_PID=$!
echo "Backend running on http://localhost:8001 (PID: $BACKEND_PID)" echo "Backend running on http://localhost:8001 (PID: $BACKEND_PID)"
sleep 2 sleep 2
echo "" echo ""
# Start Frontend (Vite) # Start Frontend (Vite)
echo "🔄 Starting Vite frontend..." echo "Starting Vite frontend..."
npm run dev -- --port 8000 & npm run dev -- --port 8000 &
FRONTEND_PID=$! FRONTEND_PID=$!
echo "Frontend running on http://localhost:8000 (PID: $FRONTEND_PID)" echo "Frontend running on http://localhost:8000 (PID: $FRONTEND_PID)"
echo "" echo ""
echo "All services started!" echo "All services started!"
echo "" echo ""
echo "📱 Frontend: http://localhost:8000" echo "Frontend: http://localhost:8000"
echo "🔌 Backend: http://localhost:8001" echo "Backend: http://localhost:8001"
echo "📄 API Docs: http://localhost:8001/docs" echo "API Docs: http://localhost:8001/docs"
echo "" echo ""
echo "To stop all services, press Ctrl+C" echo "Press Ctrl+C to stop all services"
echo "" echo ""
# Wait for both processes # Wait for both processes

108
termsofservice.html Normal file
View File

@@ -0,0 +1,108 @@
<!doctype html>
<html lang="en" style="background-color:#eef6ee">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="manifest" href="/manifest.json" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="Grateful Journal" />
<meta name="theme-color" content="#16a34a" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
/>
<!-- SEO -->
<title>Terms of Service | Grateful Journal</title>
<meta name="description" content="Terms of Service for Grateful Journal — a free, private gratitude journal app. Read about the rules and guidelines for using the service." />
<meta name="keywords" content="grateful journal terms of service, gratitude journal app terms, journal app conditions" />
<meta name="robots" content="index, follow, max-snippet:160, max-image-preview:large" />
<link rel="canonical" href="https://gratefuljournal.online/termsofservice" />
<!-- Open Graph -->
<meta property="og:type" content="website" />
<meta property="og:locale" content="en_US" />
<meta property="og:url" content="https://gratefuljournal.online/termsofservice" />
<meta property="og:title" content="Terms of Service | Grateful Journal" />
<meta property="og:description" content="Terms of Service for Grateful Journal — a free, private gratitude journal app. Read about the rules and guidelines for using the service." />
<meta property="og:image" content="https://gratefuljournal.online/web-app-manifest-512x512.png" />
<meta property="og:image:width" content="512" />
<meta property="og:image:height" content="512" />
<meta property="og:image:alt" content="Grateful Journal logo - a green sprout" />
<meta property="og:site_name" content="Grateful Journal" />
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Terms of Service | Grateful Journal" />
<meta name="twitter:description" content="Terms of Service for Grateful Journal — a free, private gratitude journal app." />
<meta name="twitter:image" content="https://gratefuljournal.online/web-app-manifest-512x512.png" />
<meta name="twitter:image:alt" content="Grateful Journal logo - a green sprout" />
<!-- JSON-LD: WebPage -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebPage",
"name": "Terms of Service",
"url": "https://gratefuljournal.online/termsofservice",
"description": "Terms of Service for Grateful Journal — a free, private gratitude journal app.",
"isPartOf": {
"@type": "WebSite",
"name": "Grateful Journal",
"url": "https://gratefuljournal.online/"
}
}
</script>
</head>
<body>
<div id="root"></div>
<noscript>
<main style="font-family:sans-serif;max-width:680px;margin:4rem auto;padding:1rem 1.5rem;color:#1a1a1a;line-height:1.7">
<nav style="margin-bottom:2rem"><a href="/" style="color:#15803d">&#8592; Grateful Journal</a></nav>
<h1 style="color:#15803d">Terms of Service</h1>
<p><em>Last updated: April 14, 2026</em></p>
<p>By using Grateful Journal, you agree to these Terms of Service. Please read them carefully.</p>
<h2>1. Use of the Service</h2>
<p>Grateful Journal is a personal journaling app. You may use it for your own personal, non-commercial journaling purposes. You must be at least 13 years old to use the service.</p>
<h2>2. Your Account</h2>
<p>You are responsible for maintaining the security of your account. We use Google Sign-In for authentication. Notify us immediately if you suspect unauthorized access to your account.</p>
<h2>3. Your Content</h2>
<p>You own all journal entries and content you create. Your journal entries are end-to-end encrypted and inaccessible to us. You are solely responsible for the content you store in the app.</p>
<h2>4. Prohibited Conduct</h2>
<p>You agree not to use the service for any unlawful purpose, attempt to gain unauthorized access to the service, or abuse the service in a way that impairs its operation for other users.</p>
<h2>5. Service Availability</h2>
<p>We strive to keep Grateful Journal available at all times but do not guarantee uninterrupted access. We are not liable for any downtime or data loss.</p>
<h2>6. Account Termination</h2>
<p>You may delete your account at any time from the Settings page. Deletion permanently removes your account and all associated data.</p>
<h2>7. Disclaimer of Warranties</h2>
<p>Grateful Journal is provided "as is" without warranties of any kind. Use of the service is at your own risk.</p>
<h2>8. Limitation of Liability</h2>
<p>To the maximum extent permitted by law, Grateful Journal and its creators shall not be liable for any indirect, incidental, or consequential damages arising from your use of the service.</p>
<h2>9. Changes to These Terms</h2>
<p>We may update these Terms of Service from time to time. Continued use of the service after changes constitutes acceptance of the updated terms.</p>
<nav style="margin-top:2rem">
<a href="/">&#8592; Back to Grateful Journal</a> ·
<a href="/privacy">Privacy Policy</a> ·
<a href="/about">About</a>
</nav>
</main>
</noscript>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -1,9 +1,55 @@
import { defineConfig } from 'vite' import { defineConfig, loadEnv } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import fs from 'fs'
import path, { resolve } from 'path'
function injectFirebaseConfig(content: string, env: Record<string, string>): string {
return content
.replace('__VITE_FIREBASE_API_KEY__', env.VITE_FIREBASE_API_KEY || '')
.replace('__VITE_FIREBASE_AUTH_DOMAIN__', env.VITE_FIREBASE_AUTH_DOMAIN || '')
.replace('__VITE_FIREBASE_PROJECT_ID__', env.VITE_FIREBASE_PROJECT_ID || '')
.replace('__VITE_FIREBASE_MESSAGING_SENDER_ID__', env.VITE_FIREBASE_MESSAGING_SENDER_ID || '')
.replace('__VITE_FIREBASE_APP_ID__', env.VITE_FIREBASE_APP_ID || '')
}
function swPlugin() {
let env: Record<string, string> = {}
return {
name: 'sw-plugin',
config(_: unknown, { mode }: { mode: string }) {
env = loadEnv(mode, process.cwd(), '')
},
// Dev server: serve sw.js with injected Firebase config
configureServer(server: { middlewares: { use: (path: string, handler: (req: unknown, res: { setHeader: (k: string, v: string) => void; end: (s: string) => void }, next: () => void) => void) => void } }) {
server.middlewares.use('/sw.js', (_req, res) => {
const swPath = path.resolve(__dirname, 'public/sw.js')
if (fs.existsSync(swPath)) {
const content = injectFirebaseConfig(
fs.readFileSync(swPath, 'utf-8').replace('__BUILD_TIME__', 'dev'),
env
)
res.setHeader('Content-Type', 'application/javascript')
res.end(content)
}
})
},
closeBundle() {
// Cache-bust sw.js and inject Firebase config
const swPath = path.resolve(__dirname, 'dist/sw.js')
if (fs.existsSync(swPath)) {
let content = fs.readFileSync(swPath, 'utf-8')
content = content.replace('__BUILD_TIME__', Date.now().toString())
content = injectFirebaseConfig(content, env)
fs.writeFileSync(swPath, content)
}
},
}
}
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react(), swPlugin()],
server: { server: {
port: 8000, port: 8000,
strictPort: false, strictPort: false,
@@ -11,5 +57,36 @@ export default defineConfig({
optimizeDeps: { optimizeDeps: {
include: ['libsodium-wrappers'], include: ['libsodium-wrappers'],
}, },
build: {
chunkSizeWarningLimit: 1000,
rollupOptions: {
input: {
main: resolve(__dirname, 'index.html'),
about: resolve(__dirname, 'about.html'),
privacy: resolve(__dirname, 'privacy.html'),
termsofservice: resolve(__dirname, 'termsofservice.html'),
},
output: {
manualChunks(id) {
if (id.includes('node_modules/firebase')) {
return 'firebase'
}
if (
id.includes('node_modules/react/') ||
id.includes('node_modules/react-dom/') ||
id.includes('node_modules/react-router-dom/')
) {
return 'react-vendor'
}
if (id.includes('node_modules/libsodium')) {
return 'crypto'
}
if (id.includes('node_modules/driver.js')) {
return 'driver'
}
},
},
},
},
}) })

16
vitest.config.ts Normal file
View File

@@ -0,0 +1,16 @@
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
environment: 'happy-dom',
globals: true,
setupFiles: ['./src/__tests__/setup.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'html'],
include: ['src/lib/**', 'src/utils/**'],
},
},
})