services now hooked up to UI

This commit is contained in:
Drew Peifer 2026-02-12 02:56:06 -05:00
parent 175517b7b3
commit 871351ddec
29 changed files with 2096 additions and 0 deletions

6
.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
.env.local
.DS_Store
node_modules/
dist/
package-lock.json
CLAUDE.md

24
claudash/.env.example Normal file
View file

@ -0,0 +1,24 @@
# REQUIRED: Anthropic API Key
REACT_APP_ANTHROPIC_API_KEY=sk-ant-your-key-here
# Data sources (comma-separated URLs)
# Supports: .json (auto-parsed), .rss/.xml (RSS feeds), plain URLs (scraped)
REACT_APP_DATA_SOURCES=https://www.reddit.com/r/technology.json,https://hacker-news.firebaseio.com/v0/topstories.json,https://www.reddit.com/r/worldnews.json
# Location for weather (supports "city, state, country" or zip code)
REACT_APP_LOCATION=harrisburg, pa, usa
# Optional: Custom CORS proxy (default: allorigins.win)
REACT_APP_CORS_PROXY=https://api.allorigins.win/raw?url=
# Optional: Refresh interval in minutes (default: 15)
REACT_APP_REFRESH_INTERVAL=15
# Optional: Days of history to keep (default: 7)
REACT_APP_HISTORY_DAYS=7
# Optional: Claude model (default: claude-sonnet-4-5-20250929)
REACT_APP_CLAUDE_MODEL=claude-3-5-sonnet-20241022
# Optional: Debug mode (shows raw data, enables dummy data)
REACT_APP_DEBUG_MODE=false

24
claudash/.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

16
claudash/README.md Normal file
View file

@ -0,0 +1,16 @@
# React + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.

29
claudash/eslint.config.js Normal file
View file

@ -0,0 +1,29 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{js,jsx}'],
extends: [
js.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
rules: {
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
},
},
])

13
claudash/index.html Normal file
View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>claudash</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

36
claudash/package.json Normal file
View file

@ -0,0 +1,36 @@
{
"name": "claudash",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"proxy": "node proxy-server.js",
"start": "npm run proxy & npm run dev",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^7.3.7",
"@mui/material": "^7.3.7",
"@mui/x-charts": "^8.27.0",
"cors": "^2.8.6",
"express": "^5.2.1",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"vite": "^4.5.3"
}
}

59
claudash/proxy-server.js Normal file
View file

@ -0,0 +1,59 @@
import express from 'express';
import cors from 'cors';
const app = express();
const PORT = 3001;
app.use(cors());
app.use(express.json());
// Proxy endpoint for Claude API
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: {
'Content-Type': 'application/json',
'x-api-key': apiKey,
'anthropic-version': '2023-06-01'
},
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);
res.json(data);
} catch (error) {
console.error('Proxy error:', error);
res.status(500).json({ error: error.message });
}
});
app.listen(PORT, () => {
console.log(`Proxy server running on http://localhost:${PORT}`);
console.log('This proxy allows the frontend to communicate with the Anthropic API');
});

1
claudash/public/vite.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

42
claudash/src/App.css Normal file
View file

@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

23
claudash/src/App.jsx Normal file
View file

@ -0,0 +1,23 @@
import { ThemeProvider, CssBaseline } from '@mui/material';
import { useState } from 'react';
import Dashboard from './components/Dashboard';
import DebugPanel from './components/DebugPanel';
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)}
isDark={isDark}
/>
{debugMode && <DebugPanel />}
</ThemeProvider>
);
}
export default App;

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4 KiB

View file

