added briefing copy btn, env vars for briefing length and support for multi briefings

This commit is contained in:
Drew Peifer 2026-02-24 07:23:32 -05:00
parent e330dc6950
commit dcf86a95fd
5 changed files with 151 additions and 9 deletions

View file

@ -4,8 +4,15 @@
A local-only React application that aggregates data from multiple sources and uses Claude AI to generate a personalized daily briefing. Features a persistent chat interface and configurable data widgets. Give it sources to parse, and generate a summary of the news you care about.
## Main Features:
- Customizable daily briefing (tone, sources, length, etc) with optional vocalization (read-aloud)
- Customizable daily briefing with advanced configuration options:
- Configurable briefing length (e.g., 200-300, 300-400 words)
- Keyword/topic emphasis for prioritizing specific subjects
- Per-row Claude instructions for customized tone per source
- Time-of-day adaptive tone (energizing mornings, reflective evenings)
- Optional vocalization with text-to-speech (read-aloud)
- Copy to clipboard and download as Markdown
- Changes since past briefings referenced in current briefing if relevant
- Briefing history widget with visualization of word count trends over time
- Claude cost and usage details for each briefing (Sonnet 4.5 = $.05-.10 avg per briefing, depending on sources used)
- Local storage of session information (no data sent to 3rd parties except Claude / open-meteo)
- Localized weather via open-meteo with charts
@ -81,6 +88,9 @@ All configuration is managed through environment variables in `.env.local`. Copy
| `REACT_APP_DATA_SOURCES` | Reddit tech/worldnews/science | JSON object for row-based layout or comma-separated URLs |
| `REACT_APP_CLAUDE_MODEL` | claude-sonnet-4-5-20250929 | Claude model identifier |
| `REACT_APP_CLAUDE_INSTRUCTIONS` | None | Custom instructions for briefing generation |
| `REACT_APP_BRIEFING_LENGTH` | 300-400 | Target word count for briefings (e.g., "200-300", "400-500") |
| `REACT_APP_BRIEFING_TOPICS` | None | Comma-separated keywords/topics to prioritize (e.g., "AI, climate, space") |
| `REACT_APP_ROW_INSTRUCTIONS` | None | JSON object with per-row Claude instructions (e.g., {"News":"be terse","Games":"be casual"}) |
| `REACT_APP_REFRESH_INTERVAL` | 15 | Cache TTL in minutes |
| `REACT_APP_HISTORY_DAYS` | 7 | Days of briefing history to retain |
| `REACT_APP_CORS_PROXY` | allorigins.win | CORS proxy service URL |

View file

@ -31,6 +31,18 @@ REACT_APP_CLAUDE_MODEL=claude-3-5-sonnet-20241022
# Example: "Focus on world news more than tech news. Use a concise and informative tone."
REACT_APP_CLAUDE_INSTRUCTIONS=
# Optional: Configurable briefing length (default: 300-400)
# Example: "200-300", "400-500", "150-200"
REACT_APP_BRIEFING_LENGTH=300-400
# Optional: Keywords/topics to prioritize in briefing (comma-separated)
# Example: "AI, climate change, space exploration"
REACT_APP_BRIEFING_TOPICS=
# Optional: Per-row instructions as JSON object (keys must match row names in DATA_SOURCES)
# Example: {"News":"be terse and factual","Games":"be casual and enthusiastic"}
REACT_APP_ROW_INSTRUCTIONS=
# Optional: Debug mode (shows raw data, enables dummy data)
REACT_APP_DEBUG_MODE=false

View file

