import { useState, useEffect, useMemo, useRef } from 'react';
import { useAuth, useCollection } from 'deplixo';
import { generatePDF } from '@deplixo/sdk';
import { JournalEntry } from './components/JournalEntry.jsx';
import { CalendarView } from './components/CalendarView.jsx';
import { RandomReflection } from './components/RandomReflection.jsx';
function getEntryDateKey(entry) {
if (!entry) return null;
const value = entry.entryDate || entry.date || entry.createdAt || entry.updatedAt;
if (!value) return null;
const d = new Date(value);
if (Number.isNaN(d.getTime())) return null;
return d.toISOString().slice(0, 10);
}
function getWeekStartKey(value) {
if (!value) return null;
const d = new Date(`${value}T00:00:00`);
if (Number.isNaN(d.getTime())) return null;
const day = d.getDay();
const diff = (day + 6) % 7;
d.setDate(d.getDate() - diff);
return d.toISOString().slice(0, 10);
}
function getMoodTagsFromEntry(entry) {
if (!entry) return [];
const candidates = [
entry.mood,
entry.moodTag,
entry.moodTags,
entry.moods,
entry.tags,
entry.labels
];
const moods = candidates.flatMap(value => {
if (Array.isArray(value)) return value;
if (typeof value === 'string') return [value];
return [];
});
return moods
.map(value => String(value).trim().toLowerCase())
.filter(Boolean);
}
function normalizeMoodLabel(label) {
const cleaned = String(label || '').trim().toLowerCase();
if (!cleaned) return null;
const mapping = {
happy: 'Happy',
joy: 'Happy',
joyful: 'Happy',
grateful: 'Happy',
calm: 'Calm',
peaceful: 'Calm',
relaxed: 'Calm',
neutral: 'Neutral',
okay: 'Neutral',
fine: 'Neutral',
sad: 'Sad',
down: 'Sad',
tired: 'Tired',
stressed: 'Stressed',
anxious: 'Anxious',
anxiousness: 'Anxious',
excited: 'Excited',
hopeful: 'Hopeful',
content: 'Content'
};
return mapping[cleaned] || cleaned.charAt(0).toUpperCase() + cleaned.slice(1);
}
function countWordsFromEntry(entry) {
if (!entry) return 0;
const fields = [
entry.text,
entry.entryText,
entry.content,
entry.gratitude,
entry.gratitudes,
entry.items,
entry.response
];
const text = fields
.flatMap(value => {
if (Array.isArray(value)) return value;
if (typeof value === 'string') return [value];
return [];
})
.join(' ')
.trim();
if (!text) return 0;
return text.split(/\s+/).filter(Boolean).length;
}
function getEntryDisplayDate(entry) {
const key = getEntryDateKey(entry);
if (!key) return 'Unknown date';
return new Date(`${key}T00:00:00`).toLocaleDateString(undefined, {
weekday: 'short',
month: 'short',
day: 'numeric'
});
}
function getEntryTextList(entry) {
if (!entry) return [];
const fields = [entry.text, entry.entryText, entry.content, entry.gratitude, entry.gratitudes, entry.items, entry.response];
return fields.flatMap(value => {
if (Array.isArray(value)) return value;
if (typeof value === 'string') return [value];
return [];
}).map(value => String(value).trim()).filter(Boolean);
}
function buildShareLink(entry) {
const entryId = entry?.id || '';
const dateKey = getEntryDateKey(entry) || '';
const payload = encodeURIComponent(JSON.stringify({ entryId, dateKey }));
return `${window.location.origin}${window.location.pathname}#share=${payload}`;
}
function EntrySharePanel({ entry, onClose }) {
const [copied, setCopied] = useState(false);
const shareLink = useMemo(() => buildShareLink(entry), [entry]);
const summary = getEntryTextList(entry).slice(0, 3).join(' • ');
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(shareLink);
setCopied(true);
window.setTimeout(() => setCopied(false), 1800);
} catch {
setCopied(false);
}
};
const handleNativeShare = async () => {
if (navigator.share) {
try {
await navigator.share({
title: 'Grateful entry',
text: summary || 'A gratitude entry from Grateful',
url: shareLink
});
} catch {
// ignore cancellation
}
}
};
return (
Share this entry
Choose how you want to share this gratitude moment.
{getEntryDisplayDate(entry)}
{summary || 'A private gratitude entry'}
);
}
function MonthlyExportCard({ entries, user }) {
const exportRef = useRef(null);
const [exporting, setExporting] = useState(false);
const [selectedMonth, setSelectedMonth] = useState(() => {
const now = new Date();
return now.toISOString().slice(0, 7);
});
const monthEntries = useMemo(() => {
return (entries || [])
.filter(entry => entry?.userId === user?.id)
.filter(entry => getEntryDateKey(entry)?.startsWith(selectedMonth));
}, [entries, selectedMonth, user?.id]);
const monthLabel = useMemo(() => {
const [year, month] = selectedMonth.split('-').map(Number);
const date = new Date(year, (month || 1) - 1, 1);
return date.toLocaleDateString(undefined, { month: 'long', year: 'numeric' });
}, [selectedMonth]);
const handleExportPDF = async () => {
if (!exportRef.current) return;
setExporting(true);
try {
await generatePDF(exportRef.current, {
filename: `grateful-${selectedMonth}-compilation.pdf`,
margin: 10
});
} finally {
setExporting(false);
}
};
return (
Monthly compilation PDF
Export a printable summary of one month
Grateful — {monthLabel}
Monthly gratitude compilation
{monthEntries.length === 0 ? (
No entries were found for this month.
) : (
{monthEntries.map(entry => (
{getEntryDisplayDate(entry)}
{getEntryTextList(entry).slice(0, 3).map((text, idx) => - {text}
)}
))}
)}
);
}
function App() {
const auth = typeof useAuth === 'function' ? useAuth() : {};
const { user, loading: authLoading, login, logout } = auth;
const collection = typeof useCollection === 'function'
? useCollection('journalEntries', { personal: true })
: {};
const { items: entries, loading: entriesLoading, add, update } = collection;
const [activeTab, setActiveTab] = useState('journal');
const [signedInEntryId, setSignedInEntryId] = useState(null);
const [shareEntry, setShareEntry] = useState(null);
const tabs = [
{ id: 'journal', label: '✏️ Today', icon: '✏️' },
{ id: 'calendar', label: '📅 Calendar', icon: '📅' },
{ id: 'reflect', label: '🔮 Reflect', icon: '🔮' },
{ id: 'charts', label: '📈 Charts', icon: '📈' },
{ id: 'stats', label: '📊 Stats', icon: '📊' }
];
useEffect(() => {
if (!authLoading && !user) {
setActiveTab('journal');
}
}, [authLoading, user]);
const handleSignIn = async () => {
if (typeof login === 'function') {
await login({ provider: 'google' });
}
};
const handleSignOut = async () => {
if (typeof logout === 'function') {
await logout();
}
};
const handleSaveEntry = async (entryData) => {
if (!user) return;
const payload = {
...entryData,
userId: user.id,
userEmail: user.email || '',
userName: user.name || '',
updatedAt: new Date().toISOString()
};
if (signedInEntryId && typeof update === 'function') {
await update(signedInEntryId, payload);
return;
}
if (typeof add !== 'function') return;
const created = await add({
...payload,
createdAt: new Date().toISOString()
});
if (created?.id) setSignedInEntryId(created.id);
};
const userEntries = (entries || []).filter(entry => entry?.userId === user?.id);
const charts = useMemo(() => {
const entriesWithDates = userEntries
.map(entry => ({ entry, dateKey: getEntryDateKey(entry) }))
.filter(item => Boolean(item.dateKey))
.sort((a, b) => new Date(a.dateKey) - new Date(b.dateKey));
const weekMap = new Map();
entriesWithDates.forEach(({ dateKey }) => {
const weekKey = getWeekStartKey(dateKey);
if (!weekKey) return;
weekMap.set(weekKey, (weekMap.get(weekKey) || 0) + 1);
});
const entriesPerWeek = [...weekMap.entries()]
.sort((a, b) => new Date(a[0]) - new Date(b[0]))
.map(([weekStart, count]) => ({
label: new Date(`${weekStart}T00:00:00`).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }),
value: count,
weekStart
}));
const moodCounts = new Map();
const moodWordTotals = new Map();
let moodTaggedEntries = 0;
userEntries.forEach(entry => {
const moods = getMoodTagsFromEntry(entry).map(normalizeMoodLabel).filter(Boolean);
if (moods.length === 0) return;
moodTaggedEntries += 1;
const wordCount = countWordsFromEntry(entry);
moods.forEach(mood => {
moodCounts.set(mood, (moodCounts.get(mood) || 0) + 1);
moodWordTotals.set(mood, (moodWordTotals.get(mood) || 0) + wordCount);
});
});
const moodCorrelation = [...moodCounts.entries()]
.map(([mood, count]) => ({
mood,
count,
avgWords: count > 0 ? moodWordTotals.get(mood) / count : 0
}))
.sort((a, b) => b.count - a.count || b.avgWords - a.avgWords);
return {
entriesPerWeek,
moodCorrelation,
moodTaggedEntries
};
}, [userEntries]);
const stats = useMemo(() => {
const totalEntries = userEntries.length;
const sortedByDate = [...userEntries].sort((a, b) => {
const aTime = new Date(a.createdAt || a.updatedAt || a.entryDate || 0).getTime();
const bTime = new Date(b.createdAt || b.updatedAt || b.entryDate || 0).getTime();
return aTime - bTime;
});
const dateKeys = [...new Set(sortedByDate.map(getEntryDateKey).filter(Boolean))].sort();
let longestStreak = 0;
let currentStreak = 0;
if (dateKeys.length > 0) {
const daySet = new Set(dateKeys);
let best = 0;
let run = 0;
for (let i = 0; i < dateKeys.length; i += 1) {
const current = dateKeys[i];
const prev = dateKeys[i - 1];
if (!prev) {
run = 1;
} else {
const prevDate = new Date(`${prev}T00:00:00`);
const currentDate = new Date(`${current}T00:00:00`);
const diffDays = Math.round((currentDate - prevDate) / 86400000);
run = diffDays === 1 ? run + 1 : 1;
}
best = Math.max(best, run);
}
longestStreak = best;
const today = new Date();
const todayKey = today.toISOString().slice(0, 10);
const yesterday = new Date(today);
yesterday.setDate(today.getDate() - 1);
const yesterdayKey = yesterday.toISOString().slice(0, 10);
if (daySet.has(todayKey) || daySet.has(yesterdayKey)) {
let streakCursor = daySet.has(todayKey) ? todayKey : yesterdayKey;
let streak = 0;
while (daySet.has(streakCursor)) {
streak += 1;
const cursorDate = new Date(`${streakCursor}T00:00:00`);
cursorDate.setDate(cursorDate.getDate() - 1);
streakCursor = cursorDate.toISOString().slice(0, 10);
}
currentStreak = streak;
}
}
const wordsWritten = userEntries.reduce((sum, entry) => sum + countWordsFromEntry(entry), 0);
return {
totalEntries,
longestStreak,
currentStreak,
wordsWritten
};
}, [userEntries]);
const maxWeeklyEntries = Math.max(1, ...charts.entriesPerWeek.map(item => item.value));
const maxMoodCount = Math.max(1, ...charts.moodCorrelation.map(item => item.count));
return (
{authLoading || entriesLoading ? (
) : !user ? (
🔐
Sign in to view your journal
Your gratitude entries are private and tied to your Google account.
Sign in to write, review, and reflect on your own entries.
) : (
<>
{activeTab === 'journal' && (
)}
{activeTab === 'calendar' && (
)}
{activeTab === 'reflect' && }
{activeTab === 'charts' && (
Your Charts
See weekly consistency and mood patterns over time.
Entries per week
Time-series aggregation
{charts.entriesPerWeek.length === 0 ? (
Not enough dated entries yet to build a weekly chart.
) : (
{charts.entriesPerWeek.map(point => (
{point.value}
{point.label}
))}
)}
Mood correlation
Only entries with mood tags are included
{charts.moodTaggedEntries === 0 ? (
Add mood tags to your entries to see mood correlation here.
Supported fields: mood, moodTag, moodTags, moods, tags, or labels.
) : charts.moodCorrelation.length < 1 ? (
Mood data is present, but there isn’t enough consistent tagging to chart yet.
) : (
{charts.moodCorrelation.map(item => (
{item.mood}
{item.count} entries
Avg. {item.avgWords.toFixed(1)} words
))}
)}
)}
{activeTab === 'stats' && (
Your Stats
Track consistency and momentum over time.
{stats.totalEntries}
Total entries
{stats.currentStreak}
Current streak
{stats.longestStreak}
Longest streak
{stats.wordsWritten}
Words written
)}
>
)}
{shareEntry && (
setShareEntry(null)}>
e.stopPropagation()}>
setShareEntry(null)} />
)}
);
}
ReactDOM.createRoot(document.getElementById("root")).render();