Archived
feat: implement reading pace notifications (US #33)
Backend: - New hermes app with ReadingGroup, GroupMeeting, GroupMembership models - NotificationPreference (per-user: enable/disable, frequency daily/weekly) - PaceNotification model for tracking sent/dismissed notifications - Pace calculation service comparing current progress vs meeting targets - API endpoints: pace status, notification preferences CRUD, reminders, dismiss - Ahead/behind/on_track/completed status determination Frontend: - PaceNotification component for in-app alerts (behind/ahead/on_track statuses) - ReadingPaceBanner — persistent banner in reader view - Notification settings in Settings page (enable/disable, frequency, reminders) - API client module (groupsApi) for all notification endpoints
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
import api from "./client";
|
||||
import type {
|
||||
GroupMeeting,
|
||||
MeetingReminder,
|
||||
NotificationPreferences,
|
||||
PaceStatus,
|
||||
ReadingGroup,
|
||||
} from "../types/notifications";
|
||||
|
||||
export const groupsApi = {
|
||||
async getPaceStatus(ebookId?: number): Promise<PaceStatus[]> {
|
||||
const params: Record<string, string> = {};
|
||||
if (ebookId) params.ebook_id = String(ebookId);
|
||||
const { data } = await api.get<PaceStatus[]>("/groups/notifications/pace/", { params });
|
||||
return data;
|
||||
},
|
||||
|
||||
async getPreferences(): Promise<NotificationPreferences> {
|
||||
const { data } = await api.get<NotificationPreferences>("/groups/notifications/preferences/");
|
||||
return data;
|
||||
},
|
||||
|
||||
async updatePreferences(
|
||||
prefs: Partial<NotificationPreferences>,
|
||||
): Promise<NotificationPreferences> {
|
||||
const { data } = await api.patch<NotificationPreferences>(
|
||||
"/groups/notifications/preferences/",
|
||||
prefs,
|
||||
);
|
||||
return data;
|
||||
},
|
||||
|
||||
async dismissPaceNotification(meetingId: number, ebookId: number): Promise<void> {
|
||||
await api.post("/groups/notifications/dismiss/", {
|
||||
meeting_id: meetingId,
|
||||
ebook_id: ebookId,
|
||||
});
|
||||
},
|
||||
|
||||
async getReminders(): Promise<MeetingReminder[]> {
|
||||
const { data } = await api.get<MeetingReminder[]>("/groups/notifications/reminders/");
|
||||
return data;
|
||||
},
|
||||
|
||||
async getGroups(): Promise<ReadingGroup[]> {
|
||||
const { data } = await api.get<{ count: number; results: ReadingGroup[] } | ReadingGroup[]>(
|
||||
"/groups/groups/",
|
||||
);
|
||||
if (Array.isArray(data)) return data;
|
||||
return data.results ?? [];
|
||||
},
|
||||
|
||||
async getUpcomingMeetings(): Promise<GroupMeeting[]> {
|
||||
const { data } = await api.get<GroupMeeting[]>("/groups/meetings/upcoming/");
|
||||
return data;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18n-lite";
|
||||
import { groupsApi } from "../api/groups";
|
||||
import type { PaceStatus } from "../types/notifications";
|
||||
|
||||
interface PaceNotificationProps {
|
||||
ebookId?: number;
|
||||
}
|
||||
|
||||
export function PaceNotification({ ebookId }: PaceNotificationProps) {
|
||||
const { t } = useTranslation();
|
||||
const [paceItems, setPaceItems] = useState<PaceStatus[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [dismissed, setDismissed] = useState<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
const data = await groupsApi.getPaceStatus(ebookId);
|
||||
setPaceItems(data);
|
||||
} catch {
|
||||
// Silently fail — notifications are non-critical
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
void load();
|
||||
}, [ebookId]);
|
||||
|
||||
const handleDismiss = async (meetingId: number, itemEbookId: number) => {
|
||||
const key = `${meetingId}-${itemEbookId}`;
|
||||
setDismissed((prev) => new Set(prev).add(key));
|
||||
try {
|
||||
await groupsApi.dismissPaceNotification(meetingId, itemEbookId);
|
||||
} catch {
|
||||
// Best effort dismissal
|
||||
}
|
||||
};
|
||||
|
||||
if (loading || paceItems.length === 0) return null;
|
||||
|
||||
const visibleItems = paceItems.filter(
|
||||
(p) => !dismissed.has(`${p.meeting_id}-${p.ebook_id}`),
|
||||
);
|
||||
if (visibleItems.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8, padding: "12px 0" }}>
|
||||
{visibleItems.map((item) => {
|
||||
const isBehind = item.status === "behind";
|
||||
const isAhead = item.status === "ahead" || item.status === "completed";
|
||||
const isOnTrack = item.status === "on_track";
|
||||
|
||||
const bgColor = isBehind ? "#fde8e8" : isAhead ? "#d4edda" : "#e8f4fd";
|
||||
const borderColor = isBehind ? "#e74c3c" : isAhead ? "#27ae60" : "#3498db";
|
||||
const textColor = isBehind ? "#a71d2a" : isAhead ? "#155724" : "#0c5460";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${item.meeting_id}-${item.ebook_id}`}
|
||||
style={{
|
||||
background: bgColor,
|
||||
border: `1px solid ${borderColor}`,
|
||||
borderRadius: 8,
|
||||
padding: "12px 16px",
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "space-between",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: textColor, marginBottom: 4 }}>
|
||||
{isBehind
|
||||
? `Behind schedule — catch up ${item.sections_to_catch_up} section${item.sections_to_catch_up !== 1 ? "s" : ""}`
|
||||
: isOnTrack
|
||||
? "On track — keep going!"
|
||||
: item.status === "completed"
|
||||
? "Meeting target completed!"
|
||||
: "Ahead of schedule — great job!"}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: textColor, opacity: 0.85 }}>
|
||||
{item.meeting_title || `Target: ${item.target_section_label || `Section ${item.target_section}`}`}
|
||||
{" — "}
|
||||
{item.ebook_title}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: textColor, opacity: 0.7, marginTop: 2 }}>
|
||||
{isBehind
|
||||
? `Currently at section ${item.current_section}, need section ${item.target_section}`
|
||||
: `Section ${item.current_section} of ${item.target_section}`}
|
||||
{item.days_until_meeting > 0 &&
|
||||
` · ${item.days_until_meeting} day${item.days_until_meeting !== 1 ? "s" : ""} until meeting`}
|
||||
{item.days_until_meeting === 0 && " · Meeting today"}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDismiss(item.meeting_id, item.ebook_id)}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: textColor,
|
||||
fontSize: 20,
|
||||
cursor: "pointer",
|
||||
padding: "0 4px",
|
||||
lineHeight: 1,
|
||||
opacity: 0.6,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
title="Dismiss"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { groupsApi } from "../../api/groups";
|
||||
import type { PaceStatus } from "../../types/notifications";
|
||||
|
||||
interface ReadingPaceBannerProps {
|
||||
ebookId: number;
|
||||
currentSection: number;
|
||||
}
|
||||
|
||||
export function ReadingPaceBanner({ ebookId, currentSection }: ReadingPaceBannerProps) {
|
||||
const [paceItems, setPaceItems] = useState<PaceStatus[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
const data = await groupsApi.getPaceStatus(ebookId);
|
||||
setPaceItems(data);
|
||||
} catch {
|
||||
// Silently fail
|
||||
}
|
||||
};
|
||||
void load();
|
||||
}, [ebookId, currentSection]);
|
||||
|
||||
if (paceItems.length === 0) return null;
|
||||
|
||||
// Only show if user is one section away from target
|
||||
const relevantItems = paceItems.filter(
|
||||
(p) => Math.abs(p.target_section - p.current_section) <= 1 && p.status !== "completed",
|
||||
);
|
||||
|
||||
if (relevantItems.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
|
||||
color: "#fff",
|
||||
padding: "10px 16px",
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
textAlign: "center",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
flexWrap: "wrap",
|
||||
position: "sticky",
|
||||
top: 0,
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
{relevantItems.map((item) => {
|
||||
const isAlmostThere = item.sections_to_catch_up === 1;
|
||||
const isAtTarget = item.sections_to_catch_up === 0;
|
||||
|
||||
return (
|
||||
<span key={item.meeting_id}>
|
||||
{isAtTarget
|
||||
? `You've reached the target for ${item.meeting_title || `Section ${item.target_section}`}!`
|
||||
: isAlmostThere
|
||||
? `Almost there! Just 1 section to go for ${item.meeting_title || `Section ${item.target_section}`}.`
|
||||
: `You should be on ${item.target_section_label || `Section ${item.target_section}`} by the upcoming meeting.`}
|
||||
{item.days_until_meeting > 0 &&
|
||||
` (${item.days_until_meeting} day${item.days_until_meeting !== 1 ? "s" : ""} left)`}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import { SelectionPopover } from "./SelectionPopover";
|
||||
import { BookMarkersPanel } from "./BookMarkersPanel";
|
||||
import { BookmarkReaderRail } from "./BookmarkReaderRail";
|
||||
import { ResumeReadingButton } from "./ResumeReadingButton";
|
||||
import { ReadingPaceBanner } from "../notifications/ReadingPaceBanner";
|
||||
import type { MarkerEntry } from "@/types";
|
||||
|
||||
const ReaderToolbar = lazy(() => import("./ReaderToolbar"));
|
||||
@@ -130,6 +131,10 @@ export function EpubReadingView({ book, bookId, initialEpubLocation }: EpubReadi
|
||||
|
||||
return (
|
||||
<ReaderSuspenseShell theme={settings.theme}>
|
||||
<ReadingPaceBanner
|
||||
ebookId={bookId}
|
||||
currentSection={tocItems.length > 0 ? Math.max(1, Math.ceil((progress / 100) * tocItems.length)) : 0}
|
||||
/>
|
||||
<ReaderToolbar
|
||||
bookTitle={book.title}
|
||||
chapterTitle={chapterTitle || t("reader.reading")}
|
||||
|
||||
@@ -24,6 +24,7 @@ import { BookmarkReaderRail } from "./BookmarkReaderRail";
|
||||
import { ResumeReadingButton } from "./ResumeReadingButton";
|
||||
import { PdfLimitationsNotice } from "./PdfLimitationsNotice";
|
||||
import { PdfViewer } from "./PdfViewer";
|
||||
import { ReadingPaceBanner } from "../notifications/ReadingPaceBanner";
|
||||
import type { MarkerEntry } from "@/types";
|
||||
|
||||
const ReaderToolbar = lazy(() => import("./ReaderToolbar"));
|
||||
@@ -170,6 +171,10 @@ export function PdfReadingView({ book, bookId, initialAnchor }: PdfReadingViewPr
|
||||
|
||||
return (
|
||||
<ReaderSuspenseShell theme={settings.theme}>
|
||||
<ReadingPaceBanner
|
||||
ebookId={bookId}
|
||||
currentSection={currentPage}
|
||||
/>
|
||||
<ReaderToolbar
|
||||
bookTitle={book.title}
|
||||
chapterTitle={chapterTitle || t("reader.reading")}
|
||||
|
||||
@@ -19,6 +19,7 @@ import { useVoiceSearch } from "../hooks/useVoiceSearch";
|
||||
import { useMediaQuery, BREAKPOINTS } from "../hooks/useMediaQuery";
|
||||
import { SearchSuggestions } from "../components/search/SearchSuggestions";
|
||||
import { BookContextMenu } from "../components/BookContextMenu";
|
||||
import { PaceNotification } from "../components/notifications/PaceNotification";
|
||||
|
||||
interface FilterState {
|
||||
genre: string;
|
||||
@@ -256,6 +257,9 @@ export function LibraryPage() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Pace Notifications */}
|
||||
<PaceNotification />
|
||||
|
||||
{/* Search Bar */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||||
|
||||
@@ -2,7 +2,9 @@ import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18n-lite";
|
||||
import { booksApi } from "../api/books";
|
||||
import { groupsApi } from "../api/groups";
|
||||
import type { ReadingSettings } from "../types/book";
|
||||
import type { NotificationPreferences } from "../types/notifications";
|
||||
import type { SupportedLanguage } from "../locales";
|
||||
import { SimpleFormPageLayout } from "../components/layout/SimpleFormPageLayout";
|
||||
|
||||
@@ -17,6 +19,7 @@ export function SettingsPage() {
|
||||
const { t, language, setLanguage } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [settings, setSettings] = useState<ReadingSettings | null>(null);
|
||||
const [notifPrefs, setNotifPrefs] = useState<NotificationPreferences | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -24,7 +27,14 @@ export function SettingsPage() {
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try { const data = await booksApi.getSettings(); setSettings(data); }
|
||||
try {
|
||||
const [data, notifData] = await Promise.all([
|
||||
booksApi.getSettings(),
|
||||
groupsApi.getPreferences(),
|
||||
]);
|
||||
setSettings(data);
|
||||
setNotifPrefs(notifData);
|
||||
}
|
||||
catch (err) { setError(err instanceof Error ? err.message : t("settings.loadFailed")); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
@@ -32,9 +42,15 @@ export function SettingsPage() {
|
||||
}, [t]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!settings) return;
|
||||
if (!settings || !notifPrefs) return;
|
||||
setSaving(true); setError(null); setSuccess(false);
|
||||
try { await booksApi.updateSettings(settings); setSuccess(true); setTimeout(() => setSuccess(false), 2000); }
|
||||
try {
|
||||
await Promise.all([
|
||||
booksApi.updateSettings(settings),
|
||||
groupsApi.updatePreferences(notifPrefs),
|
||||
]);
|
||||
setSuccess(true); setTimeout(() => setSuccess(false), 2000);
|
||||
}
|
||||
catch (err) { setError(err instanceof Error ? err.message : t("settings.saveFailed")); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
@@ -85,6 +101,48 @@ export function SettingsPage() {
|
||||
</div>
|
||||
</>}
|
||||
|
||||
{notifPrefs && <>
|
||||
<div style={{ borderTop: "1px solid #e5e7eb", paddingTop: 20 }}>
|
||||
<h3 style={{ fontSize: 16, fontWeight: 600, color: "#1f2937", marginBottom: 16 }}>
|
||||
Notification Settings
|
||||
</h3>
|
||||
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<label style={{ display: "flex", alignItems: "center", gap: 10, fontSize: 14, color: "#374151", cursor: "pointer" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={notifPrefs.pace_notifications_enabled}
|
||||
onChange={(e) => setNotifPrefs({ ...notifPrefs, pace_notifications_enabled: e.target.checked })}
|
||||
style={{ width: 18, height: 18, cursor: "pointer" }}
|
||||
/>
|
||||
Enable pace notifications
|
||||
</label>
|
||||
|
||||
<label style={{ display: "flex", alignItems: "center", gap: 10, fontSize: 14, color: "#374151", cursor: "pointer" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={notifPrefs.reminder_enabled}
|
||||
onChange={(e) => setNotifPrefs({ ...notifPrefs, reminder_enabled: e.target.checked })}
|
||||
style={{ width: 18, height: 18, cursor: "pointer" }}
|
||||
/>
|
||||
Enable pre-meeting reminders (24h before)
|
||||
</label>
|
||||
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<label style={{ fontSize: 14, fontWeight: 600, color: "#555" }}>Notification frequency</label>
|
||||
<select
|
||||
value={notifPrefs.frequency}
|
||||
onChange={(e) => setNotifPrefs({ ...notifPrefs, frequency: e.target.value as NotificationPreferences["frequency"] })}
|
||||
style={{ padding: "10px 12px", borderRadius: 6, border: "1px solid #ddd", fontSize: 16, background: "#fff", outline: "none" }}
|
||||
>
|
||||
<option value="daily">Daily</option>
|
||||
<option value="weekly">Weekly</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>}
|
||||
|
||||
<button onClick={handleSave} disabled={saving} style={{ padding: "12px 24px", borderRadius: 8, border: "none", background: "#1a1a2e", color: "#fff", fontSize: 16, fontWeight: 600, cursor: "pointer", opacity: saving ? 0.6 : 1, marginTop: 8 }}>{saving ? t("common.saving") : t("settings.saveSettings")}</button>
|
||||
</div>
|
||||
</SimpleFormPageLayout>
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
export interface PaceStatus {
|
||||
meeting_id: number;
|
||||
meeting_title: string;
|
||||
scheduled_at: string;
|
||||
target_section: number;
|
||||
target_section_label: string;
|
||||
current_section: number;
|
||||
ahead_behind_delta: number;
|
||||
status: "ahead" | "behind" | "on_track" | "completed";
|
||||
days_until_meeting: number;
|
||||
sections_to_catch_up: number;
|
||||
ebook_id: number;
|
||||
ebook_title: string;
|
||||
}
|
||||
|
||||
export interface NotificationPreferences {
|
||||
pace_notifications_enabled: boolean;
|
||||
reminder_enabled: boolean;
|
||||
frequency: "daily" | "weekly";
|
||||
}
|
||||
|
||||
export interface MeetingReminder {
|
||||
meeting_id: number;
|
||||
meeting_title: string;
|
||||
scheduled_at: string;
|
||||
target_section: number;
|
||||
target_section_label: string;
|
||||
group_name: string;
|
||||
hours_until: number;
|
||||
ebook_id: number;
|
||||
ebook_title: string;
|
||||
}
|
||||
|
||||
export interface ReadingGroup {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
book: number | null;
|
||||
ebook: number | null;
|
||||
created_by: number;
|
||||
created_at: string;
|
||||
member_count: number;
|
||||
meeting_count: number;
|
||||
}
|
||||
|
||||
export interface GroupMeeting {
|
||||
id: number;
|
||||
group: number;
|
||||
group_name: string;
|
||||
title: string;
|
||||
scheduled_at: string;
|
||||
target_section: number;
|
||||
target_section_label: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface GroupMembership {
|
||||
id: number;
|
||||
user: number;
|
||||
user_email: string;
|
||||
group: number;
|
||||
group_name: string;
|
||||
role: "admin" | "member";
|
||||
joined_at: string;
|
||||
}
|
||||
Reference in New Issue
Block a user