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
20 changed files with 2627 additions and 103 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 -103
View File
@@ -1,128 +1,79 @@
# Haver — Prayer App
# Prayer Journal
A minimalist PWA for journaling prayers and tracking answered prayers. Built with Next.js, Firebase, and Tailwind.
A minimalist web app for journaling daily prayers, tracking answered prayers, and receiving push reminders. Self-hostable via Docker.
Current Version: 0.2.0
## What's New (0.2.0)
- Redesign: unified dark theme and gradients
- Icons: lucide-react across headings and nav
- Cleanup: removed legacy email routes and pages
- UX: Pray For suggestions in compose tab
**Current version: 0.4.0**
## Features
- Firebase auth (login)
- Compose daily prayer with date and optional "Pray For"
- Suggested "Pray For" names via dropdown (type to add new)
- Mark prayers as answered with details
- Calendar view of entries
- Daily prayer composition with optional "Pray For" person
- Mark prayers as answered with notes
- Calendar view of past entries
- Reflections list with search and filters
- Answered prayers gallery
- Reminder banner + notification time settings
- PWA installable; dark theme by default
- 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
## Versioning Policy
- Uses semantic versioning: major.minor
- Minor: UI/UX tweaks, non-breaking features
- Major: structural changes or breaking updates
## Stack
## Setup
1. Create a Firebase project (Authentication + Firestore)
2. Add your Firebase config to [src/lib/firebase.ts](src/lib/firebase.ts)
3. Start the dev server
- **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`
## Notes
- Email sending routes have been removed in the redesign.
- Email verification remains available via Firebase.
## Docker (recommended)
## Firestore Rules
Deploy [firestore.rules](firestore.rules) to secure user data.
```bash
docker compose up -d
```
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
- `npm run dev` — start dev server
- `npm run build` — production build
- `npm run lint` — lint code
- `npm test` — run tests
## Troubleshooting
### Common Issues
1. **Firebase Configuration**: Ensure all Firebase config values are correctly set in `src/lib/firebase.ts`.
2. **SMTP Settings**: Verify SMTP credentials in the Admin panel. Use app passwords for Gmail.
3. **PWA Installation**: Clear browser cache if PWA doesn't install properly.
4. **Email Reminders**: Check Firestore security rules and ensure SMTP is configured.
### Development
- Run `npm run dev` for development server
- Run `npm run build` for production build
- Run `npm run lint` for code linting
- Run `npm test` for running tests
## Usage
- Compose: write your prayer, pick a date, and optionally select or type a "Pray For" name.
- Praying: mark prayers as prayed today.
- Calendar: click a day to view/edit its entry.
- Reflections: search and filter past prayers.
- Answered: review answered prayers with details.
- About: set reminder time, test notifications, and read "What's New".
## Getting Started
## Build & Deploy
```bash
npm run build
```
Deploy to Vercel or your preferred platform.
## Getting Started
First, install dependencies:
```bash
npm install
```
Then, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Configuration
For email reminders, configure SMTP settings in `src/app/api/send-email/route.ts`.
## Build
App runs at `http://localhost:3000`.
```bash
npm run build
npm run build # production build
npm run lint # lint
npm test # run tests
```
## Deploy
## CI / Deployment
Deploy to Vercel or any platform supporting Next.js PWAs.
Pushes to `master` trigger a Gitea Actions workflow (`.gitea/workflows/docker.yml`) that builds and pushes the Docker image to `git.necloud.us`.
## Docker (optional)
Basic containerization is available. Remove or adapt scripts as needed.
Secrets required in Gitea repo settings:
## Learn More
- [Next.js Documentation](https://nextjs.org/docs)
- [Tailwind CSS](https://tailwindcss.com/docs)
```
| Secret | Value |
|---|---|
| `REGISTRY_USER` | Gitea username |
| `REGISTRY_TOKEN` | Gitea access token with package write scope |
## Versioning
Semantic versioning — `npm run version:minor` or `npm run version:major` bumps the version and syncs it across the project.
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>
);
}
+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 };
}