Compare commits

..
Author SHA1 Message Date
nmemmertandClaude Sonnet 4.6 8add7ddc10 Update README for v0.4.0 and Gitea migration
Rewrite to accurately reflect current stack (Next.js + Express/JSON server),
Docker deployment, ntfy push notifications, and Gitea CI setup.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-26 14:37:56 -04:00
nmemmert eb6cbcc3d4 Merge main into master, bring in Gitea workflow 2026-08-26 14:35:19 -04:00
nmemmertandClaude Sonnet 4.6 4426b0a380 Migrate workflow to Gitea Actions
Build & Publish Docker Image / build-and-push (push) Successful in 5m42s
Move .github/workflows/ → .gitea/workflows/ and update secrets
to use REGISTRY_USER/REGISTRY_TOKEN with git.necloud.us registry.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-26 14:31:55 -04:00
Claude 26ef999c17 fix: replace em dash in ntfy title headers with ASCII dash
HTTP headers only accept bytes 0-255; the em dash (U+2014) caused
'Cannot convert argument to ByteString' errors. Replace non-ASCII
chars in the title header and use plain hyphens in title strings.
2026-08-02 19:54:51 +00:00
Claude 2f445bdc91 docs: clarify ntfy requires no account, note unverified commits 2026-08-02 17:52:49 +00:00
Claude ce62493057 docs: update README and compose for notifications and timezone
- README: document ntfy push notifications setup, per-prayer reminders,
  self-hosted ntfy, updated feature list and stack table
- docker-compose.yml: add TZ environment variable with instructions
  so daily digest and reminder times fire at the correct local time
2026-08-02 17:48:55 +00:00
Claude 965c47d66a feat: add ntfy push notifications for daily digest and per-prayer reminders
- Server-side cron (node-cron) checks every minute for scheduled notifications
- Daily digest: sends all active prayers as a summary at a configured time
- Per-prayer reminders: pick days of the week per prayer; fires at configured time
- Settings panel in UI to configure ntfy topic, digest time, reminder time
- Test notification button to verify ntfy is working
- Settings stored in data/settings.json (persisted alongside prayers)
2026-08-02 14:45:18 +00:00
nmemmertandClaude Sonnet 4.6 13c00e9322 Remove VOLUME instruction from Dockerfile
VOLUME ["/data"] was causing Docker to create an anonymous volume for
/data that shadowed the bind mount defined in docker-compose.yml,
leaving the container with an empty /data instead of the host data.
The bind mount in docker-compose.yml is sufficient; no Dockerfile
VOLUME needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-21 10:33:20 -04:00
nmemmertandClaude Sonnet 4.6 a35ba756ca Fix prod image: move build deps to devDependencies, restore proper npm install
The production stage was using an ad-hoc npm install without package.json,
which could pull unexpected package versions and lost the lockfile guarantee.
Moved all React/build/test/type packages to devDependencies so the production
stage can use npm install --omit=dev cleanly, installing only express, cors,
and docx — exactly what server.js needs. Regenerated lockfile to match.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-21 10:01:38 -04:00
nmemmertandClaude Sonnet 4.6 c2459c000e Fix build stage: use npm install instead of npm ci
npm ci fails when the lockfile was generated on a different platform
or npm version than what runs inside the container. npm install is
more forgiving and resolves the exit code 1 in the build stage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-21 09:07:34 -04:00
nmemmertandClaude Sonnet 4.6 daa7eb1a0d Fix Docker build: upgrade to node:22-alpine, install only server deps
The production stage was running npm ci --omit=dev against a macOS-
generated lockfile on linux/alpine, which caused exit code 1. Switch
to installing only the three packages the server actually needs
(express, cors, docx) so the lockfile is irrelevant in that stage.
Also upgrades to node:22-alpine (current LTS) to clear the Node 20
deprecation warning on GitHub Actions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-21 09:04:39 -04:00
nmemmertandClaude Sonnet 4.6 96c4b541f9 Add color-coded calendar dots, prayer notes, and export prayer log
- Each prayer gets a consistent color derived from its ID; calendar
  dots now use those colors so you can see which prayers were covered
  at a glance, and the day-detail panel color-codes each entry too
- New Notes field on every prayer card for scripture, updates, and
  mid-prayer thoughts — separate from the answer field, freely editable
- Word export now includes Notes and Prayer Log sections for each prayer

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-21 08:39:16 -04:00
nmemmertandClaude Sonnet 4.6 57937d986d Add prayer log and calendar view
Each prayer card now has a "Prayer Log" section where you can record
when you prayed, with an optional note per session. A Calendar tab
shows a monthly view with dots on days you prayed; clicking a day
reveals which prayers were covered and any notes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-21 08:32:06 -04:00
Claude ea0f84806d fix: use bind mount for persistent data on ZimaOS
Replace named volume with direct bind mount to
/media/ZimaOS-HD/AppData/Prayer/Data so data survives
container recreation without relying on Docker volume management.
2026-07-18 23:44:02 +00:00
Claude 2b3d6a540c Add DOCX export endpoint and Export button
- GET /api/export generates a Word document with all non-archived prayers
- Accepts ?status=active|answered|all and ?tag= query params to match
  the current filter/tag selection in the UI
- Document sections: Active Prayers + Answered Prayers with page break,
  each prayer showing person, request, answer, dates, and tags
- Styled with Palatino Linotype, page numbers in footer
- Export button in the toolbar triggers download of dated .docx file

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DReRqHxSYuVfCzTzQGD9hZ
2026-07-18 23:36:25 +00:00
Claude af836d4ec9 Add person field, full-text search, and search highlighting
- New 'Who are you praying for?' field on prayer cards (optional)
- Person name shown in primary color above the request text
- Search bar filters across person, request, answer, and tags simultaneously
- Search terms highlighted in yellow/amber in results
- Tag filter and search combine with status filter

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DReRqHxSYuVfCzTzQGD9hZ
2026-07-18 23:32:37 +00:00
Claude 8bda0b814c Add tag filtering and fix browser title
- Tag filter bar appears when any prayers have tags; click to filter,
  click again or hit Clear to reset. Tags on cards are also clickable.
- Status filter and tag filter combine (e.g. active + family tag).
- Browser tab title changed from 'React App' to 'Prayer Journal'.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DReRqHxSYuVfCzTzQGD9hZ
2026-07-18 23:28:17 +00:00
Claude bdf27b8b99 Generate prayer ID on server to fix crypto.randomUUID browser compat
Older browsers (and non-HTTPS contexts) don't have crypto.randomUUID.
Move ID generation to the server using Node's built-in crypto module.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DReRqHxSYuVfCzTzQGD9hZ
2026-07-18 17:18:12 +00:00
Claude a27b35191d Fix permissions handling and surface write errors to the UI
- Server attempts chmod on data dir at startup if not writable
- All write routes now catch errors and return 500 with a readable message
- Added /api/health endpoint to diagnose mount issues
- Frontend propagates server errors to a visible banner

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DReRqHxSYuVfCzTzQGD9hZ
2026-07-18 17:06:00 +00:00
Claude e6710b3419 Fix Express 5 wildcard route crash
Express 5 requires named wildcard params — '*' is invalid, use '*splat'.
This was causing the container to crash-loop on startup.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DReRqHxSYuVfCzTzQGD9hZ
2026-07-18 16:43:02 +00:00
Claude 821132967f Add GitHub Actions CI/CD, update docker-compose and README
- GitHub Actions builds and pushes to ghcr.io/nmemmert/prayer on every
  push to main; PRs build only (no push). Uses GHA layer cache for speed.
- docker-compose references the published image, adds healthcheck,
  restart policy, and explicit env vars.
- README rewritten: Docker usage, data persistence, CI/CD, dev setup.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DReRqHxSYuVfCzTzQGD9hZ
2026-07-18 16:21:28 +00:00
Claude 898c12b57d Add express and cors dependencies
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DReRqHxSYuVfCzTzQGD9hZ
2026-07-18 16:20:33 +00:00
Claude 1b8e307d4c Persist prayer data to a mounted Docker volume via Express API
Replaces localStorage with a JSON-file backend (server.js). Prayers are
stored in /data/prayers.json, which is mounted as a named Docker volume so
data survives container restarts and rebuilds. React fetches from /api/prayers.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DReRqHxSYuVfCzTzQGD9hZ
2026-07-18 16:20:17 +00:00
Claude 1d37105e4b Add Dockerfile and docker-compose for production deployment
Multi-stage build: Node 20 builds the React app, nginx serves the static files on port 3000.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DReRqHxSYuVfCzTzQGD9hZ
2026-07-18 16:18:34 +00:00
Claude 9e82ec2f1a Add prayer journal app with request + answer tracking
Todo-list style app where users can add prayers, record how God answered
them, filter between active/answered, and tag entries for organization.
Persists to localStorage. Clean warm serif aesthetic with dark mode support.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DReRqHxSYuVfCzTzQGD9hZ
2026-07-18 16:18:00 +00:00
24 changed files with 2628 additions and 635 deletions

No files matched your search

