diff --git a/package-lock.json b/package-lock.json index 8d3a9a7..0c64339 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,8 @@ "dependencies": { "cors": "^2.8.6", "docx": "^9.7.1", - "express": "^5.2.1" + "express": "^5.2.1", + "node-cron": "^4.6.0" }, "devDependencies": { "@testing-library/dom": "^10.4.1", @@ -12613,6 +12614,15 @@ "tslib": "^2.0.3" } }, + "node_modules/node-cron": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.6.0.tgz", + "integrity": "sha512-Si/bzYiKRHOB8/a99T2+SDGN582ONDMSTlJr5oCkT6GtnqPjZ2s10eoQRYkW9ZHwjVxONL+W8Fb+qR0AHMQsdg==", + "license": "ISC", + "engines": { + "node": ">=20" + } + }, "node_modules/node-exports-info": { "version": "1.6.2", "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.2.tgz", @@ -17097,6 +17107,24 @@ } } }, + "node_modules/tailwindcss/node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/tapable": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", diff --git a/package.json b/package.json index bed43d0..a0f1ffb 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "dependencies": { "cors": "^2.8.6", "docx": "^9.7.1", - "express": "^5.2.1" + "express": "^5.2.1", + "node-cron": "^4.6.0" }, "devDependencies": { "@testing-library/dom": "^10.4.1", diff --git a/server.js b/server.js index 7a43fb2..c390a1a 100644 --- a/server.js +++ b/server.js @@ -3,16 +3,16 @@ 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, ShadingType, TableRow, TableCell, - Table, WidthType, PageBreak, Header, Footer, PageNumber, - NumberFormat, + 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 { @@ -42,10 +42,88 @@ function writePrayers(prayers) { 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}`; + try { + const res = await fetch(url, { + method: 'POST', + headers: { + 'Title': title, + '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 = []; @@ -131,6 +209,27 @@ function prayerToSection(prayer, index) { 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(); diff --git a/src/App.css b/src/App.css index a56901b..d6611db 100644 --- a/src/App.css +++ b/src/App.css @@ -912,3 +912,136 @@ mark.highlight { font-size: 0.92rem; line-height: 1.4; } + +/* Settings panel */ +.settings-btn { + margin-top: 0.5rem; + font-size: 0.85rem; +} + +.settings-panel { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; + padding: 1.25rem; + box-shadow: var(--shadow); + margin-bottom: 1.5rem; +} + +.settings-title { + font-size: 1.1rem; + font-weight: normal; + margin-bottom: 0.5rem; +} + +.settings-desc { + font-size: 0.88rem; + color: var(--text-muted); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + margin-bottom: 1rem; + line-height: 1.5; +} + +.settings-desc a { color: var(--primary); } + +.settings-form { + display: flex; + flex-direction: column; + gap: 0.85rem; +} + +.settings-label { + display: flex; + flex-direction: column; + gap: 0.3rem; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; + font-size: 0.75rem; +} + +.settings-input { + border: 1px solid var(--border); + border-radius: 6px; + padding: 0.5rem 0.75rem; + background: var(--bg); + color: var(--text); + font-family: inherit; + font-size: 0.95rem; + outline: none; + transition: border-color 0.15s; +} + +.settings-input:focus { border-color: var(--primary); } + +.settings-hint { + font-size: 0.78rem; + color: var(--text-muted); + text-transform: none; + letter-spacing: 0; + font-style: italic; +} + +.settings-actions { + display: flex; + gap: 0.5rem; + align-items: center; + flex-wrap: wrap; +} + +.settings-msg { + font-size: 0.85rem; + color: var(--answered); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; +} + +/* Reminder day picker */ +.reminder-row { + display: flex; + align-items: center; + gap: 0.75rem; + padding-top: 0.5rem; + border-top: 1px solid var(--border); + flex-wrap: wrap; +} + +.reminder-label { + font-size: 0.8rem; + color: var(--text-muted); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + white-space: nowrap; +} + +.day-picker { + display: flex; + gap: 0.3rem; +} + +.day-btn { + width: 2rem; + height: 2rem; + border-radius: 50%; + border: 1px solid var(--border); + background: var(--bg); + color: var(--text-muted); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + font-size: 0.72rem; + cursor: pointer; + transition: background 0.12s, color 0.12s, border-color 0.12s; + display: flex; + align-items: center; + justify-content: center; + padding: 0; +} + +.day-btn:hover { + border-color: var(--primary); + color: var(--primary); +} + +.day-btn.active { + background: var(--primary); + color: #fff; + border-color: var(--primary); +} diff --git a/src/App.tsx b/src/App.tsx index f03e5b5..29b6eb1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,7 @@ import React, { useState, useMemo } from 'react'; import { usePrayers } from './usePrayers'; -import { Prayer, PrayedEntry } from './types'; +import { useSettings } from './useSettings'; +import { Prayer, PrayedEntry, AppSettings } from './types'; import './App.css'; type Filter = 'active' | 'answered' | 'all'; @@ -32,6 +33,9 @@ function matches(prayer: Prayer, query: string) { ); } +const DAYS = ['sun','mon','tue','wed','thu','fri','sat'] as const; +const DAY_LABELS: Record = { sun:'Su', mon:'Mo', tue:'Tu', wed:'We', thu:'Th', fri:'Fr', sat:'Sa' }; + export default function App() { const { prayers, @@ -46,8 +50,11 @@ export default function App() { updateRequest, updateNotes, logPrayed, + updateReminderDays, } = usePrayers(); + const { settings, saveSettings, testNotification } = useSettings(); + const [showSettings, setShowSettings] = useState(false); const [tab, setTab] = useState('prayers'); const [filter, setFilter] = useState('active'); const [activeTag, setActiveTag] = useState(null); @@ -138,8 +145,19 @@ export default function App() {

Prayer Journal

Bring your requests. Record His faithfulness.

+
+ {showSettings && ( + + )} +
+ {topic && ( + + )} + {msg && {msg}} +
+ + + ); +} + interface CardProps { prayer: Prayer; expanded: boolean; editingAnswer: string | null; activeTag: string | null; searchQuery: string; + ntfyConfigured: boolean; onTagClick: (tag: string) => void; onToggle: () => void; onStartAnswer: () => void; @@ -321,6 +419,7 @@ interface CardProps { onEditRequest: (text: string) => void; onLogPrayed: (entry: PrayedEntry) => void; onSaveNotes: (notes: string) => void; + onUpdateReminderDays: (days: string[]) => void; formatDate: (iso: string) => string; } @@ -330,6 +429,7 @@ function PrayerCard({ editingAnswer, activeTag, searchQuery, + ntfyConfigured, onTagClick, onToggle, onStartAnswer, @@ -341,6 +441,7 @@ function PrayerCard({ onDelete, onLogPrayed, onSaveNotes, + onUpdateReminderDays, formatDate, }: CardProps) { const [logDate, setLogDate] = useState(new Date().toISOString().slice(0, 10)); @@ -361,6 +462,12 @@ function PrayerCard({ 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() ); @@ -472,10 +579,7 @@ function PrayerCard({
{!showLogForm && ( - )} @@ -526,6 +630,24 @@ function PrayerCard({
+ + {ntfyConfigured && prayer.status === 'active' && ( +
+ πŸ”” Remind me +
+ {DAYS.map(d => ( + + ))} +
+
+ )} )} @@ -539,7 +661,7 @@ function CalendarView({ prayers, formatDate }: { prayers: Prayer[]; formatDate: const [selectedDate, setSelectedDate] = useState(null); const prayedByDate = useMemo(() => { - const map = new Map>() ; + const map = new Map>(); prayers.forEach(prayer => { (prayer.prayedLog ?? []).forEach(entry => { const key = entry.date.slice(0, 10); @@ -609,7 +731,6 @@ function CalendarView({ prayers, formatDate }: { prayers: Prayer[]; formatDate: day === today.getDate() && month === today.getMonth() && year === today.getFullYear(); const isSelected = selectedDate === dateStr; - // Deduplicate colors so each prayer shows one dot per day const uniqueColors = entries ? Array.from(new Map(entries.map(e => [e.prayer.id, prayerColor(e.prayer.id)])).values()) : []; diff --git a/src/types.ts b/src/types.ts index 4fe5ff6..181bebd 100644 --- a/src/types.ts +++ b/src/types.ts @@ -14,4 +14,11 @@ export interface Prayer { 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 } diff --git a/src/usePrayers.ts b/src/usePrayers.ts index 271f987..a6dc929 100644 --- a/src/usePrayers.ts +++ b/src/usePrayers.ts @@ -75,6 +75,9 @@ export function usePrayers() { 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)); @@ -93,5 +96,6 @@ export function usePrayers() { updateRequest, updateNotes, logPrayed, + updateReminderDays, }; } diff --git a/src/useSettings.ts b/src/useSettings.ts new file mode 100644 index 0000000..cf1be9e --- /dev/null +++ b/src/useSettings.ts @@ -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({}); + const [loading, setLoading] = useState(true); + + useEffect(() => { + fetch(API) + .then(r => r.json()) + .then(setSettings) + .catch(() => {}) + .finally(() => setLoading(false)); + }, []); + + const saveSettings = useCallback(async (updates: Partial) => { + 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 }; +}