@ -0,0 +1,144 @@
import { useState, useRef, useEffect } from 'react';
import { Box, TextField, IconButton, Paper, Typography } from '@mui/material';
import { Send, ExpandLess, ExpandMore } from '@mui/icons-material';
import { sendChatMessage } from '../services/claudeService';
function ChatInterface() {
const [messages, setMessages] = useState([]);
const [input, setInput] = useState('');
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.'
}]);
} finally {
setLoading(false);
}
};
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
return (
<Paper
elevation={8}
sx={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
height: isExpanded ? '40vh' : '64px',
transition: 'height 0.3s ease',
display: 'flex',
flexDirection: 'column',
zIndex: 1000
}}
>
{/* Expand/Collapse Button */}
<Box sx={{
display: 'flex',
alignItems: 'center',
p: 1,
borderBottom: 1,
borderColor: 'divider'
}}>
<IconButton
onClick={() => setIsExpanded(!isExpanded)}
size="small"
>
{isExpanded ? <ExpandMore /> : <ExpandLess />}
</IconButton>
<Typography variant="body2" sx={{ ml: 1 }}>
Chat with Claude
</Typography>
</Box>
{/* Messages area - only visible when expanded */}
{isExpanded && (
<Box sx={{ flex: 1, overflow: 'auto', p: 2 }}>
{messages.length === 0 && (
<Typography color="text.secondary" variant="body2">
Ask me anything about your briefing or the news...
</Typography>
)}
{messages.map((msg, idx) => (
<Box
key={idx}
sx={{
mb: 2,
textAlign: msg.role === 'user' ? 'right' : 'left'
}}
>
<Typography
variant="body2"
sx={{
display: 'inline-block',
bgcolor: msg.role === 'user' ? 'primary.main' : 'grey.800',
color: 'white',
p: 1.5,
borderRadius: 2,
maxWidth: '70%'
}}
>
{msg.content}
</Typography>
</Box>
))}
<div ref={messagesEndRef} />
</Box>
)}
{/* Input area - always visible */}
<Box sx={{
p: 2,
borderTop: isExpanded ? 1 : 0,
borderColor: 'divider',
display: 'flex',
gap: 1,
backgroundColor: 'background.paper'
}}>
<TextField
fullWidth
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyPress={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}}
placeholder="Ask Claude anything..."
disabled={loading}
size="small"
/>
<IconButton
onClick={handleSend}
disabled={loading || !input.trim()}
color="primary"
>
<Send />
</IconButton>
</Box>
</Paper>
);
}
export default ChatInterface;

View file

@ -0,0 +1,173 @@
import { useState, useEffect } from 'react';
import { Box, Container, CircularProgress, IconButton, Alert } from '@mui/material';
import { Brightness4, Brightness7, Refresh } from '@mui/icons-material';
import { fetchAllSources } from '../services/dataFetcher';
import { generateBriefing } from '../services/claudeService';
import { saveBriefing, getPreviousBriefing, hasTodaysBriefing, getTodaysBriefing } from '../services/storageService';
import BriefingWidget from './widgets/BriefingWidget';
import WeatherWidget from './widgets/WeatherWidget';
import NewsWidget from './widgets/NewsWidget';
import ChatInterface from './ChatInterface';
function Dashboard({ themeToggle, isDark }) {
const [loading, setLoading] = useState(true);
const [briefing, setBriefing] = useState('');
const [sourceData, setSourceData] = useState({});
const [error, setError] = useState(null);
const [refreshing, setRefreshing] = useState(false);
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');
const todaysBriefing = getTodaysBriefing();
setBriefing(todaysBriefing.briefing);
setSourceData(todaysBriefing.sources || {});
setLoading(false);
return;
}
console.log('Fetching fresh data...');
// Fetch all configured sources
const data = await fetchAllSources();
console.log('Fetched sources:', Object.keys(data.sources || {}));
setSourceData(data.sources || {});
// 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);
console.log('Calling generateBriefing...');
// Generate today's briefing with Claude
const newBriefing = await generateBriefing(data, previousBriefing);
console.log('Briefing generated, length:', newBriefing?.length);
setBriefing(newBriefing);
// Save to localStorage
const today = new Date().toISOString().split('T')[0];
console.log('Saving briefing for:', today);
saveBriefing(today, newBriefing, data.sources);
} catch (error) {
console.error('Dashboard load error:', error);
console.error('Error stack:', error.stack);
setError(`Failed to load dashboard: ${error.message}`);
} finally {
setLoading(false);
setRefreshing(false);
}
}
const handleRefresh = () => {
setRefreshing(true);
loadDashboard(true);
};
if (loading) {
return (
<Box display="flex" justifyContent="center" alignItems="center" minHeight="100vh">
<CircularProgress />
</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
}}>
<IconButton
onClick={() => {
localStorage.clear();
window.location.reload();
}}
title="Clear all cache and reload"
color="error"
>
🗑
</IconButton>
<IconButton
onClick={handleRefresh}
disabled={refreshing}
title="Refresh data"
>
<Refresh />
</IconButton>
<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>
)}
{/* Widget Grid - easily rearrangeable */}
<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' }}
/>
{/* Render NewsWidget for each news source */}
{Object.entries(sourceData).map(([key, data]) => {
if (key === 'weather') return null;
return (
<NewsWidget
key={key}
data={data}
sx={{ flex: '1 1 400px' }}
/>
);
})}
</Box>
</Container>
{/* Fixed chat at bottom */}
<ChatInterface />
</Box>
);
}
export default Dashboard;