+51
View File
@@ -0,0 +1,51 @@
name: Build & Publish Docker Image
on:
push:
branches:
- main
pull_request:
branches:
- main
env:
REGISTRY: git.necloud.us
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Log in to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=sha,prefix=sha-
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: ${{ github.event_name == 'push' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
+54 -292
View File
@@ -1,317 +1,79 @@
# Haver — Prayer App # Prayer Journal
> A beautiful, minimalist PWA for journaling prayers, tracking prayer requests, and celebrating answered prayers. A minimalist web app for journaling daily prayers, tracking answered prayers, and receiving push reminders. Self-hostable via Docker.
Built with Next.js, Firebase, and Tailwind CSS. **Current version: 0.4.0**
**Current Version: 0.4.0** ## Features
[![PWA](https://img.shields.io/badge/PWA-Enabled-blue)](https://github.com/nmemmert/prayer-app) - Daily prayer composition with optional "Pray For" person
[![Firebase](https://img.shields.io/badge/Firebase-Ready-orange)](https://firebase.google.com) - Mark prayers as answered with notes
[![License](https://img.shields.io/badge/License-MIT-green)](LICENSE) - Calendar view of past entries
- Reflections list with search and filters
- Answered prayers gallery
- Push notifications via [ntfy](https://ntfy.sh) (no account required) — daily digest and per-prayer reminders
- Export prayer log as PDF or DOCX
- PWA installable, dark theme by default
--- ## Stack
## 📸 Screenshots - **Frontend**: Next.js + React, Tailwind CSS, Firebase (auth + Firestore)
- **Backend**: Express server (`server.js`) — local JSON storage, cron-based ntfy notifications, DOCX/PDF export
- **Container**: Docker, published to `git.necloud.us`
<!-- Add screenshots here after taking them --> ## Docker (recommended)
> *Dashboard showing Today's Prayers and quick stats*
> *Compose tab for creating new prayer entries*
> *Calendar view of prayer activity*
> *Things to Pray For - active requests*
---
## ✨ What's New (0.4.0)
- **Dashboard Navigation**: Tap stat cards to jump to relevant sections
- **Today's Prayers**: Shows unique prayers + total taps with details
- **Fresh Compose**: No draft persistence, clean slate every time
- **Quick Access**: Things to Pray For card now tappable
---
## 🎯 Features
### Core Prayer Tracking
- ✍️ **Daily Prayer Composer** — Write prayers with date and optional "Pray For" categories
- 📊 **Today's Prayers Dashboard** — See unique prayers and total taps for the day
- 🙏 **Things to Pray For** — Track active prayer requests with tap-to-mark functionality
- ✅ **Answered Prayers** — Mark prayers as answered with celebration details
- 📅 **Calendar View** — Visual timeline of your prayer journey
- 📖 **Reflections List** — Search and filter all past prayers
### Smart Features
- 🎯 **Quick Navigation** — Tap stat cards to jump to relevant sections
- 💾 **Cross-Device Sync** — All data stored in Firebase, accessible anywhere
- 📱 **Offline Support** — IndexedDB persistence for reliable offline use
- 🔔 **Reminder Notifications** — Set custom reminder times
- 🌙 **Dark Theme** — Beautiful gradient design optimized for focus
- 📲 **PWA Installable** — Install as standalone app on any device
### Analytics & Insights
- 📈 **Prayer Statistics** — Track total prayers, days prayed, answered percentage
- 🔥 **Prayer Streak** — See consecutive days of prayer activity
- 📊 **Most Prayed For** — Identify your top prayer focus areas
- 📏 **Average Prayer Length** — Monitor your prayer depth
---
## 🚀 Getting Started
### Prerequisites
- Node.js 20+
- Firebase account
- npm or yarn
### Installation
1. **Clone the repository**
```bash ```bash
git clone https://github.com/nmemmert/prayer-app.git docker compose up -d
cd prayer-app
``` ```
2. **Install dependencies** The app listens on port `3050` by default. Adjust environment variables in `docker-compose.yml` as needed.
### Environment variables
| Variable | Description |
|---|---|
| `FIREBASE_PROJECT_ID` | Firebase project ID |
| `FIREBASE_CLIENT_EMAIL` | Firebase Admin SDK service account email |
| `FIREBASE_PRIVATE_KEY` | Firebase Admin SDK private key |
| `NODE_ENV` | Set to `production` for Docker deploys |
> Store secrets in a `.env` file and reference them in `docker-compose.yml` — do not hardcode private keys in compose files.
## Push notifications (ntfy)
ntfy requires no account. Set your topic in the app's Settings, then notifications arrive via the [ntfy app](https://ntfy.sh) or any ntfy-compatible client.
- **Daily digest** — morning summary of unprayed prayers
- **Per-prayer reminders** — cron-scheduled nudges for individual prayers
- **Test notification** — trigger one from the Settings page to verify
## Development
```bash ```bash
npm install npm install
```
3. **Configure Firebase**
- Create a Firebase project at [console.firebase.google.com](https://console.firebase.google.com)
- Enable Authentication (Email/Password)
- Enable Firestore Database
- Copy your Firebase config to `src/lib/firebase.ts`
4. **Deploy Firestore Rules**
```bash
firebase deploy --only firestore:rules
```
5. **Start development server**
```bash
npm run dev npm run dev
``` ```
6. **Open** [http://localhost:3000](http://localhost:3000) App runs at `http://localhost:3000`.
---
## 📖 How to Use
### 1. **Dashboard (Home)**
The dashboard is your prayer command center:
- **Today's Prayers** card shows:
- Unique prayers you've marked today
- Total taps/prayer sessions today
- List of top 3 prayed items
- **Tap any stat card** to navigate:
- Total Prayers → Reflections
- Days Prayed → Calendar
- Answered → Answered Prayers
- **Things to Pray For** shows active requests (tap to view all)
### 2. **Compose a Prayer**
Create new prayer entries:
1. Tap the **"Start New Prayer"** button or compose tab
2. Select a date (defaults to today)
3. Write your prayer in the text area
4. (Optional) Add a "Pray For" category (person/topic)
5. Tap **"Save Prayer"**
*Pro tip: Use "Pray For" categories to organize requests by person or topic*
### 3. **Things to Pray For**
Manage active prayer requests:
- View all non-answered prayers
- Tap **"Mark as Prayed"** when you pray for an item
- Multiple taps per day are tracked for Today's Prayers stats
- Tap any prayer to edit or mark as answered
### 4. **Calendar View**
Visual timeline of your prayer journey:
- See which days you created prayers (blue indicators)
- See which days you prayed for requests (dot intensity)
- Tap any date to view/edit that day's prayers
### 5. **Answered Prayers**
Celebrate God's faithfulness:
- View all prayers marked as answered
- Add answer details to remember how prayers were answered
- Review past testimonies to boost your faith
### 6. **Reflections**
Search and review your prayer history:
- Browse all prayers chronologically
- Search by text or "Pray For" category
- Filter by answered/unanswered
- Edit or update any prayer
### 7. **Settings (About)**
Customize your experience:
- Set reminder notification time
- Test notifications
- View app version and changelog
- Manage account settings
---
## 🏗️ Development
### Available Scripts
```bash ```bash
npm run dev # Start development server npm run build # production build
npm run build # Production build npm run lint # lint
npm run start # Start production server npm test # run tests
npm run lint # Lint code
npm run version:minor # Bump minor version
npm run version:major # Bump major version
``` ```
### Project Structure ## CI / Deployment
```
prayer-app/
├── src/
│ ├── app/ # Next.js app directory
│ │ ├── page.tsx # Main app logic
│ │ ├── layout.tsx # Root layout
│ │ └── globals.css # Global styles
│ ├── components/ # React components
│ │ ├── HomePage.tsx # Dashboard
│ │ ├── DailyEntry.tsx # Compose form
│ │ ├── CalendarView.tsx # Calendar
│ │ ├── ThingsToPrayFor.tsx
│ │ ├── AnsweredPrayers.tsx
│ │ ├── ReflectionsList.tsx
│ │ ├── AboutPage.tsx
│ │ └── ...
│ ├── context/
│ │ └── AuthContext.tsx # Firebase auth
│ └── lib/
│ ├── firebase.ts # Firebase config
│ └── version.ts # App version
├── public/ # Static assets
├── firestore.rules # Firestore security rules
└── Dockerfile # Docker container
```
--- Pushes to `master` trigger a Gitea Actions workflow (`.gitea/workflows/docker.yml`) that builds and pushes the Docker image to `git.necloud.us`.
## 🐳 Docker Deployment Secrets required in Gitea repo settings:
### Build Image | Secret | Value |
```bash |---|---|
docker build -t prayer-app:latest . | `REGISTRY_USER` | Gitea username |
``` | `REGISTRY_TOKEN` | Gitea access token with package write scope |
### Run Container ## Versioning
```bash
docker run -p 3000:3000 prayer-app:latest
```
### Deploy to GitHub Container Registry Semantic versioning — `npm run version:minor` or `npm run version:major` bumps the version and syncs it across the project.
```bash
docker tag prayer-app:latest ghcr.io/YOUR_USERNAME/prayer-app:latest
docker push ghcr.io/YOUR_USERNAME/prayer-app:latest
```
---
## 🔒 Firebase Security
The app uses Firestore security rules to protect user data. Key rules:
- Users can only read/write their own prayers
- Authentication required for all operations
- Subcollections for statistics, settings, and drafts
Deploy rules:
```bash
firebase deploy --only firestore:rules
```
---
## 🎨 Customization
### Theme Colors
Edit `tailwind.config.ts` to customize the color palette.
### Components
All components use Tailwind CSS utility classes for easy styling modifications.
---
## 📱 PWA Features
- **Offline Support**: Works without internet after first load
- **Install Prompt**: Add to home screen on mobile/desktop
- **Push Notifications**: Daily prayer reminders (requires permission)
- **Fast Loading**: Optimized for performance
---
## 🗺️ Roadmap
### Phase 1: Calendar Integration (Q1 2026)
- 📅 **Google Calendar Sync** — Automatically add prayer reminders to Google Calendar
- 📅 **Outlook Calendar Sync** — Sync with Microsoft Outlook
- 🔔 **Smart Reminders** — Calendar events trigger app notifications
- ⏰ **Recurring Prayers** — Set prayers to repeat daily/weekly on calendar
### Phase 2: Prayer Sharing & Community (Q2 2026)
- 🤝 **Share Prayer Requests** — Generate shareable links for prayer requests
- 👥 **Prayer Partners** — Invite friends to pray for specific requests
- 📧 **Email Sharing** — Send prayer requests via email to contacts
- 🔐 **Private Groups** — Create closed prayer groups with invite-only access
- 💬 **Prayer Updates** — Notify prayer partners when requests are answered
---
## 🐛 Troubleshooting
### Common Issues
**Firebase Connection Errors**
- Verify Firebase config in `src/lib/firebase.ts`
- Check Firestore security rules are deployed
- Ensure Authentication is enabled in Firebase Console
**Offline Warnings**
- "Could not reach backend" warnings are normal with offline persistence enabled
- Data will sync when connection is restored
**Notifications Not Working**
- Grant notification permission in browser settings
- Test using the "Test Notification" button in About tab
**PWA Not Installing**
- Clear browser cache
- Ensure you're using HTTPS (or localhost)
- Check browser supports PWA
---
## 📄 License
MIT License - feel free to use this project for personal or commercial purposes.
---
## 🙏 Acknowledgments
- Built with [Next.js](https://nextjs.org)
- Powered by [Firebase](https://firebase.google.com)
- Styled with [Tailwind CSS](https://tailwindcss.com)
- Icons by [Lucide React](https://lucide.dev)
---
## 📞 Support
Having issues? Check the [Troubleshooting](#-troubleshooting) section or open an issue on GitHub.
---
**Made with ❤️ and ✝️ for daily prayer practice**
```
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

+43
View File
@@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-react-app"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>Prayer Journal</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

+3
View File
@@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:
+421
View File
@@ -0,0 +1,421 @@
const express = require('express');
const cors = require('cors');
const fs = require('fs');
const path = require('path');
const { randomUUID } = require('crypto');
const cron = require('node-cron');
const {
Document, Packer, Paragraph, TextRun, HeadingLevel,
AlignmentType, BorderStyle, PageBreak, Footer, PageNumber,
} = require('docx');
const app = express();
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data');
const DATA_FILE = path.join(DATA_DIR, 'prayers.json');
const SETTINGS_FILE = path.join(DATA_DIR, 'settings.json');
const PORT = process.env.PORT || 4000;
try {
fs.mkdirSync(DATA_DIR, { recursive: true });
fs.accessSync(DATA_DIR, fs.constants.W_OK);
console.log(`Data directory ready: ${DATA_DIR}`);
} catch (err) {
console.error(`WARNING: Data directory not writable: ${DATA_DIR} — ${err.message}`);
try { fs.chmodSync(DATA_DIR, 0o755); } catch {}
}
app.use(cors());
app.use(express.json());
app.use(express.static(path.join(__dirname, 'build')));
function readPrayers() {
if (!fs.existsSync(DATA_FILE)) return [];
try {
return JSON.parse(fs.readFileSync(DATA_FILE, 'utf8'));
} catch {
return [];
}
}
function writePrayers(prayers) {
fs.mkdirSync(DATA_DIR, { recursive: true });
fs.writeFileSync(DATA_FILE, JSON.stringify(prayers, null, 2));
}
function readSettings() {
if (!fs.existsSync(SETTINGS_FILE)) return {};
try { return JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf8')); } catch { return {}; }
}
function writeSettings(s) {
fs.mkdirSync(DATA_DIR, { recursive: true });
fs.writeFileSync(SETTINGS_FILE, JSON.stringify(s, null, 2));
}
function fmtDate(iso) {
return iso ? new Date(iso).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }) : '';
}
async function sendNtfy(topic, title, message, tags = []) {
if (!topic) return;
const url = topic.startsWith('http') ? topic : `https://ntfy.sh/${topic}`;
// HTTP headers must be ASCII-only; replace non-ASCII characters
const safeTitle = title.replace(/[^\x00-\x7F]/g, '-');
try {
const res = await fetch(url, {
method: 'POST',
headers: {
'Title': safeTitle,
'Content-Type': 'text/plain',
...(tags.length ? { 'Tags': tags.join(',') } : {}),
},
body: message,
});
if (!res.ok) console.error('ntfy error:', res.status, await res.text());
else console.log(`ntfy sent: "${title}"`);
} catch (err) {
console.error('ntfy send failed:', err.message);
}
}
async function sendDailyDigest() {
const settings = readSettings();
if (!settings.ntfyTopic) return;
const prayers = readPrayers().filter(p => p.status === 'active');
if (!prayers.length) return;
const lines = prayers.map((p, i) => {
const who = p.person ? `${p.person}: ` : '';
return `${i + 1}. ${who}${p.request}`;
});
await sendNtfy(
settings.ntfyTopic,
`Prayer Journal -${prayers.length} active prayer${prayers.length !== 1 ? 's' : ''}`,
lines.join('\n'),
['pray', 'raised_hands']
);
}
async function checkPrayerReminders() {
const settings = readSettings();
if (!settings.ntfyTopic) return;
const prayers = readPrayers().filter(p => p.status === 'active' && p.reminderDays && p.reminderDays.length);
if (!prayers.length) return;
const now = new Date();
const dayName = ['sun','mon','tue','wed','thu','fri','sat'][now.getDay()];
const currentHHMM = `${String(now.getHours()).padStart(2,'0')}:${String(now.getMinutes()).padStart(2,'0')}`;
const reminderTime = settings.reminderTime || '08:00';
if (currentHHMM !== reminderTime) return;
for (const prayer of prayers) {
if ((prayer.reminderDays || []).includes(dayName)) {
const title = prayer.person ? `Praying for ${prayer.person}` : 'Prayer reminder';
await sendNtfy(settings.ntfyTopic, title, prayer.request, ['pray', 'raised_hands']);
}
}
}
// Cron: every minute checks whether it's time for digest or per-prayer reminders
cron.schedule('* * * * *', async () => {
const settings = readSettings();
if (!settings.ntfyTopic) return;
const now = new Date();
const hhmm = `${String(now.getHours()).padStart(2,'0')}:${String(now.getMinutes()).padStart(2,'0')}`;
if (settings.dailyDigestTime && hhmm === settings.dailyDigestTime) await sendDailyDigest();
if (settings.reminderTime) await checkPrayerReminders();
});
console.log('Notification cron scheduled.');
function prayerToSection(prayer, index) {
const paras = [];
// Prayer number + person
const titleRuns = [
new TextRun({ text: `${index + 1}. `, bold: true, size: 24, color: '4B6FA8' }),
];
if (prayer.person) {
titleRuns.push(new TextRun({ text: prayer.person, bold: true, size: 24, color: '4B6FA8' }));
}
paras.push(new Paragraph({ children: titleRuns, spacing: { before: 240, after: 60 } }));
// Meta: date + tags
const metaParts = [`Added ${fmtDate(prayer.createdAt)}`];
if (prayer.tags && prayer.tags.length) metaParts.push(`Tags: ${prayer.tags.join(', ')}`);
paras.push(new Paragraph({
children: [new TextRun({ text: metaParts.join(' · '), size: 18, color: '888888', italics: true })],
spacing: { after: 120 },
}));
// Request
paras.push(new Paragraph({
children: [new TextRun({ text: 'Request', bold: true, size: 20, color: '333333' })],
spacing: { after: 60 },
}));
paras.push(new Paragraph({
children: [new TextRun({ text: prayer.request, size: 22 })],
spacing: { after: 160 },
}));
// Answer (if present)
if (prayer.answer) {
paras.push(new Paragraph({
children: [new TextRun({ text: 'Answer', bold: true, size: 20, color: '4A8C6A' })],
spacing: { after: 60 },
}));
paras.push(new Paragraph({
children: [new TextRun({ text: prayer.answer, size: 22, color: '2D5C40' })],
spacing: { after: 80 },
}));
if (prayer.answeredAt) {
paras.push(new Paragraph({
children: [new TextRun({ text: `Answered ${fmtDate(prayer.answeredAt)}`, size: 18, italics: true, color: '888888' })],
spacing: { after: 160 },
}));
}
}
// Notes (if present)
if (prayer.notes && prayer.notes.trim()) {
paras.push(new Paragraph({
children: [new TextRun({ text: 'Notes', bold: true, size: 20, color: '5A5A2A' })],
spacing: { after: 60 },
}));
paras.push(new Paragraph({
children: [new TextRun({ text: prayer.notes, size: 22 })],
spacing: { after: 160 },
}));
}
// Prayer log (if present)
if (prayer.prayedLog && prayer.prayedLog.length > 0) {
paras.push(new Paragraph({
children: [new TextRun({ text: `Prayer Log (${prayer.prayedLog.length} session${prayer.prayedLog.length !== 1 ? 's' : ''})`, bold: true, size: 20, color: '4B6FA8' })],
spacing: { after: 60 },
}));
const sorted = [...prayer.prayedLog].sort((a, b) => new Date(b.date) - new Date(a.date));
sorted.forEach(entry => {
const runs = [new TextRun({ text: fmtDate(entry.date), bold: true, size: 19, color: '555555' })];
if (entry.note) runs.push(new TextRun({ text: ` — ${entry.note}`, size: 19, color: '666666' }));
paras.push(new Paragraph({ children: runs, spacing: { after: 60 } }));
});
paras.push(new Paragraph({ children: [], spacing: { after: 80 } }));
}
// Divider paragraph with bottom border
paras.push(new Paragraph({
children: [],
spacing: { after: 200 },
border: { bottom: { style: BorderStyle.SINGLE, size: 1, color: 'E2DAC8' } },
}));
return paras;
}
app.get('/api/settings', (req, res) => {
res.json(readSettings());
});
app.put('/api/settings', (req, res) => {
try {
const updated = { ...readSettings(), ...req.body };
writeSettings(updated);
res.json(updated);
} catch (err) {
res.status(500).json({ error: 'Could not save settings.' });
}
});
app.post('/api/notifications/test', async (req, res) => {
const settings = readSettings();
if (!settings.ntfyTopic) return res.status(400).json({ error: 'No ntfy topic configured.' });
await sendNtfy(settings.ntfyTopic, 'Prayer Journal -Test', 'Notifications are working! 🙏', ['white_check_mark']);
res.json({ ok: true });
});
app.get('/api/export', (req, res) => {
try {
const all = readPrayers();
const status = req.query.status || 'all';
const tag = req.query.tag || '';
const prayers = all.filter(p => {
const statusMatch = status === 'all' ? p.status !== 'archived' : p.status === status;
const tagMatch = tag ? (p.tags || []).includes(tag) : true;
return statusMatch && tagMatch;
});
const active = prayers.filter(p => p.status === 'active');
const answered = prayers.filter(p => p.status === 'answered');
const titleLabel = status === 'active' ? 'Active Prayers'
: status === 'answered' ? 'Answered Prayers'
: 'Prayer Journal';
const children = [
new Paragraph({
text: 'Prayer Journal',
heading: HeadingLevel.TITLE,
alignment: AlignmentType.CENTER,
spacing: { after: 120 },
}),
new Paragraph({
children: [new TextRun({ text: `Exported ${fmtDate(new Date().toISOString())}`, size: 20, italics: true, color: '888888' })],
alignment: AlignmentType.CENTER,
spacing: { after: tag ? 80 : 480 },
}),
];
if (tag) {
children.push(new Paragraph({
children: [new TextRun({ text: `Tag: ${tag}`, size: 20, italics: true, color: '4B6FA8' })],
alignment: AlignmentType.CENTER,
spacing: { after: 480 },
}));
}
if (status === 'all' || status === 'active') {
if (active.length) {
children.push(new Paragraph({
text: `Active Prayers (${active.length})`,
heading: HeadingLevel.HEADING_1,
spacing: { before: 0, after: 240 },
}));
active.forEach((p, i) => children.push(...prayerToSection(p, i)));
}
}
if (status === 'all' && answered.length && active.length) {
children.push(new Paragraph({ children: [new PageBreak()] }));
}
if (status === 'all' || status === 'answered') {
if (answered.length) {
children.push(new Paragraph({
text: `Answered Prayers (${answered.length})`,
heading: HeadingLevel.HEADING_1,
spacing: { before: 0, after: 240 },
}));
answered.forEach((p, i) => children.push(...prayerToSection(p, i)));
}
}
if (prayers.length === 0) {
children.push(new Paragraph({
children: [new TextRun({ text: 'No prayers found.', italics: true, color: '888888' })],
}));
}
const doc = new Document({
styles: {
default: {
document: {
run: { font: 'Palatino Linotype', size: 22 },
},
},
paragraphStyles: [
{
id: 'Title',
name: 'Title',
basedOn: 'Normal',
run: { size: 56, bold: false, color: '252018', font: 'Palatino Linotype' },
paragraph: { spacing: { before: 0, after: 120 } },
},
{
id: 'Heading1',
name: 'Heading 1',
basedOn: 'Normal',
run: { size: 32, bold: true, color: '4B6FA8', font: 'Palatino Linotype' },
paragraph: {
spacing: { before: 480, after: 240 },
border: { bottom: { style: BorderStyle.SINGLE, size: 2, color: 'C4A882' } },
},
},
],
},
sections: [{
properties: {
page: { size: { width: 12240, height: 15840 } },
margin: { top: 1440, bottom: 1440, left: 1440, right: 1440 },
},
footers: {
default: new Footer({
children: [new Paragraph({
alignment: AlignmentType.CENTER,
children: [
new TextRun({ children: [PageNumber.CURRENT], size: 18, color: '888888' }),
new TextRun({ text: ' / ', size: 18, color: '888888' }),
new TextRun({ children: [PageNumber.TOTAL_PAGES], size: 18, color: '888888' }),
],
})],
}),
},
children,
}],
});
Packer.toBuffer(doc).then(buffer => {
const filename = `prayer-journal-${new Date().toISOString().slice(0, 10)}.docx`;
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document');
res.send(buffer);
});
} catch (err) {
console.error('Export failed:', err.message);
res.status(500).json({ error: 'Export failed: ' + err.message });
}
});
app.get('/api/health', (req, res) => {
let writable = false;
try { fs.accessSync(DATA_DIR, fs.constants.W_OK); writable = true; } catch {}
res.json({ ok: writable, dataDir: DATA_DIR, dataFile: DATA_FILE });
});
app.get('/api/prayers', (req, res) => {
res.json(readPrayers());
});
app.post('/api/prayers', (req, res) => {
try {
const prayers = readPrayers();
const prayer = { id: randomUUID(), ...req.body };
prayers.unshift(prayer);
writePrayers(prayers);
res.status(201).json(prayer);
} catch (err) {
console.error('Write failed:', err.message);
res.status(500).json({ error: 'Could not save prayer. Check data directory permissions.' });
}
});
app.put('/api/prayers/:id', (req, res) => {
try {
const prayers = readPrayers();
const idx = prayers.findIndex(p => p.id === req.params.id);
if (idx === -1) return res.status(404).json({ error: 'Not found' });
prayers[idx] = { ...prayers[idx], ...req.body };
writePrayers(prayers);
res.json(prayers[idx]);
} catch (err) {
console.error('Write failed:', err.message);
res.status(500).json({ error: 'Could not save prayer. Check data directory permissions.' });
}
});
app.delete('/api/prayers/:id', (req, res) => {
try {
const prayers = readPrayers();
const filtered = prayers.filter(p => p.id !== req.params.id);
writePrayers(filtered);
res.status(204).end();
} catch (err) {
console.error('Write failed:', err.message);
res.status(500).json({ error: 'Could not delete prayer. Check data directory permissions.' });
}
});
app.get('*splat', (req, res) => {
res.sendFile(path.join(__dirname, 'build', 'index.html'));
});
app.listen(PORT, () => console.log(`Server running on port ${PORT}, data: ${DATA_FILE}`));
+1047
View File
File diff suppressed because it is too large. Load diff
+9
View File
@@ -0,0 +1,9 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});
+784
View File
@@ -0,0 +1,784 @@
import React, { useState, useMemo } from 'react';
import { usePrayers } from './usePrayers';
import { useSettings } from './useSettings';
import { Prayer, PrayedEntry, AppSettings } from './types';
import './App.css';
type Filter = 'active' | 'answered' | 'all';
type Tab = 'prayers' | 'calendar';
const DOT_PALETTE = [
'#c07a3a', '#3d6b52', '#6b4545', '#456b8a',
'#7a5c8a', '#8a7a3a', '#3a6b6b', '#8a4a6b',
];
function prayerColor(id: string): string {
let hash = 0;
for (let i = 0; i < id.length; i++) hash = (hash * 31 + id.charCodeAt(i)) & 0xffffffff;
return DOT_PALETTE[Math.abs(hash) % DOT_PALETTE.length];
}
function normalize(s: string) {
return s.toLowerCase().trim();
}
function matches(prayer: Prayer, query: string) {
const q = normalize(query);
if (!q) return true;
return (
normalize(prayer.person).includes(q) ||
normalize(prayer.request).includes(q) ||
normalize(prayer.answer).includes(q) ||
prayer.tags.some(t => normalize(t).includes(q))
);
}
const DAYS = ['sun','mon','tue','wed','thu','fri','sat'] as const;
const DAY_LABELS: Record<string, string> = { sun:'Su', mon:'Mo', tue:'Tu', wed:'We', thu:'Th', fri:'Fr', sat:'Sa' };
export default function App() {
const {
prayers,
loading,
error: apiError,
addPrayer,
updateAnswer,
markAnswered,
markActive,
archivePrayer,
deletePrayer,
updateRequest,
updateNotes,
logPrayed,
updateReminderDays,
} = usePrayers();
const { settings, saveSettings, testNotification } = useSettings();
const [showSettings, setShowSettings] = useState(false);
const [tab, setTab] = useState<Tab>('prayers');
const [filter, setFilter] = useState<Filter>('active');
const [activeTag, setActiveTag] = useState<string | null>(null);
const [search, setSearch] = useState('');
const [newPerson, setNewPerson] = useState('');
const [newRequest, setNewRequest] = useState('');
const [newTags, setNewTags] = useState('');
const [expandedId, setExpandedId] = useState<string | null>(null);
const [editingAnswer, setEditingAnswer] = useState<{ id: string; text: string } | null>(null);
const [writeError, setWriteError] = useState<string | null>(null);
const allTags = useMemo(() => {
const set = new Set<string>();
prayers.filter(p => p.status !== 'archived').forEach(p => p.tags.forEach(t => set.add(t)));
return Array.from(set).sort();
}, [prayers]);
const filtered = useMemo(() => prayers.filter(p => {
const statusMatch =
filter === 'active' ? p.status === 'active' :
filter === 'answered' ? p.status === 'answered' :
p.status !== 'archived';
const tagMatch = activeTag ? p.tags.includes(activeTag) : true;
const searchMatch = matches(p, search);
return statusMatch && tagMatch && searchMatch;
}), [prayers, filter, activeTag, search]);
const counts = {
active: prayers.filter(p => p.status === 'active').length,
answered: prayers.filter(p => p.status === 'answered').length,
};
async function handleAdd(e: React.FormEvent) {
e.preventDefault();
if (!newRequest.trim()) return;
const tags = newTags.split(',').map(t => t.trim()).filter(Boolean);
try {
await addPrayer(newPerson.trim(), newRequest.trim(), tags);
setNewPerson('');
setNewRequest('');
setNewTags('');
setWriteError(null);
} catch (err: any) {
setWriteError(err.message);
}
}
async function handleSaveAnswer(id: string) {
if (!editingAnswer) return;
try {
await updateAnswer(id, editingAnswer.text);
await markAnswered(id);
setEditingAnswer(null);
setExpandedId(null);
setWriteError(null);
} catch (err: any) {
setWriteError(err.message);
}
}
function formatDate(iso: string) {
return new Date(iso).toLocaleDateString(undefined, {
month: 'short', day: 'numeric', year: 'numeric',
});
}
function handleTagClick(tag: string) {
setActiveTag(prev => prev === tag ? null : tag);
}
function handleExport() {
const params = new URLSearchParams();
params.set('status', filter);
if (activeTag) params.set('tag', activeTag);
window.location.href = `/api/export?${params.toString()}`;
}
const emptyMessage = search
? `No results for "${search}".`
: activeTag
? `No ${filter === 'all' ? '' : filter + ' '}prayers tagged "${activeTag}".`
: filter === 'active'
? 'No active prayers. Add one above.'
: 'Nothing here yet.';
return (
<div className="app">
<header className="header">
<h1>Prayer Journal</h1>
<p className="subtitle">Bring your requests. Record His faithfulness.</p>
<button className="btn ghost settings-btn" onClick={() => setShowSettings(s => !s)} title="Notification settings">
🔔 {showSettings ? 'Close settings' : 'Notifications'}
</button>
</header>
{showSettings && (
<SettingsPanel
settings={settings}
onSave={saveSettings}
onTest={testNotification}
/>
)}
<div className="tab-bar">
<button
className={`tab-btn ${tab === 'prayers' ? 'active' : ''}`}
onClick={() => setTab('prayers')}
>
Prayers
</button>
<button
className={`tab-btn ${tab === 'calendar' ? 'active' : ''}`}
onClick={() => setTab('calendar')}
>
Calendar
</button>
</div>
{tab === 'prayers' && (
<>
<form className="add-form" onSubmit={handleAdd}>
<input
className="person-input"
placeholder="Who are you praying for? (optional)"
value={newPerson}
onChange={e => setNewPerson(e.target.value)}
/>
<textarea
className="request-input"
placeholder="What would you like to pray about?"
value={newRequest}
onChange={e => setNewRequest(e.target.value)}
rows={3}
/>
<div className="add-row">
<input
className="tag-input"
placeholder="Tags (comma separated)"
value={newTags}
onChange={e => setNewTags(e.target.value)}
/>
<button className="btn btn-primary" type="submit">
Add Prayer
</button>
</div>
</form>
<div className="toolbar">
<div className="filter-bar">
<button
className={`filter-btn ${filter === 'active' ? 'active' : ''}`}
onClick={() => setFilter('active')}
>
Active <span className="count">{counts.active}</span>
</button>
<button
className={`filter-btn ${filter === 'answered' ? 'active' : ''}`}
onClick={() => setFilter('answered')}
>
Answered <span className="count">{counts.answered}</span>
</button>
<button
className={`filter-btn ${filter === 'all' ? 'active' : ''}`}
onClick={() => setFilter('all')}
>
All
</button>
</div>
<button className="btn btn-export" onClick={handleExport} title="Export to Word">
↓ Export
</button>
<div className="search-wrap">
<span className="search-icon">⌕</span>
<input
className="search-input"
placeholder="Search prayers…"
value={search}
onChange={e => setSearch(e.target.value)}
/>
{search && (
<button className="search-clear" onClick={() => setSearch('')}>✕</button>
)}
</div>
</div>
{allTags.length > 0 && (
<div className="tag-filter-bar">
<span className="tag-filter-label">Tags:</span>
{allTags.map(tag => (
<button
key={tag}
className={`tag-filter-btn ${activeTag === tag ? 'active' : ''}`}
onClick={() => handleTagClick(tag)}
>
{tag}
</button>
))}
{activeTag && (
<button className="tag-clear-btn" onClick={() => setActiveTag(null)}>✕ Clear</button>
)}
</div>
)}
{(writeError || apiError) && (
<div className="error-banner">⚠ {writeError || apiError}</div>
)}
{loading && <p className="loading">Loading prayers…</p>}
<ul className="prayer-list">
{filtered.length === 0 && !loading && (
<li className="empty">{emptyMessage}</li>
)}
{filtered.map(prayer => (
<PrayerCard
key={prayer.id}
prayer={prayer}
expanded={expandedId === prayer.id}
editingAnswer={editingAnswer?.id === prayer.id ? editingAnswer.text : null}
activeTag={activeTag}
searchQuery={search}
ntfyConfigured={!!settings.ntfyTopic}
onTagClick={handleTagClick}
onToggle={() => setExpandedId(expandedId === prayer.id ? null : prayer.id)}
onStartAnswer={() => {
setExpandedId(prayer.id);
setEditingAnswer({ id: prayer.id, text: prayer.answer });
}}
onEditAnswer={text => setEditingAnswer({ id: prayer.id, text })}
onSaveAnswer={() => handleSaveAnswer(prayer.id)}
onCancelAnswer={() => setEditingAnswer(null)}
onMarkActive={() => markActive(prayer.id)}
onArchive={() => archivePrayer(prayer.id)}
onDelete={() => deletePrayer(prayer.id)}
onEditRequest={text => updateRequest(prayer.id, text)}
onLogPrayed={entry => logPrayed(prayer.id, entry)}
onSaveNotes={notes => updateNotes(prayer.id, notes)}
onUpdateReminderDays={days => updateReminderDays(prayer.id, days)}
formatDate={formatDate}
/>
))}
</ul>
</>
)}
{tab === 'calendar' && (
<CalendarView prayers={prayers} formatDate={formatDate} />
)}
</div>
);
}
function highlight(text: string, query: string) {
if (!query.trim()) return <>{text}</>;
const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
const parts = text.split(regex);
return (
<>
{parts.map((part, i) =>
regex.test(part) ? <mark key={i} className="highlight">{part}</mark> : part
)}
</>
);
}
function SettingsPanel({
settings,
onSave,
onTest,
}: {
settings: AppSettings;
onSave: (s: Partial<AppSettings>) => Promise<AppSettings>;
onTest: () => Promise<void>;
}) {
const [topic, setTopic] = useState(settings.ntfyTopic || '');
const [digest, setDigest] = useState(settings.dailyDigestTime || '');
const [remTime, setRemTime] = useState(settings.reminderTime || '08:00');
const [msg, setMsg] = useState('');
const [testing, setTesting] = useState(false);
async function handleSave(e: React.FormEvent) {
e.preventDefault();
try {
await onSave({ ntfyTopic: topic.trim() || undefined, dailyDigestTime: digest || undefined, reminderTime: remTime || '08:00' });
setMsg('Settings saved.');
} catch { setMsg('Failed to save.'); }
setTimeout(() => setMsg(''), 3000);
}
async function handleTest() {
setTesting(true);
try { await onTest(); setMsg('Test notification sent!'); }
catch (err: any) { setMsg(err.message); }
setTesting(false);
setTimeout(() => setMsg(''), 4000);
}
return (
<div className="settings-panel">
<h2 className="settings-title">Notification Settings</h2>
<p className="settings-desc">
Notifications are sent via <a href="https://ntfy.sh" target="_blank" rel="noreferrer">ntfy.sh</a> — a free, open-source push service.
Install the ntfy app on your phone and subscribe to your topic to receive notifications.
</p>
<form onSubmit={handleSave} className="settings-form">
<label className="settings-label">
ntfy topic
<input
className="settings-input"
placeholder="e.g. my-prayer-journal-abc123"
value={topic}
onChange={e => setTopic(e.target.value)}
/>
<span className="settings-hint">Pick a unique topic name. Anyone who knows it can subscribe, so make it hard to guess.</span>
</label>
<label className="settings-label">
Daily digest time
<input type="time" className="settings-input" value={digest} onChange={e => setDigest(e.target.value)} />
<span className="settings-hint">Receive a summary of all active prayers at this time each day. Leave blank to disable.</span>
</label>
<label className="settings-label">
Per-prayer reminder time
<input type="time" className="settings-input" value={remTime} onChange={e => setRemTime(e.target.value)} />
<span className="settings-hint">Time of day reminders fire for individual prayers that have reminder days set.</span>
</label>
<div className="settings-actions">
<button className="btn btn-primary btn-sm" type="submit">Save</button>
{topic && (
<button type="button" className="btn btn-sm" onClick={handleTest} disabled={testing}>
{testing ? 'Sending…' : 'Send test'}
</button>
)}
{msg && <span className="settings-msg">{msg}</span>}
</div>
</form>
</div>
);
}
interface CardProps {
prayer: Prayer;
expanded: boolean;
editingAnswer: string | null;
activeTag: string | null;
searchQuery: string;
ntfyConfigured: boolean;
onTagClick: (tag: string) => void;
onToggle: () => void;
onStartAnswer: () => void;
onEditAnswer: (text: string) => void;
onSaveAnswer: () => void;
onCancelAnswer: () => void;
onMarkActive: () => void;
onArchive: () => void;
onDelete: () => void;
onEditRequest: (text: string) => void;
onLogPrayed: (entry: PrayedEntry) => void;
onSaveNotes: (notes: string) => void;
onUpdateReminderDays: (days: string[]) => void;
formatDate: (iso: string) => string;
}
function PrayerCard({
prayer,
expanded,
editingAnswer,
activeTag,
searchQuery,
ntfyConfigured,
onTagClick,
onToggle,
onStartAnswer,
onEditAnswer,
onSaveAnswer,
onCancelAnswer,
onMarkActive,
onArchive,
onDelete,
onLogPrayed,
onSaveNotes,
onUpdateReminderDays,
formatDate,
}: CardProps) {
const [logDate, setLogDate] = useState(new Date().toISOString().slice(0, 10));
const [logNote, setLogNote] = useState('');
const [showLogForm, setShowLogForm] = useState(false);
const [editingNotes, setEditingNotes] = useState<string | null>(null);
function handleLogSubmit(e: React.FormEvent) {
e.preventDefault();
onLogPrayed({ date: logDate, note: logNote.trim() });
setLogNote('');
setShowLogForm(false);
}
function handleNotesSave() {
if (editingNotes === null) return;
onSaveNotes(editingNotes);
setEditingNotes(null);
}
function toggleDay(day: string) {
const current = prayer.reminderDays || [];
const next = current.includes(day) ? current.filter(d => d !== day) : [...current, day];
onUpdateReminderDays(next);
}
const sortedLog = [...(prayer.prayedLog ?? [])].sort(
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()
);
const color = prayerColor(prayer.id);
return (
<li className={`prayer-card ${prayer.status}`} style={{ borderLeftColor: color }}>
<div className="card-top" onClick={onToggle}>
<div className="card-main">
<span className="status-dot" style={{ background: color }} />
<div className="card-text">
{prayer.person && (
<p className="person-name">{highlight(prayer.person, searchQuery)}</p>
)}
<p className="request-text">{highlight(prayer.request, searchQuery)}</p>
</div>
</div>
<div className="card-meta">
<span className="date">{formatDate(prayer.createdAt)}</span>
{prayer.prayedLog && prayer.prayedLog.length > 0 && (
<span className="prayed-count" title={`Prayed ${prayer.prayedLog.length} time${prayer.prayedLog.length !== 1 ? 's' : ''}`}>
🙏 {prayer.prayedLog.length}
</span>
)}
{prayer.tags.map(tag => (
<span
key={tag}
className={`tag ${activeTag === tag ? 'tag-active' : ''}`}
onClick={e => { e.stopPropagation(); onTagClick(tag); }}
>
{tag}
</span>
))}
<span className="chevron">{expanded ? '▲' : '▼'}</span>
</div>
</div>
{expanded && (
<div className="card-body">
{prayer.status === 'answered' && prayer.answer && editingAnswer === null && (
<div className="answer-section">
<label className="answer-label">Answer</label>
<p className="answer-text">{highlight(prayer.answer, searchQuery)}</p>
{prayer.answeredAt && (
<span className="answered-date">Answered {formatDate(prayer.answeredAt)}</span>
)}
<button className="btn btn-sm" onClick={onStartAnswer}>Edit answer</button>
</div>
)}
{(prayer.status === 'active' || editingAnswer !== null) && (
<div className="answer-section">
<label className="answer-label">
{prayer.status === 'answered' ? 'Edit answer' : 'Record answer'}
</label>
<textarea
className="answer-input"
placeholder="How did God answer this prayer?"
value={editingAnswer ?? prayer.answer}
onChange={e => onEditAnswer(e.target.value)}
rows={4}
/>
<div className="card-actions">
<button className="btn btn-primary btn-sm" onClick={onSaveAnswer}>
Mark Answered
</button>
{editingAnswer !== null && prayer.status === 'answered' && (
<button className="btn btn-sm" onClick={onCancelAnswer}>Cancel</button>
)}
</div>
</div>
)}
{/* Notes section */}
<div className="notes-section">
<div className="prayed-log-header">
<label className="answer-label">Notes</label>
{editingNotes === null && (
<button className="btn btn-sm" onClick={() => setEditingNotes(prayer.notes ?? '')}>
{prayer.notes ? 'Edit' : '+ Add notes'}
</button>
)}
</div>
{editingNotes !== null ? (
<>
<textarea
className="log-note-input"
placeholder="Scripture, updates, mid-prayer thoughts…"
value={editingNotes}
onChange={e => setEditingNotes(e.target.value)}
rows={4}
autoFocus
/>
<div className="card-actions">
<button className="btn btn-primary btn-sm" onClick={handleNotesSave}>Save</button>
<button className="btn btn-sm" onClick={() => setEditingNotes(null)}>Cancel</button>
</div>
</>
) : prayer.notes ? (
<p className="notes-text">{prayer.notes}</p>
) : (
<p className="prayed-log-empty">No notes yet.</p>
)}
</div>
{/* Prayer log section */}
<div className="prayed-log-section">
<div className="prayed-log-header">
<label className="answer-label">Prayer Log</label>
{!showLogForm && (
<button className="btn btn-sm btn-primary" onClick={() => setShowLogForm(true)}>
+ I Prayed This
</button>
)}
</div>
{showLogForm && (
<form className="prayed-log-form" onSubmit={handleLogSubmit}>
<input
type="date"
className="log-date-input"
value={logDate}
onChange={e => setLogDate(e.target.value)}
/>
<textarea
className="log-note-input"
placeholder="Notes from this prayer time (optional)"
value={logNote}
onChange={e => setLogNote(e.target.value)}
rows={2}
/>
<div className="card-actions">
<button className="btn btn-primary btn-sm" type="submit">Save</button>
<button className="btn btn-sm" type="button" onClick={() => setShowLogForm(false)}>Cancel</button>
</div>
</form>
)}
{sortedLog.length > 0 ? (
<ul className="prayed-log-list">
{sortedLog.map((entry, i) => (
<li key={i} className="prayed-log-entry">
<span className="prayed-log-date">{formatDate(entry.date)}</span>
{entry.note && <p className="prayed-log-note">{entry.note}</p>}
</li>
))}
</ul>
) : (
!showLogForm && <p className="prayed-log-empty">No prayer sessions logged yet.</p>
)}
</div>
<div className="card-actions secondary">
{prayer.status === 'answered' && (
<button className="btn btn-sm ghost" onClick={onMarkActive}>
Move back to active
</button>
)}
<button className="btn btn-sm ghost" onClick={onArchive}>Archive</button>
<button className="btn btn-sm ghost danger" onClick={onDelete}>Delete</button>
</div>
{ntfyConfigured && prayer.status === 'active' && (
<div className="reminder-row">
<span className="reminder-label">🔔 Remind me</span>
<div className="day-picker">
{DAYS.map(d => (
<button
key={d}
type="button"
className={`day-btn ${(prayer.reminderDays || []).includes(d) ? 'active' : ''}`}
onClick={() => toggleDay(d)}
>
{DAY_LABELS[d]}
</button>
))}
</div>
</div>
)}
</div>
)}
</li>
);
}
function CalendarView({ prayers, formatDate }: { prayers: Prayer[]; formatDate: (iso: string) => string }) {
const today = new Date();
const [year, setYear] = useState(today.getFullYear());
const [month, setMonth] = useState(today.getMonth());
const [selectedDate, setSelectedDate] = useState<string | null>(null);
const prayedByDate = useMemo(() => {
const map = new Map<string, Array<{ prayer: Prayer; entry: PrayedEntry }>>();
prayers.forEach(prayer => {
(prayer.prayedLog ?? []).forEach(entry => {
const key = entry.date.slice(0, 10);
if (!map.has(key)) map.set(key, []);
map.get(key)!.push({ prayer, entry });
});
});
return map;
}, [prayers]);
const daysInMonth = new Date(year, month + 1, 0).getDate();
const firstDow = new Date(year, month, 1).getDay();
function prevMonth() {
if (month === 0) { setYear(y => y - 1); setMonth(11); }
else setMonth(m => m - 1);
setSelectedDate(null);
}
function nextMonth() {
if (month === 11) { setYear(y => y + 1); setMonth(0); }
else setMonth(m => m + 1);
setSelectedDate(null);
}
const monthName = new Date(year, month).toLocaleDateString(undefined, { month: 'long', year: 'numeric' });
const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const selectedEntries = selectedDate ? (prayedByDate.get(selectedDate) ?? []) : [];
const totalThisMonth = useMemo(() => {
let count = 0;
prayedByDate.forEach((entries, date) => {
if (date.startsWith(`${year}-${String(month + 1).padStart(2, '0')}`)) {
count += entries.length;
}
});
return count;
}, [prayedByDate, year, month]);
return (
<div className="calendar-view">
<div className="calendar-header">
<button className="cal-nav-btn" onClick={prevMonth}>‹</button>
<span className="cal-month-name">{monthName}</span>
<button className="cal-nav-btn" onClick={nextMonth}>›</button>
</div>
{totalThisMonth > 0 && (
<p className="cal-summary">{totalThisMonth} prayer session{totalThisMonth !== 1 ? 's' : ''} this month</p>
)}
<div className="cal-grid">
{dayNames.map(d => (
<div key={d} className="cal-dow">{d}</div>
))}
{Array.from({ length: firstDow }).map((_, i) => (
<div key={`blank-${i}`} className="cal-cell cal-blank" />
))}
{Array.from({ length: daysInMonth }).map((_, i) => {
const day = i + 1;
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
const entries = prayedByDate.get(dateStr);
const isToday =
day === today.getDate() && month === today.getMonth() && year === today.getFullYear();
const isSelected = selectedDate === dateStr;
const uniqueColors = entries
? Array.from(new Map(entries.map(e => [e.prayer.id, prayerColor(e.prayer.id)])).values())
: [];
return (
<div
key={day}
className={`cal-cell ${isToday ? 'cal-today' : ''} ${isSelected ? 'cal-selected' : ''} ${entries ? 'cal-has-prayers' : ''}`}
onClick={() => entries && setSelectedDate(isSelected ? null : dateStr)}
>
<span className="cal-day-num">{day}</span>
{uniqueColors.length > 0 && (
<span className="cal-dot-row">
{uniqueColors.slice(0, 5).map((color, di) => (
<span key={di} className="cal-dot" style={{ background: color }} />
))}
{uniqueColors.length > 5 && (
<span className="cal-dot-more">+{uniqueColors.length - 5}</span>
)}
</span>
)}
</div>
);
})}
</div>
{selectedDate && (
<div className="cal-detail">
<h3 className="cal-detail-title">
{formatDate(selectedDate + 'T12:00:00')}
</h3>
{selectedEntries.length === 0 ? (
<p className="prayed-log-empty">No prayer sessions on this day.</p>
) : (
<ul className="cal-detail-list">
{selectedEntries.map(({ prayer, entry }, i) => (
<li key={i} className="cal-detail-entry" style={{ borderLeftColor: prayerColor(prayer.id) }}>
<div className="cal-detail-prayer">
{prayer.person && <span className="person-name">{prayer.person} — </span>}
<span className="request-text">{prayer.request}</span>
</div>
{entry.note && <p className="prayed-log-note">{entry.note}</p>}
</li>
))}
</ul>
)}
</div>
)}
</div>
);
}
-36
View File
@@ -1,36 +0,0 @@
'use client';
import { useAuth } from '@/context/AuthContext';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import Login from '@/components/Login';
import { Loader2 } from 'lucide-react';
export default function LoginPage() {
const { user, loading } = useAuth();
const router = useRouter();
useEffect(() => {
if (!loading && user) {
router.push('/');
}
}, [user, loading, router]);
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900">
<Loader2 className="w-8 h-8 animate-spin text-blue-400" />
</div>
);
}
if (user) {
return null;
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900">
<Login />
</div>
);
}
+1 -2
View File
@@ -12,7 +12,6 @@ import ThingsToPrayFor from '@/components/ThingsToPrayFor';
import AboutPage from '@/components/AboutPage'; import AboutPage from '@/components/AboutPage';
import HomePage from '@/components/HomePage'; import HomePage from '@/components/HomePage';
import AnsweredPrayers from '@/components/AnsweredPrayers'; import AnsweredPrayers from '@/components/AnsweredPrayers';
import PublicHome from './public-home';
import { db } from '@/lib/firebase'; import { db } from '@/lib/firebase';
import { collection, addDoc, getDocs, updateDoc, query, where, doc, setDoc, getDoc } from 'firebase/firestore'; import { collection, addDoc, getDocs, updateDoc, query, where, doc, setDoc, getDoc } from 'firebase/firestore';
import { Loader2 } from 'lucide-react'; import { Loader2 } from 'lucide-react';
@@ -320,7 +319,7 @@ export default function Home() {
} }
if (!user) { if (!user) {
return <PublicHome />; return <Login />;
} }
return ( return (
-161
View File
@@ -1,161 +0,0 @@
import Link from 'next/link';
export default function PrivacyPolicy() {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900">
{/* Header */}
<header className="border-b border-slate-700/50 sticky top-0 z-50 backdrop-blur-xl bg-slate-900/80">
<div className="max-w-6xl mx-auto px-4 py-4 flex justify-between items-center">
<Link href="/" className="flex items-center gap-2 hover:opacity-80 transition-opacity">
<span className="text-3xl">🙏</span>
<h1 className="text-2xl font-bold text-white">Haver</h1>
</Link>
<Link href="/" className="text-slate-300 hover:text-white transition-colors">Back to Home</Link>
</div>
</header>
{/* Content */}
<div className="max-w-4xl mx-auto px-4 py-12">
<h1 className="text-4xl font-bold text-white mb-8">Privacy Policy</h1>
<div className="prose prose-invert max-w-none space-y-6">
<section>
<h2 className="text-2xl font-bold text-white mb-4">1. Introduction</h2>
<p className="text-slate-300">
Haver ("we", "our", or "us") is committed to protecting your privacy. This Privacy Policy explains
how we collect, use, disclose, and safeguard your information when you use our prayer journal application
and website.
</p>
</section>
<section>
<h2 className="text-2xl font-bold text-white mb-4">2. Information We Collect</h2>
<h3 className="text-xl font-semibold text-slate-200 mb-2">2.1 Account Information</h3>
<p className="text-slate-300">
When you create an account, we collect your email address and authentication information to maintain
your account and provide services.
</p>
<h3 className="text-xl font-semibold text-slate-200 mb-2 mt-4">2.2 Prayer Data</h3>
<p className="text-slate-300">
All prayers, prayer requests, and related entries you create in Haver are stored in your account.
This data is essential to the functionality of the application.
</p>
<h3 className="text-xl font-semibold text-slate-200 mb-2 mt-4">2.3 Calendar Integration Data</h3>
<p className="text-slate-300">
If you connect your Google Calendar, we store your Google authentication token to sync recurring
prayer reminders. We do not store your Google password.
</p>
<h3 className="text-xl font-semibold text-slate-200 mb-2 mt-4">2.4 Device Information</h3>
<p className="text-slate-300">
We may collect information about your device, including device type, operating system, and browser
type for analytics and debugging purposes.
</p>
</section>
<section>
<h2 className="text-2xl font-bold text-white mb-4">3. How We Use Your Information</h2>
<ul className="list-disc list-inside text-slate-300 space-y-2">
<li>To provide and maintain the Haver service</li>
<li>To sync your prayers and prayer requests across devices</li>
<li>To sync recurring prayer reminders to your Google Calendar</li>
<li>To send notifications and reminders (if enabled)</li>
<li>To improve and optimize the application</li>
<li>To comply with legal obligations</li>
</ul>
</section>
<section>
<h2 className="text-2xl font-bold text-white mb-4">4. Data Security</h2>
<p className="text-slate-300 mb-4">
Haver uses Firebase, a Google Cloud service, to securely store all your data. We implement industry-standard
security measures including:
</p>
<ul className="list-disc list-inside text-slate-300 space-y-2">
<li>Encryption of data in transit (HTTPS)</li>
<li>Secure authentication (Firebase Authentication)</li>
<li>Regular security updates and patches</li>
<li>Firestore security rules to control data access</li>
</ul>
</section>
<section>
<h2 className="text-2xl font-bold text-white mb-4">5. Third-Party Services</h2>
<p className="text-slate-300 mb-4">
Haver uses the following third-party services:
</p>
<ul className="list-disc list-inside text-slate-300 space-y-2">
<li><strong>Firebase:</strong> For data storage, authentication, and analytics</li>
<li><strong>Google Calendar API:</strong> For syncing recurring prayer reminders (if enabled)</li>
<li><strong>Google Analytics:</strong> For anonymous usage statistics</li>
</ul>
<p className="text-slate-300 mt-4">
Please review the privacy policies of these services for more information about their data practices.
</p>
</section>
<section>
<h2 className="text-2xl font-bold text-white mb-4">6. Your Rights and Choices</h2>
<h3 className="text-xl font-semibold text-slate-200 mb-2">6.1 Access and Portability</h3>
<p className="text-slate-300">
You can access, download, or export your prayer data from your account at any time.
</p>
<h3 className="text-xl font-semibold text-slate-200 mb-2 mt-4">6.2 Deletion</h3>
<p className="text-slate-300">
You can delete individual prayers or your entire account. When you delete your account, all associated
data is permanently removed.
</p>
<h3 className="text-xl font-semibold text-slate-200 mb-2 mt-4">6.3 Notifications</h3>
<p className="text-slate-300">
You can control reminder notifications and calendar sync settings within the application.
</p>
</section>
<section>
<h2 className="text-2xl font-bold text-white mb-4">7. Children's Privacy</h2>
<p className="text-slate-300">
Haver is not intended for children under 13. We do not knowingly collect personal information from
children under 13. If we become aware that we have collected such information, we will delete it promptly.
</p>
</section>
<section>
<h2 className="text-2xl font-bold text-white mb-4">8. Changes to This Privacy Policy</h2>
<p className="text-slate-300">
We may update this Privacy Policy from time to time. We will notify you of any changes by updating
the "Last Updated" date below. Your continued use of Haver after such modifications constitutes your
acceptance of the updated Privacy Policy.
</p>
</section>
<section>
<h2 className="text-2xl font-bold text-white mb-4">9. Contact Us</h2>
<p className="text-slate-300">
If you have any questions about this Privacy Policy or our privacy practices, please contact us at
privacy@haverprayerapp.com or visit our website at https://prayer.necloud.us.
</p>
</section>
<section className="bg-slate-800/50 backdrop-blur rounded-lg p-6 border border-slate-700/50">
<p className="text-slate-400 text-sm">
<strong>Last Updated:</strong> January 19, 2026
</p>
</section>
</div>
</div>
{/* Footer */}
<footer className="border-t border-slate-700/50 bg-slate-900/50 mt-20 py-12">
<div className="max-w-6xl mx-auto px-4 text-center">
<Link href="/" className="text-slate-400 hover:text-white transition-colors">
Back to Haver
</Link>
</div>
</footer>
</div>
);
}
-144
View File
@@ -1,144 +0,0 @@
import Link from 'next/link';
export default function PublicHome() {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900">
{/* Header */}
<header className="border-b border-slate-700/50 sticky top-0 z-50 backdrop-blur-xl bg-slate-900/80">
<div className="max-w-6xl mx-auto px-4 py-4 flex justify-between items-center">
<div className="flex items-center gap-2">
<span className="text-3xl">🙏</span>
<h1 className="text-2xl font-bold text-white">Haver</h1>
</div>
<Link
href="/login"
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-semibold transition-colors"
>
Sign In
</Link>
</div>
</header>
{/* Hero Section */}
<section className="max-w-6xl mx-auto px-4 py-20 text-center">
<h2 className="text-5xl md:text-6xl font-bold text-white mb-6">
A Prayer Journal for Your Daily Practice
</h2>
<p className="text-xl text-slate-300 mb-8 max-w-2xl mx-auto">
Haver is a beautiful, minimalist app designed to help you journal your prayers, track prayer requests,
and celebrate when God answers. Stay connected to your prayer life, anytime, anywhere.
</p>
<Link
href="/login"
className="inline-block px-8 py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-semibold transition-colors text-lg"
>
Get Started
</Link>
</section>
{/* Features Section */}
<section className="max-w-6xl mx-auto px-4 py-20">
<h3 className="text-3xl font-bold text-white mb-12 text-center">Why Use Haver?</h3>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
<div className="bg-slate-800/50 backdrop-blur rounded-xl p-6 border border-slate-700/50">
<div className="text-4xl mb-4">✍️</div>
<h4 className="text-xl font-semibold text-white mb-2">Daily Prayer Composer</h4>
<p className="text-slate-300">Write prayers with dates and prayer request categories to organize your spiritual life.</p>
</div>
<div className="bg-slate-800/50 backdrop-blur rounded-xl p-6 border border-slate-700/50">
<div className="text-4xl mb-4">📊</div>
<h4 className="text-xl font-semibold text-white mb-2">Prayer Dashboard</h4>
<p className="text-slate-300">See your unique prayers and total prayer taps for each day at a glance.</p>
</div>
<div className="bg-slate-800/50 backdrop-blur rounded-xl p-6 border border-slate-700/50">
<div className="text-4xl mb-4">🙏</div>
<h4 className="text-xl font-semibold text-white mb-2">Prayer Requests</h4>
<p className="text-slate-300">Track active prayer requests and mark them as you pray for them throughout the week.</p>
</div>
<div className="bg-slate-800/50 backdrop-blur rounded-xl p-6 border border-slate-700/50">
<div className="text-4xl mb-4">✅</div>
<h4 className="text-xl font-semibold text-white mb-2">Answered Prayers</h4>
<p className="text-slate-300">Celebrate God's faithfulness by recording and reflecting on answered prayers.</p>
</div>
<div className="bg-slate-800/50 backdrop-blur rounded-xl p-6 border border-slate-700/50">
<div className="text-4xl mb-4">📅</div>
<h4 className="text-xl font-semibold text-white mb-2">Prayer Calendar</h4>
<p className="text-slate-300">Visualize your prayer journey with a beautiful calendar view of your activity.</p>
</div>
<div className="bg-slate-800/50 backdrop-blur rounded-xl p-6 border border-slate-700/50">
<div className="text-4xl mb-4">📱</div>
<h4 className="text-xl font-semibold text-white mb-2">Works Offline</h4>
<p className="text-slate-300">Access your prayers anywhere with offline support and cross-device sync via Firebase.</p>
</div>
</div>
</section>
{/* Calendar Sync Section */}
<section className="max-w-6xl mx-auto px-4 py-20">
<div className="bg-slate-800/50 backdrop-blur rounded-xl p-8 border border-slate-700/50">
<h3 className="text-2xl font-bold text-white mb-4">📅 Calendar Integration</h3>
<p className="text-slate-300 mb-4">
Create recurring prayer reminders and automatically sync them to your Google Calendar.
Never forget to pray for what matters most.
</p>
<ul className="text-slate-300 space-y-2">
<li>✓ Sync recurring prayers to Google Calendar</li>
<li>✓ Automatic reminders at your preferred time</li>
<li>✓ Set daily, weekly, biweekly, or monthly reminders</li>
</ul>
</div>
</section>
{/* CTA Section */}
<section className="max-w-6xl mx-auto px-4 py-20 text-center">
<h3 className="text-3xl font-bold text-white mb-6">Ready to Start Your Prayer Journey?</h3>
<Link
href="/login"
className="inline-block px-8 py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-semibold transition-colors text-lg"
>
Sign In Now
</Link>
</section>
{/* Footer */}
<footer className="border-t border-slate-700/50 bg-slate-900/50 mt-20 py-12">
<div className="max-w-6xl mx-auto px-4">
<div className="grid md:grid-cols-4 gap-8 mb-8">
<div>
<h4 className="text-white font-semibold mb-4">Haver</h4>
<p className="text-slate-400 text-sm">A prayer app for your daily practice.</p>
</div>
<div>
<h4 className="text-white font-semibold mb-4">Quick Links</h4>
<ul className="space-y-2">
<li><Link href="/privacy" className="text-slate-400 hover:text-white transition-colors">Privacy Policy</Link></li>
<li><Link href="#" className="text-slate-400 hover:text-white transition-colors">Terms of Service</Link></li>
<li><Link href="/login" className="text-slate-400 hover:text-white transition-colors">Sign In</Link></li>
</ul>
</div>
<div>
<h4 className="text-white font-semibold mb-4">Contact</h4>
<p className="text-slate-400 text-sm">Questions? We'd love to hear from you.</p>
</div>
<div>
<h4 className="text-white font-semibold mb-4">Version</h4>
<p className="text-slate-400 text-sm">v0.4.0</p>
</div>
</div>
<div className="border-t border-slate-700/50 pt-8 flex flex-col md:flex-row justify-between items-center">
<p className="text-slate-400 text-sm mb-4 md:mb-0">© 2026 Haver. All rights reserved.</p>
<div className="flex gap-4">
<Link href="/privacy" className="text-slate-400 hover:text-white text-sm transition-colors">Privacy</Link>
<Link href="#" className="text-slate-400 hover:text-white text-sm transition-colors">Terms</Link>
</div>
</div>
</div>
</footer>
</div>
);
}
+10
View File
@@ -0,0 +1,10 @@
body {
margin: 0;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
+19
View File
@@ -0,0 +1,19 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

+1
View File
@@ -0,0 +1 @@
/// <reference types="react-scripts" />
+15
View File
@@ -0,0 +1,15 @@
import { ReportHandler } from 'web-vitals';
const reportWebVitals = (onPerfEntry?: ReportHandler) => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(onPerfEntry);
getFID(onPerfEntry);
getFCP(onPerfEntry);
getLCP(onPerfEntry);
getTTFB(onPerfEntry);
});
}
};
export default reportWebVitals;
+5
View File
@@ -0,0 +1,5 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';
+24
View File
@@ -0,0 +1,24 @@
export interface PrayedEntry {
date: string;
note: string;
}
export interface Prayer {
id: string;
person: string;
request: string;
answer: string;
notes?: string;
status: 'active' | 'answered' | 'archived';
createdAt: string;
answeredAt?: string;
tags: string[];
prayedLog?: PrayedEntry[];
reminderDays?: string[]; // e.g. ['mon', 'wed', 'fri']
}
export interface AppSettings {
ntfyTopic?: string;
dailyDigestTime?: string; // HH:MM
reminderTime?: string; // HH:MM — time per-prayer reminders fire
}
+101
View File
@@ -0,0 +1,101 @@
import { useState, useEffect, useCallback } from 'react';
import { Prayer, PrayedEntry } from './types';
const API = '/api/prayers';
async function checkResponse(r: Response) {
if (!r.ok) {
const body = await r.json().catch(() => ({}));
throw new Error(body.error || `Server error ${r.status}`);
}
return r;
}
export function usePrayers() {
const [prayers, setPrayers] = useState<Prayer[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetch(API)
.then(r => r.json())
.then(setPrayers)
.catch(e => setError(e.message))
.finally(() => setLoading(false));
}, []);
const addPrayer = useCallback(async (person: string, request: string, tags: string[]) => {
const prayer = {
person,
request,
answer: '',
status: 'active',
createdAt: new Date().toISOString(),
tags,
};
const saved = await fetch(API, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(prayer),
}).then(checkResponse).then(r => r.json());
setPrayers(prev => [saved, ...prev]);
}, []);
const patch = useCallback(async (id: string, updates: Partial<Prayer>) => {
const updated = await fetch(`${API}/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
}).then(checkResponse).then(r => r.json());
setPrayers(prev => prev.map(p => p.id === id ? updated : p));
}, []);
const updateAnswer = useCallback((id: string, answer: string) =>
patch(id, { answer }), [patch]);
const markAnswered = useCallback((id: string) =>
patch(id, { status: 'answered', answeredAt: new Date().toISOString() }), [patch]);
const markActive = useCallback((id: string) =>
patch(id, { status: 'active', answeredAt: undefined }), [patch]);
const archivePrayer = useCallback((id: string) =>
patch(id, { status: 'archived' }), [patch]);
const updateRequest = useCallback((id: string, request: string) =>
patch(id, { request }), [patch]);
const updateNotes = useCallback((id: string, notes: string) =>
patch(id, { notes }), [patch]);
const logPrayed = useCallback(async (id: string, entry: PrayedEntry) => {
const prayer = prayers.find(p => p.id === id);
if (!prayer) return;
const prayedLog = [...(prayer.prayedLog ?? []), entry];
await patch(id, { prayedLog });
}, [prayers, patch]);
const updateReminderDays = useCallback((id: string, reminderDays: string[]) =>
patch(id, { reminderDays }), [patch]);
const deletePrayer = useCallback(async (id: string) => {
await fetch(`${API}/${id}`, { method: 'DELETE' }).then(checkResponse);
setPrayers(prev => prev.filter(p => p.id !== id));
}, []);
return {
prayers,
loading,
error,
addPrayer,
updateAnswer,
markAnswered,
markActive,
archivePrayer,
deletePrayer,
updateRequest,
updateNotes,
logPrayed,
updateReminderDays,
};
}
+39
View File
@@ -0,0 +1,39 @@
import { useState, useEffect, useCallback } from 'react';
import { AppSettings } from './types';
const API = '/api/settings';
export function useSettings() {
const [settings, setSettings] = useState<AppSettings>({});
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(API)
.then(r => r.json())
.then(setSettings)
.catch(() => {})
.finally(() => setLoading(false));
}, []);
const saveSettings = useCallback(async (updates: Partial<AppSettings>) => {
const res = await fetch(API, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
});
if (!res.ok) throw new Error('Failed to save settings');
const updated = await res.json();
setSettings(updated);
return updated;
}, []);
const testNotification = useCallback(async () => {
const res = await fetch('/api/notifications/test', { method: 'POST' });
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || 'Test failed');
}
}, []);
return { settings, loading, saveSettings, testNotification };
}