added token usage and cost to UI, cleanup

This commit is contained in:
Drew Peifer 2026-02-12 03:26:10 -05:00
parent a85e96aad8
commit bffb11e5b0
15 changed files with 285 additions and 254 deletions

View file

@ -11,21 +11,21 @@ app.use(express.json());
app.post('/api/claude', async (req, res) => {
try {
const { messages, apiKey, model, maxTokens } = req.body;
console.log('=== Proxy received request ===');
console.log('Model:', model);
console.log('Max tokens:', maxTokens);
console.log('Messages count:', messages?.length);
console.log('API Key present:', !!apiKey);
const requestBody = {
model: model,
max_tokens: maxTokens,
messages: messages
};
console.log('Request body:', JSON.stringify(requestBody, null, 2));
const response = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
@ -35,17 +35,18 @@ app.post('/api/claude', async (req, res) => {
},
body: JSON.stringify(requestBody)
});
console.log('Claude API response status:', response.status);
if (!response.ok) {
const error = await response.text();
console.error('Claude API error:', error);
return res.status(response.status).json({ error });
}
const data = await response.json();
console.log('Claude API success, response content length:', data.content?.[0]?.text?.length);
console.log('Token usage:', data.usage);
res.json(data);
} catch (error) {
console.error('Proxy error:', error);

View file

@ -7,12 +7,12 @@ import { lightTheme, darkTheme } from './theme';
function App() {
const [isDark, setIsDark] = useState(true);
const debugMode = import.meta.env.REACT_APP_DEBUG_MODE === 'true';
return (
<ThemeProvider theme={isDark ? darkTheme : lightTheme}>
<CssBaseline />
<Dashboard
themeToggle={() => setIsDark(!isDark)}
<Dashboard
themeToggle={() => setIsDark(!isDark)}
isDark={isDark}
/>
{debugMode && <DebugPanel />}

View file

@ -9,42 +9,42 @@ function ChatInterface() {
const [loading, setLoading] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
const messagesEndRef = useRef(null);
const handleSend = async () => {
if (!input.trim() || loading) return;
const userMessage = { role: 'user', content: input };
setMessages(prev => [...prev, userMessage]);
setInput('');
setLoading(true);
setIsExpanded(true); // Expand chat when sending a message
try {
const response = await sendChatMessage(input, messages);
setMessages(prev => [...prev, { role: 'assistant', content: response }]);
} catch (error) {
console.error('Chat error:', error);
setMessages(prev => [...prev, {
role: 'assistant',
content: 'Sorry, I encountered an error. Please try again.'
setMessages(prev => [...prev, {
role: 'assistant',
content: 'Sorry, I encountered an error. Please try again.'
}]);
} finally {
setLoading(false);
}
};
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
return (
<Paper
<Paper
elevation={8}
sx={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
sx={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
height: isExpanded ? '40vh' : '64px',
transition: 'height 0.3s ease',
display: 'flex',
@ -53,14 +53,14 @@ function ChatInterface() {
}}
>
{/* Expand/Collapse Button */}
<Box sx={{
display: 'flex',
alignItems: 'center',
p: 1,
borderBottom: 1,
borderColor: 'divider'
<Box sx={{
display: 'flex',
alignItems: 'center',
p: 1,
borderBottom: 1,
borderColor: 'divider'
}}>
<IconButton
<IconButton
onClick={() => setIsExpanded(!isExpanded)}
size="small"
>
@ -70,7 +70,7 @@ function ChatInterface() {
Chat with Claude
</Typography>
</Box>
{/* Messages area - only visible when expanded */}
{isExpanded && (
<Box sx={{ flex: 1, overflow: 'auto', p: 2 }}>
@ -80,16 +80,16 @@ function ChatInterface() {
</Typography>
)}
{messages.map((msg, idx) => (
<Box
key={idx}
sx={{
mb: 2,
textAlign: msg.role === 'user' ? 'right' : 'left'
<Box
key={idx}
sx={{
mb: 2,
textAlign: msg.role === 'user' ? 'right' : 'left'
}}
>
<Typography
variant="body2"
sx={{
<Typography
variant="body2"
sx={{
display: 'inline-block',
bgcolor: msg.role === 'user' ? 'primary.main' : 'grey.800',
color: 'white',
@ -105,13 +105,13 @@ function ChatInterface() {
<div ref={messagesEndRef} />
</Box>
)}
{/* Input area - always visible */}
<Box sx={{
p: 2,
borderTop: isExpanded ? 1 : 0,
borderColor: 'divider',
display: 'flex',
<Box sx={{
p: 2,
borderTop: isExpanded ? 1 : 0,
borderColor: 'divider',
display: 'flex',
gap: 1,
backgroundColor: 'background.paper'
}}>
@ -129,8 +129,8 @@ function ChatInterface() {
disabled={loading}
size="small"
/>
<IconButton
onClick={handleSend}
<IconButton
onClick={handleSend}
disabled={loading || !input.trim()}
color="primary"
>

View file

@ -16,17 +16,18 @@ function Dashboard({ themeToggle, isDark }) {
const [sourceData, setSourceData] = useState({});
const [error, setError] = useState(null);
const [refreshing, setRefreshing] = useState(false);
const [tokenUsage, setTokenUsage] = useState(null);
useEffect(() => {
loadDashboard();
}, []);
async function loadDashboard(forceRefresh = false) {
try {
console.log('=== loadDashboard called ===');
console.log('Force refresh:', forceRefresh);
setError(null);
// Check if we already have today's briefing (unless forcing refresh)
if (!forceRefresh && hasTodaysBriefing()) {
console.log('Using cached briefing from today');
@ -36,53 +37,63 @@ function Dashboard({ themeToggle, isDark }) {
setLoading(false);
return;
}
console.log('Fetching fresh data...');
setLoadingStatus('Fetching weather and news sources...');
// Fetch all configured sources
const data = await fetchAllSources();
console.log('Fetched sources:', Object.keys(data.sources || {}));
console.log('Failed sources:', data.failed);
setSourceData(data.sources || {});
// If all sources failed, show error but continue
if (data.failed > 0 && data.successful === 0) {
setError('All data sources failed to load. Please check your internet connection and try again.');
} else if (data.failed > 0) {
setError(`${data.failed} data source(s) failed to load. Showing available data.`);
}
// Check if API key is configured
const apiKey = import.meta.env.REACT_APP_ANTHROPIC_API_KEY;
console.log('API key configured:', !!apiKey);
if (!apiKey || apiKey === 'sk-ant-your-key-here') {
console.warn('API key not configured properly');
setBriefing('Please add your Anthropic API key to .env.local to generate briefings.');
setLoading(false);
return;
}
// Get yesterday's briefing for comparison
const previousBriefing = getPreviousBriefing(1);
console.log('Previous briefing exists:', !!previousBriefing);
// Only generate briefing if we have some data
if (Object.keys(data.sources || {}).length > 0) {
console.log('Calling generateBriefing...');
setLoadingStatus('Generating your personalized briefing with AI...');
try {
// Generate today's briefing with Claude
const newBriefing = await generateBriefing(data, previousBriefing);
console.log('Briefing generated, length:', newBriefing?.length);
setBriefing(newBriefing);
const result = await generateBriefing(data, previousBriefing);
console.log('Briefing generated');
// Handle result object with text and usage
if (result && typeof result === 'object' && result.text) {
setBriefing(result.text);
setTokenUsage(result.usage);
console.log('Token usage:', result.usage);
} else if (typeof result === 'string') {
// Fallback for string responses
setBriefing(result);
}
// Save to localStorage
const today = new Date().toISOString().split('T')[0];
console.log('Saving briefing for:', today);
saveBriefing(today, newBriefing, data.sources);
const briefingText = result?.text || result;
saveBriefing(today, briefingText, data.sources);
} catch (briefingError) {
console.error('Failed to generate briefing:', briefingError);
setBriefing(`Unable to generate AI briefing: ${briefingError.message}\n\nYou can still view the data widgets below.`);
@ -90,7 +101,7 @@ function Dashboard({ themeToggle, isDark }) {
} else {
setBriefing('No data sources loaded successfully. Please check your configuration and try again.');
}
} catch (error) {
console.error('Dashboard load error:', error);
console.error('Error stack:', error.stack);
@ -100,12 +111,12 @@ function Dashboard({ themeToggle, isDark }) {
setRefreshing(false);
}
}
const handleRefresh = () => {
setRefreshing(true);
loadDashboard(true);
};
if (loading) {
return (
<Box display="flex" flexDirection="column" justifyContent="center" alignItems="center" minHeight="100vh" gap={2}>
@ -116,20 +127,20 @@ function Dashboard({ themeToggle, isDark }) {
</Box>
);
}
return (
<Box sx={{ minHeight: '100vh', pb: 10 }}>
{/* Top toolbar */}
<Box sx={{
position: 'fixed',
top: 0,
right: 0,
p: 2,
display: 'flex',
gap: 1,
zIndex: 100
<Box sx={{
position: 'fixed',
top: 0,
right: 0,
p: 2,
display: 'flex',
gap: 1,
zIndex: 100
}}>
<IconButton
<IconButton
onClick={() => {
localStorage.clear();
window.location.reload();
@ -139,56 +150,70 @@ function Dashboard({ themeToggle, isDark }) {
>
🗑
</IconButton>
<IconButton
<IconButton
onClick={handleRefresh}
disabled={refreshing}
title="Refresh data"
>
<Refresh />
</IconButton>
<IconButton
onClick={themeToggle}
<IconButton
onClick={themeToggle}
title={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
>
{isDark ? <Brightness7 /> : <Brightness4 />}
</IconButton>
</Box>
<Container maxWidth="xl" sx={{ pt: 4 }}>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
{tokenUsage && (
<Box sx={{ mb: 2, p: 2, bgcolor: 'background.paper', borderRadius: 1, border: '1px solid', borderColor: 'divider' }}>
<Typography variant="body2" color="text.secondary">
<strong>API Usage:</strong> {tokenUsage.input_tokens?.toLocaleString()} input + {tokenUsage.output_tokens?.toLocaleString()} output = {(tokenUsage.input_tokens + tokenUsage.output_tokens).toLocaleString()} total tokens
{' • '}
<strong>Cost:</strong> ${((tokenUsage.input_tokens * 0.003 / 1000) + (tokenUsage.output_tokens * 0.015 / 1000)).toFixed(4)}
{' '}
<Typography component="span" variant="caption" color="text.disabled">
(Claude Sonnet 4 pricing: $0.003/1K input, $0.015/1K output)
</Typography>
</Typography>
</Box>
)}
{/* Widget Grid - easily rearrangeable */}
<Box sx={{
display: 'flex',
flexWrap: 'wrap',
<Box sx={{
display: 'flex',
flexWrap: 'wrap',
gap: 2,
mb: 4
}}>
<BriefingWidget briefing={briefing} sx={{ flex: '1 1 100%' }} />
<WeatherWidget
data={sourceData.weather}
sx={{ flex: '1 1 300px' }}
<WeatherWidget
data={sourceData.weather}
sx={{ flex: '1 1 300px' }}
/>
{/* Render NewsWidget for each news source */}
{Object.entries(sourceData).map(([key, data]) => {
if (key === 'weather') return null;
return (
<NewsWidget
<NewsWidget
key={key}
data={data}
sx={{ flex: '1 1 400px' }}
data={data}
sx={{ flex: '1 1 400px' }}
/>
);
})}
</Box>
</Container>
{/* Fixed chat at bottom */}
<ChatInterface />
</Box>

View file

@ -5,7 +5,7 @@ import { exportAllData, importDummyData } from '../services/storageService';
function DebugPanel() {
const [dummyMode, setDummyMode] = useState(false);
const handleExport = () => {
const data = exportAllData();
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
@ -16,11 +16,11 @@ function DebugPanel() {
a.click();
URL.revokeObjectURL(url);
};
const handleImport = (event) => {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
try {
@ -35,40 +35,40 @@ function DebugPanel() {
};
reader.readAsText(file);
};
return (
<Paper sx={{ position: 'fixed', bottom: 80, right: 16, p: 2, width: 250, zIndex: 999 }}>
<Typography variant="h6" gutterBottom>Debug Tools</Typography>
<Button
fullWidth
startIcon={<Download />}
<Button
fullWidth
startIcon={<Download />}
onClick={handleExport}
sx={{ mb: 1 }}
variant="outlined"
>
Export Data
</Button>
<Button
fullWidth
<Button
fullWidth
component="label"
startIcon={<Upload />}
variant="outlined"
>
Import Dummy Data
<input
type="file"
hidden
accept=".json"
onChange={handleImport}
<input
type="file"
hidden
accept=".json"
onChange={handleImport}
/>
</Button>
{dummyMode && (
<Typography
variant="caption"
color="warning.main"
<Typography
variant="caption"
color="warning.main"
sx={{ mt: 1, display: 'block' }}
>
Using dummy data

View file

@ -3,15 +3,15 @@ import GenericWidget from './GenericWidget';
function BriefingWidget({ briefing, sx }) {
return (
<GenericWidget
title="Your Daily Briefing"
<GenericWidget
title="Your Daily Briefing"
sx={{ ...sx }}
>
<Box sx={{ mt: 1 }}>
{briefing ? (
<Typography
variant="body1"
sx={{
<Typography
variant="body1"
sx={{
whiteSpace: 'pre-line',
lineHeight: 1.7,
fontSize: '1.05rem'

View file

@ -2,13 +2,13 @@ import { Paper, Typography, Box } from '@mui/material';
function GenericWidget({ title, data, children, sx, ...props }) {
return (
<Paper
elevation={2}
sx={{
p: 2,
<Paper
elevation={2}
sx={{
p: 2,
minWidth: 300,
...sx
}}
...sx
}}
{...props}
>
<Typography variant="h6" gutterBottom>{title}</Typography>

View file

@ -16,7 +16,7 @@ function NewsWidget({ data, sx }) {
// Handle Reddit data
if (data.type === 'reddit' && data.posts) {
const subreddit = data.url?.match(/\/r\/([^\/\.]+)/)?.[1] || 'Reddit';
return (
<GenericWidget title={`r/${subreddit}`} sx={{ ...sx }}>
<List dense>
@ -49,7 +49,7 @@ function NewsWidget({ data, sx }) {
href={post.url}
target="_blank"
rel="noopener noreferrer"
sx={{
sx={{
textDecoration: 'none',
color: 'text.primary',
'&:hover': { textDecoration: 'underline' }
@ -65,17 +65,17 @@ function NewsWidget({ data, sx }) {
secondaryTypographyProps={{ component: 'div' }}
secondary={
<Box sx={{ display: 'flex', gap: 1, mt: 0.5 }}>
<Chip
icon={<TrendingUp />}
label={post.score}
size="small"
variant="outlined"
<Chip
icon={<TrendingUp />}
label={post.score}
size="small"
variant="outlined"
/>
<Chip
icon={<Comment />}
label={post.comments}
size="small"
variant="outlined"
<Chip
icon={<Comment />}
label={post.comments}
size="small"
variant="outlined"
/>
<Typography variant="caption" color="text.secondary" sx={{ ml: 1, alignSelf: 'center' }}>
by {post.author}
@ -124,7 +124,7 @@ function NewsWidget({ data, sx }) {
href={item.link}
target="_blank"
rel="noopener noreferrer"
sx={{
sx={{
textDecoration: 'none',
color: 'text.primary',
'&:hover': { textDecoration: 'underline' }
@ -144,10 +144,10 @@ function NewsWidget({ data, sx }) {
{item.pubDate && new Date(item.pubDate).toLocaleDateString()}
</Typography>
{item.description && (
<Typography
variant="caption"
color="text.secondary"
sx={{
<Typography
variant="caption"
color="text.secondary"
sx={{
display: '-webkit-box',
mt: 0.5,
overflow: 'hidden',

View file

@ -47,21 +47,21 @@ function WeatherWidget({ data, sx }) {
</Typography>
</Box>
</Box>
<Typography variant="body1" sx={{ mb: 2 }}>
{description}
</Typography>
<Stack direction="row" spacing={1}>
<Chip
label={`Humidity: ${humidity}%`}
size="small"
variant="outlined"
<Chip
label={`Humidity: ${humidity}%`}
size="small"
variant="outlined"
/>
<Chip
label={`Wind: ${windSpeed} ${windUnit}`}
size="small"
variant="outlined"
<Chip
label={`Wind: ${windSpeed} ${windUnit}`}
size="small"
variant="outlined"
/>
</Stack>

View file

@ -1,7 +1,7 @@
import {
ANTHROPIC_API_URL,
DEFAULT_CLAUDE_MODEL,
MAX_TOKENS
import {
ANTHROPIC_API_URL,
DEFAULT_CLAUDE_MODEL,
MAX_TOKENS
} from '../utils/constants';
/**
@ -12,22 +12,22 @@ import {
*/
async function callClaude(prompt, messages = []) {
const apiKey = import.meta.env.REACT_APP_ANTHROPIC_API_KEY;
console.log('=== callClaude function ===');
console.log('API Key configured:', !!apiKey);
console.log('API Key starts with:', apiKey?.substring(0, 15) + '...');
if (!apiKey || apiKey === 'sk-ant-your-key-here') {
console.error('Anthropic API key not configured');
return 'Please configure your Anthropic API key in .env.local';
}
const model = import.meta.env.REACT_APP_CLAUDE_MODEL || DEFAULT_CLAUDE_MODEL;
console.log('Using model:', model);
console.log('Max tokens:', MAX_TOKENS);
console.log('Prompt length:', prompt.length);
console.log('Previous messages:', messages.length);
try {
const requestBody = {
apiKey: apiKey,
@ -38,10 +38,10 @@ async function callClaude(prompt, messages = []) {
{ role: 'user', content: prompt }
]
};
console.log('Sending request to proxy...');
console.log('Request messages:', requestBody.messages.map(m => ({ role: m.role, contentLength: m.content.length })));
// Use local proxy server instead of direct API call
const response = await fetch('http://localhost:3001/api/claude', {
method: 'POST',
@ -50,19 +50,24 @@ async function callClaude(prompt, messages = []) {
},
body: JSON.stringify(requestBody)
});
console.log('Proxy response status:', response.status);
if (!response.ok) {
const error = await response.text();
console.error('Proxy error response:', error);
throw new Error(`Claude API error: ${response.status} - ${error}`);
}
const data = await response.json();
console.log('Response data structure:', Object.keys(data));
console.log('Response content:', data.content?.[0]?.text?.substring(0, 200) + '...');
return data.content[0].text;
console.log('Usage data:', data.usage);
return {
text: data.content[0].text,
usage: data.usage // { input_tokens, output_tokens }
};
} catch (error) {
console.error('Error calling Claude:', error);
throw error;
@ -77,14 +82,14 @@ async function callClaude(prompt, messages = []) {
*/
function buildBriefingPrompt(currentData, previousBriefing) {
const customInstructions = import.meta.env.REACT_APP_CLAUDE_INSTRUCTIONS;
let prompt = '';
// Prepend custom instructions if provided
if (customInstructions && customInstructions.trim()) {
prompt += `CUSTOM INSTRUCTIONS: ${customInstructions}\n\n`;
}
prompt += `You are creating a personalized daily briefing. Be conversational and highlight what's interesting.
CURRENT DATA:
@ -126,19 +131,19 @@ export async function generateBriefing(currentData, previousBriefing) {
if (!currentData || Object.keys(currentData.sources || currentData).length === 0) {
return 'No data available to generate briefing. Please check your data sources configuration.';
}
try {
const prompt = buildBriefingPrompt(currentData, previousBriefing);
const briefing = await callClaude(prompt);
return briefing;
const result = await callClaude(prompt);
return result; // Returns { text, usage }
} catch (error) {
console.error('Error generating briefing:', error);
// Return a fallback briefing
return `Good morning! I encountered an issue generating your personalized briefing today.
return `Good morning! I encountered an issue generating your personalized briefing today.
Error: ${error.message}
Please check your API key configuration and try again.`;
}
}
@ -153,23 +158,23 @@ export async function sendChatMessage(message, conversationHistory = []) {
if (!message || !message.trim()) {
throw new Error('Message cannot be empty');
}
// Build context from conversation history
const messages = conversationHistory.map(msg => ({
role: msg.role === 'user' ? 'user' : 'assistant',
content: msg.content
}));
// Add context about the dashboard
const contextPrompt = `You are a helpful assistant embedded in a personal dashboard application.
You have access to the user's daily briefing data and can help answer questions about news, weather,
const contextPrompt = `You are a helpful assistant embedded in a personal dashboard application.
You have access to the user's daily briefing data and can help answer questions about news, weather,
and other information sources configured in their dashboard. Be conversational and helpful.
User's message: ${message}`;
try {
const response = await callClaude(contextPrompt, messages);
return response;
const result = await callClaude(contextPrompt, messages);
return result.text; // For chat, just return text for now
} catch (error) {
console.error('Error in chat:', error);
return `I apologize, but I encountered an error: ${error.message}. Please try again.`;
@ -183,23 +188,23 @@ export async function sendChatMessage(message, conversationHistory = []) {
* @returns {Promise<Object>} Extracted content
*/
export async function extractContentFromHTML(html, url) {
const prompt = `Extract the main content from this HTML page.
const prompt = `Extract the main content from this HTML page.
Focus on the primary article, news content, or main information.
Ignore navigation, ads, and other peripheral content.
URL: ${url}
HTML (truncated to first 10000 chars):
${html.substring(0, 10000)}
Please provide a JSON response with:
- title: The main title
- summary: A brief summary (2-3 sentences)
- mainContent: The main text content (up to 500 words)
- metadata: Any relevant metadata (author, date, etc.)
Respond with valid JSON only.`;
try {
const response = await callClaude(prompt);
// Try to parse as JSON
@ -236,7 +241,7 @@ export async function summarizeData(data, dataType) {
${JSON.stringify(data, null, 2)}
Be concise and focus on what would be most relevant to someone checking their daily news.`;
try {
return await callClaude(prompt);
} catch (error) {

View file

@ -1,10 +1,10 @@
import { parseSource, standardizeSourceData } from './sourceParser';
import { getCachedData, cacheSourceData } from './storageService';
import { fetchWithCors } from '../utils/corsProxy';
import {
DEFAULT_DATA_SOURCES,
import {
DEFAULT_DATA_SOURCES,
WEATHER_API_BASE,
DEFAULT_REFRESH_INTERVAL
DEFAULT_REFRESH_INTERVAL
} from '../utils/constants';
/**
@ -33,23 +33,23 @@ function getCacheKey(url) {
*/
export async function fetchSingleSource(url) {
const cacheKey = getCacheKey(url);
// Check cache first
const cached = getCachedData(cacheKey);
if (cached) {
console.log(`Using cached data for ${url}`);
return cached;
}
try {
console.log(`Fetching fresh data from ${url}`);
const data = await parseSource(url);
const standardized = standardizeSourceData(data, url);
// Cache the result
const ttl = parseInt(import.meta.env.REACT_APP_REFRESH_INTERVAL) || DEFAULT_REFRESH_INTERVAL;
cacheSourceData(cacheKey, standardized, ttl);
return standardized;
} catch (error) {
console.error(`Error fetching ${url}:`, error);
@ -73,24 +73,24 @@ export async function fetchWeather(location) {
console.warn('No location configured for weather');
return null;
}
const cacheKey = `weather_${location.replace(/[^a-zA-Z0-9]/g, '_')}`;
// Check cache first
const cached = getCachedData(cacheKey);
if (cached) {
console.log(`Using cached weather data for ${location}`);
return cached;
}
try {
// Format location for wttr.in
const formattedLocation = encodeURIComponent(location);
const weatherUrl = `${WEATHER_API_BASE}/${formattedLocation}?format=j1`;
console.log(`Fetching weather for ${location}`);
const data = await fetchWithCors(weatherUrl);
// Standardize weather data
const weatherData = {
type: 'weather',
@ -100,11 +100,11 @@ export async function fetchWeather(location) {
nearestArea: data.nearest_area?.[0] || {},
fetchedAt: Date.now()
};
// Cache the result
const ttl = parseInt(import.meta.env.REACT_APP_REFRESH_INTERVAL) || DEFAULT_REFRESH_INTERVAL;
cacheSourceData(cacheKey, weatherData, ttl);
return weatherData;
} catch (error) {
console.error(`Error fetching weather for ${location}:`, error);
@ -120,22 +120,22 @@ export async function fetchAllSources() {
// Get configured sources from environment or use defaults
const sourcesEnv = import.meta.env.REACT_APP_DATA_SOURCES;
const sources = sourcesEnv ? parseEnvArray(sourcesEnv) : DEFAULT_DATA_SOURCES;
// Get location for weather
const location = import.meta.env.REACT_APP_LOCATION;
console.log('Fetching from sources:', sources);
console.log('Weather location:', location);
// Create fetch promises
const promises = sources.map(url =>
const promises = sources.map(url =>
fetchSingleSource(url).catch(error => ({
error: true,
url: url,
message: error.message
}))
);
// Add weather fetch if location is configured
if (location) {
promises.push(
@ -146,14 +146,14 @@ export async function fetchAllSources() {
}))
);
}
// Fetch all in parallel
const results = await Promise.allSettled(promises);
// Process results
const sourceData = {};
const errors = [];
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
const data = result.value;
@ -193,12 +193,12 @@ export async function fetchAllSources() {
});
}
});
// Log any errors
if (errors.length > 0) {
console.warn('Some sources failed to fetch:', errors);
}
return {
sources: sourceData,
errors: errors,
@ -233,7 +233,7 @@ export async function fetchHackerNewsStories(storyIds, limit = 10) {
const idsToFetch = storyIds.slice(0, limit);
const promises = idsToFetch.map(id => fetchHackerNewsStory(id));
const results = await Promise.allSettled(promises);
return results
.filter(r => r.status === 'fulfilled' && r.value)
.map(r => r.value);

View file

@ -8,13 +8,13 @@ import { RSS_TO_JSON_API } from '../utils/constants';
*/
export function detectSourceType(url) {
const urlLower = url.toLowerCase();
if (urlLower.endsWith('.json')) return 'json';
if (urlLower.endsWith('.rss') ||
urlLower.endsWith('.xml') ||
if (urlLower.endsWith('.rss') ||
urlLower.endsWith('.xml') ||
urlLower.includes('/feed') ||
urlLower.includes('rss')) return 'rss';
return 'html'; // Default to HTML scraping
}
@ -44,18 +44,18 @@ export async function parseRSS(url) {
// Use RSS to JSON service
const rssUrl = `${RSS_TO_JSON_API}?rss_url=${encodeURIComponent(url)}`;
const response = await fetch(rssUrl);
if (!response.ok) {
throw new Error(`RSS parse failed: ${response.status}`);
}
const data = await response.json();
// Check if RSS2JSON returned an error
if (data.status !== 'ok') {
throw new Error(data.message || 'RSS feed parsing failed');
}
return data;
} catch (error) {
console.error(`Error parsing RSS from ${url}:`, error);
@ -72,7 +72,7 @@ export async function parseRSS(url) {
export async function parseHTML(url, corsProxy) {
try {
const html = await fetchWithCors(url);
// Return structured data for Claude to process
return {
url: url,
@ -94,17 +94,17 @@ export async function parseHTML(url, corsProxy) {
*/
export async function parseSource(url, type = null) {
const sourceType = type || detectSourceType(url);
switch(sourceType) {
case 'json':
return await parseJSON(url);
case 'rss':
return await parseRSS(url);
case 'html':
return await parseHTML(url);
default:
throw new Error(`Unknown source type: ${sourceType}`);
}
@ -153,7 +153,7 @@ export function standardizeSourceData(data, sourceUrl) {
before: data.data.before
};
}
if (sourceUrl.includes('hacker-news') && Array.isArray(data)) {
// Hacker News top stories (just IDs)
return {
@ -163,7 +163,7 @@ export function standardizeSourceData(data, sourceUrl) {
fetchedAt: Date.now()
};
}
if (data.items && data.feed) {
// RSS feed format from rss2json
return {
@ -181,7 +181,7 @@ export function standardizeSourceData(data, sourceUrl) {
}))
};
}
// Default format for unknown sources
return {
type: 'unknown',

View file

@ -34,15 +34,15 @@ function saveStorage(storage) {
*/
export function saveBriefing(date, briefingText, sourceData) {
const storage = getStorage();
storage.briefings[date] = {
timestamp: Date.now(),
briefing: briefingText,
sources: sourceData
};
saveStorage(storage);
// Clean up old data after saving
cleanupOldData();
}
@ -57,7 +57,7 @@ export function getPreviousBriefing(daysAgo = 1) {
const date = new Date();
date.setDate(date.getDate() - daysAgo);
const dateKey = date.toISOString().split('T')[0];
return storage.briefings[dateKey] || null;
}
@ -69,13 +69,13 @@ export function getPreviousBriefing(daysAgo = 1) {
*/
export function cacheSourceData(sourceKey, data, ttlMinutes = CACHE_TTL) {
const storage = getStorage();
storage.cache[sourceKey] = {
data: data,
timestamp: Date.now(),
ttl: ttlMinutes * 60 * 1000 // Convert to milliseconds
};
saveStorage(storage);
}
@ -87,21 +87,21 @@ export function cacheSourceData(sourceKey, data, ttlMinutes = CACHE_TTL) {
export function getCachedData(sourceKey) {
const storage = getStorage();
const cached = storage.cache[sourceKey];
if (!cached) {
return null;
}
const now = Date.now();
const age = now - cached.timestamp;
if (age > cached.ttl) {
// Cache expired
delete storage.cache[sourceKey];
saveStorage(storage);
return null;
}
return cached.data;
}
@ -112,10 +112,10 @@ export function getCachedData(sourceKey) {
export function cleanupOldData(daysToKeep = null) {
const storage = getStorage();
const days = daysToKeep || parseInt(import.meta.env.REACT_APP_HISTORY_DAYS) || DEFAULT_HISTORY_DAYS;
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - days);
// Clean old briefings
if (storage.briefings) {
Object.keys(storage.briefings).forEach(date => {
@ -124,7 +124,7 @@ export function cleanupOldData(daysToKeep = null) {
}
});
}
// Clean expired cache entries
const now = Date.now();
if (storage.cache) {
@ -135,7 +135,7 @@ export function cleanupOldData(daysToKeep = null) {
}
});
}
saveStorage(storage);
}

View file

@ -8,7 +8,7 @@ import { DEFAULT_CORS_PROXY } from './constants';
*/
export async function fetchWithCors(url, options = {}) {
const proxy = import.meta.env.REACT_APP_CORS_PROXY || DEFAULT_CORS_PROXY;
try {
// Try direct fetch first
const response = await fetch(url, {
@ -19,11 +19,11 @@ export async function fetchWithCors(url, options = {}) {
'Accept': 'application/json',
}
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
return await response.json();
@ -33,15 +33,15 @@ export async function fetchWithCors(url, options = {}) {
} catch (error) {
// If CORS fails, try with proxy
console.warn(`CORS failed for ${url}, trying proxy...`, error.message);
try {
const proxiedUrl = `${proxy}${encodeURIComponent(url)}`;
const response = await fetch(proxiedUrl, options);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
return await response.json();
@ -82,7 +82,7 @@ export async function getAccessibleUrl(url) {
if (canAccess) {
return url;
}
const proxy = import.meta.env.REACT_APP_CORS_PROXY || DEFAULT_CORS_PROXY;
return `${proxy}${encodeURIComponent(url)}`;
}

View file

@ -5,7 +5,7 @@ import react from '@vitejs/plugin-react'
export default defineConfig(({ mode }) => {
// Load env file based on `mode` in the current working directory.
const env = loadEnv(mode, process.cwd(), '')
// Expose REACT_APP_ prefixed variables to import.meta.env
const processEnvValues = {
'process.env': Object.entries(env).reduce(
@ -18,7 +18,7 @@ export default defineConfig(({ mode }) => {
{},
)
}
return {
plugins: [react()],
define: {