View file

@ -0,0 +1,81 @@
import { useState } from 'react';
import { Button, Box, Paper, Typography } from '@mui/material';
import { Download, Upload } from '@mui/icons-material';
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' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `claudash-debug-${new Date().toISOString()}.json`;
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 {
const data = JSON.parse(e.target.result);
importDummyData(data);
setDummyMode(true);
window.location.reload();
} catch (error) {
console.error('Error importing data:', error);
alert('Failed to import data. Please check the file format.');
}
};
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 />}
onClick={handleExport}
sx={{ mb: 1 }}
variant="outlined"
>
Export Data
</Button>
<Button
fullWidth
component="label"
startIcon={<Upload />}
variant="outlined"
>
Import Dummy Data
<input
type="file"
hidden
accept=".json"
onChange={handleImport}
/>
</Button>
{dummyMode && (
<Typography
variant="caption"
color="warning.main"
sx={{ mt: 1, display: 'block' }}
>
Using dummy data
</Typography>
)}
</Paper>
);
}
export default DebugPanel;

View file

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

View file

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

View file

@ -0,0 +1,157 @@
import { Typography, Box, Link, Chip, List, ListItem, ListItemText } from '@mui/material';
import { OpenInNew, Comment, TrendingUp } from '@mui/icons-material';
import GenericWidget from './GenericWidget';
function NewsWidget({ data, sx }) {
if (!data || data.error) {
return (
<GenericWidget title="News" sx={{ opacity: 0.5, ...sx }}>
<Typography color="text.secondary">
{data?.message || 'News data unavailable'}
</Typography>
</GenericWidget>
);
}
// 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>
{data.posts.slice(0, 5).map((post, index) => (
<ListItem key={index} disablePadding sx={{ mb: 1 }}>
<ListItemText
primary={
<Box sx={{ display: 'flex', alignItems: 'start', gap: 1 }}>
<Link
href={post.url}
target="_blank"
rel="noopener noreferrer"
sx={{
textDecoration: 'none',
color: 'text.primary',
'&:hover': { textDecoration: 'underline' }
}}
>
<Typography variant="body2" sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{post.title}
<OpenInNew sx={{ fontSize: 14 }} />
</Typography>
</Link>
</Box>
}
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={<Comment />}
label={post.comments}
size="small"
variant="outlined"
/>
<Typography variant="caption" color="text.secondary" sx={{ ml: 1, alignSelf: 'center' }}>
by {post.author}
</Typography>
</Box>
}
/>
</ListItem>
))}
</List>
</GenericWidget>
);
}
// Handle RSS feed data
if (data.type === 'rss' && data.items) {
return (
<GenericWidget title={data.feedTitle || 'RSS Feed'} sx={{ ...sx }}>
<List dense>
{data.items.slice(0, 5).map((item, index) => (
<ListItem key={index} disablePadding sx={{ mb: 1 }}>
<ListItemText
primary={
<Link
href={item.link}
target="_blank"
rel="noopener noreferrer"
sx={{
textDecoration: 'none',
color: 'text.primary',
'&:hover': { textDecoration: 'underline' }
}}
>
<Typography variant="body2" sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{item.title}
<OpenInNew sx={{ fontSize: 14 }} />
</Typography>
</Link>
}
secondaryTypographyProps={{ component: 'div' }}
secondary={
<Box>
<Typography variant="caption" color="text.secondary">
{item.pubDate && new Date(item.pubDate).toLocaleDateString()}
</Typography>
{item.description && (
<Typography
variant="caption"
color="text.secondary"
sx={{
display: '-webkit-box',
mt: 0.5,
overflow: 'hidden',
textOverflow: 'ellipsis',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
}}
>
{item.description.replace(/<[^>]*>?/gm, '')}
</Typography>
)}
</Box>
}
/>
</ListItem>
))}
</List>
</GenericWidget>
);
}
// Handle Hacker News data
if (data.type === 'hackernews' && data.storyIds) {
return (
<GenericWidget title="Hacker News" sx={{ ...sx }}>
<Typography variant="body2" color="text.secondary">
{data.storyIds.length} top stories loaded
</Typography>
<Typography variant="caption" color="text.secondary">
(Story details will load when API is configured)
</Typography>
</GenericWidget>
);
}
// Default fallback for unknown data types
return (
<GenericWidget title="News" sx={{ ...sx }}>
<Typography variant="body2" color="text.secondary">
Data format not recognized
</Typography>
<Typography variant="caption" color="text.secondary">
{JSON.stringify(data).substring(0, 100)}...
</Typography>
</GenericWidget>
);
}
export default NewsWidget;

