Compare commits

..
Author SHA1 Message Date
nmemmert e236609e94 Add /login page and fix public home page links
- Create dedicated /login page for sign up/sign in
- Replace all /settings links with /login in public home
- Get Started button now takes users to login page
- Redirects authenticated users back to main app
2026-01-19 15:23:21 -05:00
nmemmert 149cfa5790 Add public landing page and privacy policy for Google OAuth verification
- Create public home page accessible without login
- Add comprehensive privacy policy page at /privacy
- Add privacy policy link to home page and footer
- Clearly explain app purpose: prayer journal and tracker
- Show app name 'Haver' prominently on home page
- Addresses Google OAuth verification requirements:
  - Home page not behind login
  - Separate privacy policy URL
  - App purpose clearly explained
  - Privacy policy link visible
2026-01-19 15:20:34 -05:00
nmemmert b7c9593c13 readme 2026-01-19 12:45:51 -05:00
24 changed files with 635 additions and 2628 deletions

No files matched your search

-51
View File
@@ -1,51 +0,0 @@
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
+292 -54
View File
@@ -1,79 +1,317 @@
# Prayer Journal # Haver — Prayer App
A minimalist web app for journaling daily prayers, tracking answered prayers, and receiving push reminders. Self-hostable via Docker. > A beautiful, minimalist PWA for journaling prayers, tracking prayer requests, and celebrating answered prayers.
**Current version: 0.4.0** Built with Next.js, Firebase, and Tailwind CSS.
## Features **Current Version: 0.4.0**
- Daily prayer composition with optional "Pray For" person [![PWA](https://img.shields.io/badge/PWA-Enabled-blue)](https://github.com/nmemmert/prayer-app)
- Mark prayers as answered with notes [![Firebase](https://img.shields.io/badge/Firebase-Ready-orange)](https://firebase.google.com)
- Calendar view of past entries [![License](https://img.shields.io/badge/License-MIT-green)](LICENSE)
- 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 ---
- **Frontend**: Next.js + React, Tailwind CSS, Firebase (auth + Firestore) ## 📸 Screenshots
- **Backend**: Express server (`server.js`) — local JSON storage, cron-based ntfy notifications, DOCX/PDF export
- **Container**: Docker, published to `git.necloud.us`
## Docker (recommended) <!-- Add screenshots here after taking them -->
> *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
docker compose up -d git clone https://github.com/nmemmert/prayer-app.git
cd prayer-app
``` ```
The app listens on port `3050` by default. Adjust environment variables in `docker-compose.yml` as needed. 2. **Install dependencies**
### 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
``` ```
App runs at `http://localhost:3000`. 6. **Open** [http://localhost:3000](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 build # production build npm run dev # Start development server
npm run lint # lint npm run build # Production build
npm test # run tests npm run start # Start production server
npm run lint # Lint code
npm run version:minor # Bump minor version
npm run version:major # Bump major version
``` ```
## CI / Deployment ### Project Structure
```
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`. ---
Secrets required in Gitea repo settings: ## 🐳 Docker Deployment
| Secret | Value | ### Build Image
|---|---| ```bash
| `REGISTRY_USER` | Gitea username | docker build -t prayer-app:latest .
| `REGISTRY_TOKEN` | Gitea access token with package write scope | ```
## Versioning ### Run Container
```bash
docker run -p 3000:3000 prayer-app:latest
```
Semantic versioning — `npm run version:minor` or `npm run version:major` bumps the version and syncs it across the project. ### Deploy to GitHub Container Registry
```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.

Before

Width:  |  Height:  |  Size: 3.8 KiB

-43
View File
@@ -1,43 +0,0 @@
<!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.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

-3
View File
@@ -1,3 +0,0 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:
-421
View File
@@ -1,421 +0,0 @@
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
@@ -1,9 +0,0 @@
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
@@ -1,784 +0,0 @@
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
@@ -0,0 +1,36 @@
'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>
);
}
+2 -1
View File
@@ -12,6 +12,7 @@ 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';
@@ -319,7 +320,7 @@ export default function Home() {
} }
if (!user) { if (!user) {
return <Login />; return <PublicHome />;
} }
return ( return (
+161
View File
@@ -0,0 +1,161 @@
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
@@ -0,0 +1,144 @@
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
@@ -1,10 +0,0 @@
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
@@ -1,19 +0,0 @@
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
@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 2.6 KiB

-1
View File
@@ -1 +0,0 @@
/// <reference types="react-scripts" />
-15
View File
@@ -1,15 +0,0 @@
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
@@ -1,5 +0,0 @@
// 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
@@ -1,24 +0,0 @@
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
@@ -1,101 +0,0 @@
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
@@ -1,39 +0,0 @@
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 };
}