@ -1,6 +1,6 @@
import { useState, useEffect, useRef } from 'react';
import { Skeleton, Box, IconButton, Collapse, FormControlLabel, Switch, ToggleButtonGroup, ToggleButton, Select, MenuItem, FormControl, InputLabel, Typography } from '@mui/material';
import { VolumeUp, PlayArrow, Pause, Settings, Stop } from '@mui/icons-material';
import { Skeleton, Box, IconButton, Collapse, FormControlLabel, Switch, ToggleButtonGroup, ToggleButton, Select, MenuItem, FormControl, InputLabel, Typography, Snackbar } from '@mui/material';
import { VolumeUp, PlayArrow, Pause, Settings, Stop, ContentCopy, Download } from '@mui/icons-material';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import GenericWidget from './GenericWidget';
@ -13,6 +13,8 @@ function BriefingWidget({ briefing, sx }) {
const [playbackSpeed, setPlaybackSpeed] = useState(1);
const [voices, setVoices] = useState([]);
const [selectedVoice, setSelectedVoice] = useState('');
const [snackbarOpen, setSnackbarOpen] = useState(false);
const [snackbarMessage, setSnackbarMessage] = useState('');
const utteranceRef = useRef(null);
// Load available voices
@ -155,6 +157,42 @@ function BriefingWidget({ briefing, sx }) {
setShowControls(!showControls);
};
const handleCopy = async () => {
if (!briefing) return;
try {
await navigator.clipboard.writeText(briefing);
setSnackbarMessage('Briefing copied to clipboard!');
setSnackbarOpen(true);
} catch (error) {
console.error('Failed to copy:', error);
setSnackbarMessage('Failed to copy briefing');
setSnackbarOpen(true);
}
};
const handleDownload = () => {
if (!briefing) return;
const blob = new Blob([briefing], { type: 'text/markdown' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const date = new Date().toISOString().split('T')[0];
a.download = `briefing-${date}.md`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
setSnackbarMessage('Briefing downloaded!');
setSnackbarOpen(true);
};
const handleSnackbarClose = () => {
setSnackbarOpen(false);
};
// Cleanup on unmount
useEffect(() => {
return () => {
@ -164,11 +202,25 @@ function BriefingWidget({ briefing, sx }) {
return (
<GenericWidget
title="Your Daily Briefing"
title={`Daily Briefing for ${new Date().toLocaleDateString()}`}
sx={{ ...sx }}
headerAction={
briefing && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<IconButton
size="small"
onClick={handleCopy}
title="Copy to clipboard"
>
<ContentCopy />
</IconButton>
<IconButton
size="small"
onClick={handleDownload}
title="Download as Markdown"
>
<Download />
</IconButton>
{!showControls && !isPlaying && (
<IconButton
size="small"
@ -336,6 +388,15 @@ function BriefingWidget({ briefing, sx }) {
</>
)}
</Box>
{/* Snackbar for copy/download feedback */}
<Snackbar
open={snackbarOpen}
autoHideDuration={3000}
onClose={handleSnackbarClose}
message={snackbarMessage}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
/>
</GenericWidget>
);
}

View file

@ -109,10 +109,28 @@ CURRENT DATA:
debugLog('Condensed data size:', JSON.stringify(condensedData).length);
debugLog('Size reduction:', Math.round((1 - JSON.stringify(condensedData).length / JSON.stringify(currentData).length) * 100) + '%');
// Add all condensed source data
// Get optional per-row instructions from env
let rowInstructions = {};
const rowInstructionsEnv = import.meta.env.REACT_APP_ROW_INSTRUCTIONS;
if (rowInstructionsEnv && rowInstructionsEnv.trim()) {
try {
rowInstructions = JSON.parse(rowInstructionsEnv);
debugLog('Parsed row instructions:', rowInstructions);
} catch (error) {
console.error('Failed to parse REACT_APP_ROW_INSTRUCTIONS:', error);
}
}
// Add all condensed source data with optional per-row instructions
Object.entries(condensedData).forEach(([source, data]) => {
if (data && !data.error) {
prompt += `\n${source.toUpperCase()}:\n${JSON.stringify(data, null, 2)}\n`;
// Check if there are specific instructions for this row
const rowInstruction = rowInstructions[source];
if (rowInstruction) {
prompt += `\n${source.toUpperCase()} [Instructions: ${rowInstruction}]:\n${JSON.stringify(data, null, 2)}\n`;
} else {
prompt += `\n${source.toUpperCase()}:\n${JSON.stringify(data, null, 2)}\n`;
}
}
});
@ -122,14 +140,42 @@ CURRENT DATA:
prompt += `\nHighlight what's changed, any follow-up stories, or interesting trends.`;
}
prompt += `\n\nCreate a friendly briefing (300-400 words) that:
- Starts with a warm greeting appropriate for the time of day
// Get configurable briefing length from env (default: 300-400 words)
const briefingLength = import.meta.env.REACT_APP_BRIEFING_LENGTH || '300-400';
// Get optional keyword/topic emphasis from env
const topicEmphasis = import.meta.env.REACT_APP_BRIEFING_TOPICS;
const topicInstruction = topicEmphasis && topicEmphasis.trim()
? `\n\nPRIORITY TOPICS: Pay special attention to and prioritize stories related to: ${topicEmphasis}\n`
: '';
// Detect time of day for tone adjustment
const hour = new Date().getHours();
let timeOfDay, toneGuidance;
if (hour >= 5 && hour < 12) {
timeOfDay = 'morning';
toneGuidance = 'Use an energizing, upbeat tone to help start the day. Focus on what lies ahead.';
} else if (hour >= 12 && hour < 17) {
timeOfDay = 'afternoon';
toneGuidance = 'Use a balanced, informative tone. Provide updates on what\'s happened so far today.';
} else if (hour >= 17 && hour < 21) {
timeOfDay = 'evening';
toneGuidance = 'Use a relaxed, reflective tone. Summarize the day\'s events and what to look forward to.';
} else {
timeOfDay = 'night';
toneGuidance = 'Use a calm, concise tone. Keep it brief and focus on the most important highlights.';
}
prompt += `\n\nCreate a friendly briefing (${briefingLength} words) that:
- Starts with a warm greeting appropriate for ${timeOfDay}
- ${toneGuidance}
- Highlights the most interesting items from the data
- Notes anything that's changed since yesterday (if applicable)
- Has personality and isn't just a dry list
- Includes relevant weather information if available
- Ends with something engaging or thought-provoking
${topicInstruction}
Focus on the most newsworthy and interesting items. Don't try to mention everything.
IMPORTANT: Provide your response as valid JSON with exactly two fields:

View file

@ -26,6 +26,19 @@ export function detectSourceType(url) {
*/
export async function parseJSON(url, corsProxy) {
try {
// Reddit's JSON API blocks unauthenticated browser CORS requests (returns 403).
// Route through the local proxy server, which fetches server-side with a proper
// User-Agent header that Reddit accepts.
if (url.includes('reddit.com')) {
const proxyUrl = `http://localhost:3001/api/scrape?url=${encodeURIComponent(url)}`;
const response = await fetch(proxyUrl);
if (!response.ok) {
throw new Error(`Local proxy returned ${response.status} for Reddit URL`);
}
const text = await response.text();
return JSON.parse(text);
}
const data = await fetchWithCors(url);
return data;
} catch (error) {