View file

@ -0,0 +1,92 @@
import { Typography, Box, Chip, Stack } from '@mui/material';
import { Cloud, WbSunny, Thunderstorm, AcUnit } from '@mui/icons-material';
import GenericWidget from './GenericWidget';
function WeatherWidget({ data, sx }) {
const getWeatherIcon = (description) => {
const desc = description?.toLowerCase() || '';
if (desc.includes('sun') || desc.includes('clear')) return <WbSunny />;
if (desc.includes('cloud') || desc.includes('overcast')) return <Cloud />;
if (desc.includes('rain') || desc.includes('storm')) return <Thunderstorm />;
if (desc.includes('snow') || desc.includes('ice')) return <AcUnit />;
return <Cloud />;
};
if (!data || data.error) {
return (
<GenericWidget title="Weather" sx={{ opacity: 0.5, ...sx }}>
<Typography color="text.secondary">
{data?.message || 'Weather data unavailable'}
</Typography>
</GenericWidget>
);
}
const current = data.current || {};
const location = data.nearestArea?.areaName?.[0]?.value || data.location || 'Unknown';
const temp = current.temp_F || current.temp_C || '?';
const tempUnit = current.temp_F ? '°F' : '°C';
const description = current.weatherDesc?.[0]?.value || 'No description';
const humidity = current.humidity || '?';
const windSpeed = current.windspeedMiles || current.windspeedKmph || '?';
const windUnit = current.windspeedMiles ? 'mph' : 'km/h';
return (
<GenericWidget title={`Weather - ${location}`} sx={{ ...sx }}>
<Box sx={{ mt: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<Box sx={{ mr: 2 }}>
{getWeatherIcon(description)}
</Box>
<Box>
<Typography variant="h3" component="span">
{temp}
</Typography>
<Typography variant="h5" component="span" color="text.secondary">
{tempUnit}
</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={`Wind: ${windSpeed} ${windUnit}`}
size="small"
variant="outlined"
/>
</Stack>
{data.forecast && data.forecast.length > 0 && (
<Box sx={{ mt: 2 }}>
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
Forecast
</Typography>
<Stack direction="row" spacing={1}>
{data.forecast.slice(0, 3).map((day, index) => (
<Box key={index} sx={{ textAlign: 'center' }}>
<Typography variant="caption" display="block">
Day {index + 1}
</Typography>
<Typography variant="body2">
{day.maxtempF || day.maxtempC}° / {day.mintempF || day.mintempC}°
</Typography>
</Box>
))}
</Stack>
</Box>
)}
</Box>
</GenericWidget>
);
}
export default WeatherWidget;

68
claudash/src/index.css Normal file
View file

@ -0,0 +1,68 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}

9
claudash/src/main.jsx Normal file
View file

@ -0,0 +1,9 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

View file

@ -0,0 +1,237 @@
import {
ANTHROPIC_API_URL,
DEFAULT_CLAUDE_MODEL,
MAX_TOKENS
} from '../utils/constants';
/**
* Make a request to Claude API
* @param {string} prompt - The prompt to send
* @param {Array} messages - Optional message history
* @returns {Promise<string>} Claude's response
*/
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,
model: model,
maxTokens: MAX_TOKENS,
messages: [
...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',
headers: {
'Content-Type': 'application/json',
},
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;
} catch (error) {
console.error('Error calling Claude:', error);
throw error;
}
}
/**
* Build briefing prompt with memory
* @param {Object} currentData - Current source data
* @param {Object} previousBriefing - Previous briefing object
* @returns {string} The prompt
*/
function buildBriefingPrompt(currentData, previousBriefing) {
let prompt = `You are creating a personalized daily briefing. Be conversational and highlight what's interesting.
CURRENT DATA:
`;
// Add all current source data
Object.entries(currentData.sources || currentData).forEach(([source, data]) => {
if (data && !data.error) {
prompt += `\n${source.toUpperCase()}:\n${JSON.stringify(data, null, 2)}\n`;
}
});
// Add historical context if available
if (previousBriefing && previousBriefing.briefing) {
prompt += `\n\nYESTERDAY'S BRIEFING:\n${previousBriefing.briefing}\n`;
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
- 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
Focus on the most newsworthy and interesting items. Don't try to mention everything.`;
return prompt;
}
/**
* Generate daily briefing
* @param {Object} currentData - Current data from all sources
* @param {Object} previousBriefing - Previous briefing for comparison
* @returns {Promise<string>} Generated briefing text
*/
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;
} catch (error) {
console.error('Error generating briefing:', error);
// Return a fallback briefing
return `Good morning! I encountered an issue generating your personalized briefing today.
Error: ${error.message}
Please check your API key configuration and try again.`;
}
}
/**
* Send a chat message to Claude
* @param {string} message - User's message
* @param {Array} conversationHistory - Previous messages
* @returns {Promise<string>} Claude's response
*/
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,
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;
} catch (error) {
console.error('Error in chat:', error);
return `I apologize, but I encountered an error: ${error.message}. Please try again.`;
}
}
/**
* Extract meaningful content from HTML using Claude
* @param {string} html - The HTML content
* @param {string} url - The source URL
* @returns {Promise<Object>} Extracted content
*/
export async function extractContentFromHTML(html, url) {
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
try {
return JSON.parse(response);
} catch {
// If not valid JSON, return structured response
return {
title: 'Content from ' + url,
summary: response.substring(0, 200),
mainContent: response,
metadata: { url, extractedAt: Date.now() }
};
}
} catch (error) {
console.error('Error extracting HTML content:', error);
return {
error: true,
message: error.message,
url: url
};
}
}
/**
* Generate a summary of specific data
* @param {Object} data - Data to summarize
* @param {string} dataType - Type of data (reddit, news, etc.)
* @returns {Promise<string>} Summary
*/
export async function summarizeData(data, dataType) {
const prompt = `Summarize this ${dataType} data in 2-3 sentences, highlighting the most interesting or important items:
${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) {
console.error(`Error summarizing ${dataType}:`, error);
return `Unable to summarize ${dataType} data.`;
}
}

View file

@ -0,0 +1,231 @@
import { parseSource, standardizeSourceData } from './sourceParser';
import { getCachedData, cacheSourceData } from './storageService';
import { fetchWithCors } from '../utils/corsProxy';
import {
DEFAULT_DATA_SOURCES,
WEATHER_API_BASE,
DEFAULT_REFRESH_INTERVAL
} from '../utils/constants';
/**
* Parse environment variable array
* @param {string} envValue - Comma-separated values
* @returns {Array} Parsed array
*/
function parseEnvArray(envValue) {
if (!envValue) return [];
return envValue.split(',').map(s => s.trim()).filter(Boolean);
}
/**
* Generate a cache key from URL
* @param {string} url - The URL
* @returns {string} Cache key
*/
function getCacheKey(url) {
return url.replace(/[^a-zA-Z0-9]/g, '_').substring(0, 50);
}
/**
* Fetch single source with caching
* @param {string} url - Source URL
* @returns {Promise<Object>} Source data
*/
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);
// Try to return cached data even if expired
const expiredCache = getCachedData(cacheKey);
if (expiredCache) {
console.log(`Returning expired cache for ${url} due to fetch error`);
return expiredCache;
}
throw error;
}
}
/**
* Fetch weather data from wttr.in
* @param {string} location - Location string (city, zip, etc.)
* @returns {Promise<Object>} Weather data
*/
export async function fetchWeather(location) {
if (!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',
location: location,
current: data.current_condition?.[0] || {},
forecast: data.weather || [],
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);
return null;
}
}
/**
* Fetch from all configured sources
* @returns {Promise<Object>} Object with all source data
*/
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 =>
fetchSingleSource(url).catch(error => ({
error: true,
url: url,
message: error.message
}))
);
// Add weather fetch if location is configured
if (location) {
promises.push(
fetchWeather(location).catch(error => ({
error: true,
type: 'weather',
message: error.message
}))
);
}
// 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;
if (data.error) {
errors.push(data);
} else {
// Assign to appropriate key
if (data.type === 'weather') {
sourceData.weather = data;
} else if (data.type === 'reddit') {
const key = `reddit_${Object.keys(sourceData).filter(k => k.startsWith('reddit')).length}`;
sourceData[key] = data;
} else if (data.type === 'hackernews') {
sourceData.hackernews = data;
} else if (data.type === 'rss') {
const key = `rss_${Object.keys(sourceData).filter(k => k.startsWith('rss')).length}`;
sourceData[key] = data;
} else {
const key = `source_${index}`;
sourceData[key] = data;
}
}
} else {
errors.push({
error: true,
index: index,
reason: result.reason?.message || 'Unknown error'
});
}
});
// Log any errors
if (errors.length > 0) {
console.warn('Some sources failed to fetch:', errors);
}
return {
sources: sourceData,
errors: errors,
fetchedAt: Date.now(),
successful: Object.keys(sourceData).length,
failed: errors.length
};
}
/**
* Fetch a specific Hacker News story by ID
* @param {number} storyId - Story ID
* @returns {Promise<Object>} Story data
*/
export async function fetchHackerNewsStory(storyId) {
try {
const url = `https://hacker-news.firebaseio.com/v0/item/${storyId}.json`;
return await fetchWithCors(url);
} catch (error) {
console.error(`Error fetching HN story ${storyId}:`, error);
return null;
}
}
/**
* Fetch multiple Hacker News stories
* @param {Array<number>} storyIds - Array of story IDs
* @param {number} limit - Maximum number of stories to fetch
* @returns {Promise<Array>} Array of story data
*/
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

@ -0,0 +1,192 @@
import { fetchWithCors } from '../utils/corsProxy';
import { RSS_TO_JSON_API } from '../utils/constants';
/**
* Determine source type from URL
* @param {string} url - The URL to analyze
* @returns {string} - 'json' | 'rss' | 'html'
*/
export function detectSourceType(url) {
const urlLower = url.toLowerCase();
if (urlLower.endsWith('.json')) return 'json';
if (urlLower.endsWith('.rss') ||
urlLower.endsWith('.xml') ||
urlLower.includes('/feed') ||
urlLower.includes('rss')) return 'rss';
return 'html'; // Default to HTML scraping
}
/**
* Parse JSON endpoint
* @param {string} url - The JSON URL
* @param {string} corsProxy - CORS proxy URL
* @returns {Promise<any>} - Parsed JSON data
*/
export async function parseJSON(url, corsProxy) {
try {
const data = await fetchWithCors(url);
return data;
} catch (error) {
console.error(`Error parsing JSON from ${url}:`, error);
throw error;
}
}
/**
* Parse RSS feed
* @param {string} url - The RSS feed URL
* @returns {Promise<any>} - Parsed RSS data
*/
export async function parseRSS(url) {
try {
// 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);
throw error;
}
}
/**
* Parse HTML content (fetch and prepare for Claude extraction)
* @param {string} url - The HTML page URL
* @param {string} corsProxy - CORS proxy URL
* @returns {Promise<Object>} - Object with URL and HTML content
*/
export async function parseHTML(url, corsProxy) {
try {
const html = await fetchWithCors(url);
// Return structured data for Claude to process
return {
url: url,
html: typeof html === 'string' ? html : JSON.stringify(html),
type: 'html',
timestamp: Date.now()
};
} catch (error) {
console.error(`Error fetching HTML from ${url}:`, error);
throw error;
}
}
/**
* Parse a source based on its detected type
* @param {string} url - The URL to parse
* @param {string} type - Optional type override
* @returns {Promise<any>} - Parsed data
*/
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}`);
}
}
/**
* Extract meaningful content from HTML using Claude
* This will be called later when Claude service is integrated
* @param {string} html - The HTML content
* @param {string} url - The source URL
* @returns {Promise<Object>} - Extracted content
*/
export async function extractWithClaude(html, url) {
// This function will be implemented when Claude service is ready
// For now, return a placeholder structure
return {
url: url,
content: html.substring(0, 5000), // Limit content for now
extractedAt: Date.now(),
needsClaudeProcessing: true
};
}
/**
* Clean and standardize source data
* @param {any} data - Raw source data
* @param {string} sourceUrl - The source URL
* @returns {Object} - Standardized data object
*/
export function standardizeSourceData(data, sourceUrl) {
// Detect the source and format accordingly
if (sourceUrl.includes('reddit.com') && data.data) {
// Reddit JSON format
return {
type: 'reddit',
url: sourceUrl,
posts: data.data.children?.map(child => ({
title: child.data.title,
url: child.data.url,
author: child.data.author,
score: child.data.score,
comments: child.data.num_comments,
created: child.data.created_utc
})) || [],
after: data.data.after,
before: data.data.before
};
}
if (sourceUrl.includes('hacker-news') && Array.isArray(data)) {
// Hacker News top stories (just IDs)
return {
type: 'hackernews',
url: sourceUrl,
storyIds: data.slice(0, 30), // Get top 30 stories
fetchedAt: Date.now()
};
}
if (data.items && data.feed) {
// RSS feed format from rss2json
return {
type: 'rss',
url: sourceUrl,
feedTitle: data.feed.title,
feedUrl: data.feed.url,
items: data.items.map(item => ({
title: item.title,
link: item.link,
description: item.description,
pubDate: item.pubDate,
author: item.author,
categories: item.categories
}))
};
}
// Default format for unknown sources
return {
type: 'unknown',
url: sourceUrl,
data: data,
fetchedAt: Date.now()
};
}

View file

@ -0,0 +1,194 @@
import { STORAGE_KEY, DEFAULT_HISTORY_DAYS, CACHE_TTL } from '../utils/constants';
/**
* Get the storage object from localStorage
* @returns {Object} The storage object
*/
function getStorage() {
try {
const stored = localStorage.getItem(STORAGE_KEY);
return stored ? JSON.parse(stored) : { briefings: {}, cache: {} };
} catch (error) {
console.error('Error reading from localStorage:', error);
return { briefings: {}, cache: {} };
}
}
/**
* Save the storage object to localStorage
* @param {Object} storage - The storage object to save
*/
function saveStorage(storage) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(storage));
} catch (error) {
console.error('Error saving to localStorage:', error);
}
}
/**
* Save today's briefing
* @param {string} date - Date in YYYY-MM-DD format
* @param {string} briefingText - The generated briefing text
* @param {Object} sourceData - Raw data from all sources
*/
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();
}
/**
* Get a previous briefing
* @param {number} daysAgo - How many days ago
* @returns {Object|null} The briefing object or null if not found
*/
export function getPreviousBriefing(daysAgo = 1) {
const storage = getStorage();
const date = new Date();
date.setDate(date.getDate() - daysAgo);
const dateKey = date.toISOString().split('T')[0];
return storage.briefings[dateKey] || null;
}
/**
* Cache fetched source data with timestamp
* @param {string} sourceKey - Unique key for the source
* @param {any} data - Data to cache
* @param {number} ttlMinutes - Time to live in minutes
*/
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);
}
/**
* Get cached data if still fresh
* @param {string} sourceKey - Unique key for the source
* @returns {any|null} Cached data or null if expired/not found
*/
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;
}
/**
* Clean up old data (keep only last N days)
* @param {number} daysToKeep - Number of days to keep
*/
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 => {
if (new Date(date) < cutoffDate) {
delete storage.briefings[date];
}
});
}
// Clean expired cache entries
const now = Date.now();
if (storage.cache) {
Object.keys(storage.cache).forEach(key => {
const cached = storage.cache[key];
if (cached && (now - cached.timestamp) > cached.ttl) {
delete storage.cache[key];
}
});
}
saveStorage(storage);
}
/**
* Export all data for debugging
* @returns {Object} All stored data
*/
export function exportAllData() {
return getStorage();
}
/**
* Import dummy data (dev mode)
* @param {Object} jsonData - Data to import
*/
export function importDummyData(jsonData) {
if (jsonData && typeof jsonData === 'object') {
saveStorage(jsonData);
}
}
/**
* Clear all stored data
*/
export function clearAllData() {
localStorage.removeItem(STORAGE_KEY);
}
/**
* Get all briefings
* @returns {Object} All briefings
*/
export function getAllBriefings() {
const storage = getStorage();
return storage.briefings || {};
}
/**
* Check if we have a briefing for today
* @returns {boolean} True if today's briefing exists
*/
export function hasTodaysBriefing() {
const storage = getStorage();
const today = new Date().toISOString().split('T')[0];
return !!storage.briefings[today];
}
/**
* Get today's briefing
* @returns {Object|null} Today's briefing or null
*/
export function getTodaysBriefing() {
const storage = getStorage();
const today = new Date().toISOString().split('T')[0];
return storage.briefings[today] || null;
}

32
claudash/src/theme.js Normal file
View file

@ -0,0 +1,32 @@
import { createTheme } from '@mui/material/styles';
const sharedTheme = {
typography: {
fontFamily: '"Inter", "Roboto", "Helvetica", "Arial", sans-serif',
},
shape: {
borderRadius: 8,
},
};
export const darkTheme = createTheme({
...sharedTheme,
palette: {
mode: 'dark',
primary: { main: '#90caf9' },
secondary: { main: '#f48fb1' },
background: {
default: '#0a1929',
paper: '#1e2a35',
},
},
});
export const lightTheme = createTheme({
...sharedTheme,
palette: {
mode: 'light',
primary: { main: '#1976d2' },
secondary: { main: '#dc004e' },
},
});

View file

@ -0,0 +1,30 @@
// App constants
export const STORAGE_KEY = 'claudash';
export const DEFAULT_REFRESH_INTERVAL = 15; // minutes
export const DEFAULT_HISTORY_DAYS = 7;
export const DEFAULT_CLAUDE_MODEL = 'claude-sonnet-4-5-20250929';
export const DEFAULT_CORS_PROXY = 'https://api.allorigins.win/raw?url=';
// Cache TTL in minutes
export const CACHE_TTL = 15;
// Date format
export const DATE_FORMAT = 'YYYY-MM-DD';
// API endpoints
export const ANTHROPIC_API_URL = 'https://api.anthropic.com/v1/messages';
export const RSS_TO_JSON_API = 'https://api.rss2json.com/v1/api.json';
// Default data sources (fallback if none provided)
export const DEFAULT_DATA_SOURCES = [
'https://www.reddit.com/r/technology.json',
'https://hacker-news.firebaseio.com/v0/topstories.json',
'https://www.reddit.com/r/worldnews.json'
];
// Weather API
export const WEATHER_API_BASE = 'https://wttr.in';
// Max tokens for Claude responses
export const MAX_TOKENS = 1024;

View file

@ -0,0 +1,88 @@
import { DEFAULT_CORS_PROXY } from './constants';
/**
* Fetch data with CORS proxy fallback
* @param {string} url - The URL to fetch
* @param {Object} options - Fetch options
* @returns {Promise<any>} - The fetched data
*/
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, {
...options,
mode: 'cors',
headers: {
...options.headers,
'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();
} else {
return await response.text();
}
} 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();
} else {
return await response.text();
}
} catch (proxyError) {
console.error(`Both direct and proxy fetch failed for ${url}:`, proxyError);
throw proxyError;
}
}
}
/**
* Test if a URL is accessible directly without CORS issues
* @param {string} url - The URL to test
* @returns {Promise<boolean>} - True if accessible, false otherwise
*/
export async function testCorsAccess(url) {
try {
const response = await fetch(url, {
method: 'HEAD',
mode: 'cors',
});
return response.ok;
} catch {
return false;
}
}
/**
* Get the appropriate URL (direct or proxied) based on CORS accessibility
* @param {string} url - The original URL
* @returns {Promise<string>} - Either the original URL or a proxied version
*/
export async function getAccessibleUrl(url) {
const canAccess = await testCorsAccess(url);
if (canAccess) {
return url;
}
const proxy = import.meta.env.REACT_APP_CORS_PROXY || DEFAULT_CORS_PROXY;
return `${proxy}${encodeURIComponent(url)}`;
}

36
claudash/vite.config.js Normal file
View file

@ -0,0 +1,36 @@
import { defineConfig, loadEnv } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
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(
(prev, [key, val]) => {
return {
...prev,
[key]: val,
}
},
{},
)
}
return {
plugins: [react()],
define: {
// Expose all REACT_APP_ prefixed variables
'import.meta.env.REACT_APP_ANTHROPIC_API_KEY': JSON.stringify(env.REACT_APP_ANTHROPIC_API_KEY),
'import.meta.env.REACT_APP_DATA_SOURCES': JSON.stringify(env.REACT_APP_DATA_SOURCES),
'import.meta.env.REACT_APP_LOCATION': JSON.stringify(env.REACT_APP_LOCATION),
'import.meta.env.REACT_APP_CORS_PROXY': JSON.stringify(env.REACT_APP_CORS_PROXY),
'import.meta.env.REACT_APP_REFRESH_INTERVAL': JSON.stringify(env.REACT_APP_REFRESH_INTERVAL),
'import.meta.env.REACT_APP_HISTORY_DAYS': JSON.stringify(env.REACT_APP_HISTORY_DAYS),
'import.meta.env.REACT_APP_CLAUDE_MODEL': JSON.stringify(env.REACT_APP_CLAUDE_MODEL),
'import.meta.env.REACT_APP_DEBUG_MODE': JSON.stringify(env.REACT_APP_DEBUG_MODE),
}
}
})