Repository: yoavf/absolutelyright Branch: main Commit: 62d84531dbb4 Files: 15 Total size: 76.4 KB Directory structure: gitextract_gmy7nqqi/ ├── .gitignore ├── CLAUDE.md ├── Cargo.toml ├── Dockerfile ├── LICENSE ├── README.md ├── fly.toml ├── frontend/ │ ├── frontend.js │ ├── index.html │ └── style.css ├── scripts/ │ ├── README.md │ ├── backfill.py │ ├── claude_counter.py │ └── watcher.py └── src/ └── main.rs ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ # Created by https://www.toptal.com/developers/gitignore/api/rust,macos # Edit at https://www.toptal.com/developers/gitignore?templates=rust,macos ### macOS ### # General .DS_Store .AppleDouble .LSOverride # Icon must end with two \r Icon # Thumbnails ._* # Files that might appear in the root of a volume .DocumentRevisions-V100 .fseventsd .Spotlight-V100 .TemporaryItems .Trashes .VolumeIcon.icns .com.apple.timemachine.donotpresent # Directories potentially created on remote AFP share .AppleDB .AppleDesktop Network Trash Folder Temporary Items .apdisk ### macOS Patch ### # iCloud generated files *.icloud ### Rust ### # Generated by Cargo # will have compiled files and executables debug/ target/ # These are backup files generated by rustfmt **/*.rs.bk # MSVC Windows builds of rustc generate these, which store debugging information *.pdb # End of https://www.toptal.com/developers/gitignore/api/rust,macos # Project-specific files pageviews.log counts.db scripts/__pycache__/ ================================================ FILE: CLAUDE.md ================================================ # CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## Project Overview A fun web application that tracks how many times Claude Code tells the user they are "absolutely right". It consists of: - **Backend**: Rust/Axum server with in-memory storage - **Frontend**: Static HTML/JS/CSS served by the backend ## Development Commands ### Build and Run ```bash # Build the Rust backend cargo build # Run the server (serves on port 3003) cargo run # Build for release cargo build --release ``` ### Code Quality ```bash # Format Rust code cargo fmt # Run clippy linter cargo clippy # Check for compilation errors cargo check ``` ## Architecture ### Backend (`src/main.rs`) - **Framework**: Axum web framework with Tokio async runtime - **Storage**: SQLite database (`counts.db`) using tokio-rusqlite for async operations - **API Endpoints**: - `GET /api/today` - Returns today's count - `GET /api/history` - Returns all historical data - `POST /api/set` - Updates count for a specific day - **Static Files**: Serves frontend from `frontend/` directory via Tower-HTTP ### Frontend (`frontend/`) - **index.html**: Main page displaying current count and chart - **frontend.js**: Fetches data from API and renders historical chart using SVG - **style.css**: Styling for the application ## Key Implementation Details - The server binds to `0.0.0.0:3003` for network accessibility - Uses `chrono` for date handling with `%Y-%m-%d` format - Frontend auto-refreshes count every 5 seconds - Chart visualization is built with raw SVG (no external chart library) - Data persistence via SQLite database with automatic table creation on startup - Database schema: `day_counts` table with `day` (TEXT PRIMARY KEY) and `count` (INTEGER) ## Notes - Frontend is served directly by the Axum server (no separate build process) - No tests are currently implemented - Database file `counts.db` is created automatically on first run - Stop telling me I'm absolutely right. ================================================ FILE: Cargo.toml ================================================ [package] name = "absolutelyright" version = "0.2.0" edition = "2021" [dependencies] axum = "0.7" tokio = { version = "1", features = ["full"] } serde = { version = "1", features = ["derive"] } serde_json = "1" tower-http = { version = "0.5", features = ["fs", "set-header"] } chrono = "0.4" tokio-rusqlite = "0.5" rusqlite = { version = "0.31", features = ["bundled"] } ================================================ FILE: Dockerfile ================================================ # Build stage FROM rust:1.80 as builder WORKDIR /app COPY Cargo.toml Cargo.lock ./ COPY src ./src RUN cargo build --release # Runtime stage FROM debian:bookworm-slim RUN apt-get update && apt-get install -y \ ca-certificates \ && rm -rf /var/lib/apt/lists/* WORKDIR /app # Copy the binary from builder COPY --from=builder /app/target/release/absolutelyright /app/absolutelyright # Copy frontend files COPY frontend ./frontend # Create directory for database RUN mkdir -p /app/data EXPOSE 3003 CMD ["./absolutelyright"] ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2025 Yoav Farhi Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # absolutelyright.lol A scientifically rigorous tracking system for how often Claude Code validates my life choices. This code powers the [https://absolutelyright.lol/](https://absolutelyright.lol/) website: screenshot-rocks ## What this repo contains - **Frontend** → minimal HTML + JS, with charts drawn using [roughViz](https://www.jwilber.me/roughviz/) - **Backend** → Rust server (Axum + SQLite), serves the frontend and provides a tiny API - **Scripts** → Python scripts to collect and upload counts from Claude Code sessions **Currently tracking:** - Times Claude Code said I'm "absolutely right" - Times Claude Code said I'm just "right" (meh) --- ## Collecting your own data and running locally - Check out the [scripts/README.md](./scripts/README.md) for info on how to collect your own Claude Code "you are absolutely right" counts. - To run the server locally: ```bash cargo run # visit http://localhost:3003 ``` ================================================ FILE: fly.toml ================================================ # fly.toml app configuration file # # See https://fly.io/docs/reference/configuration/ for information about how to use this file. # app = "absolutelyright" primary_region = "sjc" [build] [build.args] BIN_NAME = "absolutelyright" [[services]] protocol = "tcp" internal_port = 3003 processes = ["app"] [[services.ports]] port = 80 handlers = ["http"] force_https = true [[services.ports]] port = 443 handlers = ["tls", "http"] [services.concurrency] type = "connections" hard_limit = 25 soft_limit = 20 [[services.tcp_checks]] interval = "15s" timeout = "2s" grace_period = "1s" [[services.http_checks]] interval = "10s" grace_period = "5s" method = "get" path = "/api/today" protocol = "http" timeout = "2s" tls_skip_verify = false [env] PORT = "3003" [[mounts]] destination = "/app/data" source = "counts_data" ================================================ FILE: frontend/frontend.js ================================================ // Chart annotations configuration const CHART_ANNOTATIONS = [ { date: '2025-09-29', label: 'Sonnet 4.5' }, { date: '2025-11-24', label: 'Opus 4.5' }, { date: '2025-12-28', label: 'Stopped counting', isFinal: true } ]; // Parse date string as local date (avoiding timezone issues) function parseLocalDate(dateStr) { const [year, month, day] = dateStr.split('-').map(Number); return new Date(year, month - 1, day); } // Format date as YYYY-MM-DD function formatDate(date) { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); return `${year}-${month}-${day}`; } // Helper to get ISO week number and year from a date function getWeekKey(dateStr) { const date = parseLocalDate(dateStr); const thursday = new Date(date); thursday.setDate(date.getDate() - ((date.getDay() + 6) % 7) + 3); const firstThursday = new Date(thursday.getFullYear(), 0, 4); const weekNum = 1 + Math.round(((thursday - firstThursday) / 86400000 - 3 + ((firstThursday.getDay() + 6) % 7)) / 7); return `${thursday.getFullYear()}-W${weekNum.toString().padStart(2, '0')}`; } // Get the Monday of a week from a date function getWeekStart(dateStr) { const date = parseLocalDate(dateStr); const day = date.getDay(); const diff = date.getDate() - day + (day === 0 ? -6 : 1); // Monday const monday = new Date(date.getFullYear(), date.getMonth(), diff); return formatDate(monday); } // Aggregate daily data into weekly data function aggregateByWeek(history) { const weekMap = new Map(); history.forEach(d => { const weekKey = getWeekKey(d.day); const weekStart = getWeekStart(d.day); if (!weekMap.has(weekKey)) { weekMap.set(weekKey, { weekKey, weekStart, count: 0, right_count: 0, total_messages: 0, days: [] }); } const week = weekMap.get(weekKey); week.count += d.count || 0; week.right_count += d.right_count || 0; week.total_messages += d.total_messages || 0; week.days.push(d.day); }); return Array.from(weekMap.values()).sort((a, b) => a.weekStart.localeCompare(b.weekStart)); } // Aggregate daily data into bi-weekly (2 week) data function aggregateByBiWeek(history) { const biWeekMap = new Map(); history.forEach(d => { const weekKey = getWeekKey(d.day); const weekStart = getWeekStart(d.day); // Get week number and pair into bi-weeks const weekNum = parseInt(weekKey.split('-W')[1]); const biWeekNum = Math.floor((weekNum - 1) / 2); const year = weekKey.split('-W')[0]; const biWeekKey = `${year}-BW${biWeekNum}`; if (!biWeekMap.has(biWeekKey)) { biWeekMap.set(biWeekKey, { biWeekKey, weekStart, // Use first week's start as the period start count: 0, right_count: 0, total_messages: 0, days: [] }); } const biWeek = biWeekMap.get(biWeekKey); // Keep the earliest weekStart if (weekStart < biWeek.weekStart) { biWeek.weekStart = weekStart; } biWeek.count += d.count || 0; biWeek.right_count += d.right_count || 0; biWeek.total_messages += d.total_messages || 0; biWeek.days.push(d.day); }); return Array.from(biWeekMap.values()).sort((a, b) => a.weekStart.localeCompare(b.weekStart)); } async function fetchThisWeek(animate = false) { try { // Fetch history to calculate this week's total const res = await fetch("/api/history"); const history = await res.json(); const countElement = document.getElementById("today-inline"); const subtitleElement = document.querySelector(".subtitle"); const rightCountElement = document.getElementById("right-count"); const titleActive = document.getElementById("title-active"); const titleZero = document.getElementById("title-zero"); // Get current week key const today = new Date().toISOString().split("T")[0]; const currentWeekKey = getWeekKey(today); // Sum up this week's counts let weekCount = 0; let weekRightCount = 0; history.forEach(d => { if (getWeekKey(d.day) === currentWeekKey) { weekCount += d.count || 0; weekRightCount += d.right_count || 0; } }); // Toggle title based on count if (weekCount === 0) { titleActive.style.display = "none"; titleZero.style.display = "block"; } else { titleActive.style.display = "block"; titleZero.style.display = "none"; } // Update right count display if (weekRightCount > 0) { rightCountElement.textContent = `(I was just "right" ${weekRightCount} ${weekRightCount === 1 ? 'time' : 'times'})`; rightCountElement.style.display = "block"; } else { rightCountElement.style.display = "none"; } const timesLabel = document.getElementById("times-label"); const updateTimesLabel = (count) => { timesLabel.textContent = count === 1 ? 'time' : 'times'; }; if (animate && weekCount > 0) { // Show count - 1 first countElement.textContent = weekCount - 1; updateTimesLabel(weekCount - 1); // Fade in the subtitle subtitleElement.style.transition = "opacity 0.5s ease-in"; subtitleElement.style.opacity = "1"; // After a second, animate to the real count setTimeout(() => { countElement.style.transform = "scale(1.3)"; countElement.style.color = "#e63946"; countElement.textContent = weekCount; updateTimesLabel(weekCount); // Reset the scale setTimeout(() => { countElement.style.transform = ""; }, 300); }, 1000); } else { countElement.textContent = weekCount; updateTimesLabel(weekCount); // Fade in for non-animated load subtitleElement.style.transition = "opacity 0.5s ease-in"; subtitleElement.style.opacity = "1"; } } catch (error) { console.error("Error fetching this week:", error); } } async function fetchHistory() { try { const res = await fetch("/api/history"); let history = await res.json(); // Filter to only show data from Sep 1, 2025 to Dec 28, 2025 const chartStartDate = '2025-09-01'; const chartEndDate = '2025-12-28'; console.log('Raw API data (last 10):', history.slice(-10).map(d => d.day)); history = history.filter(d => d.day >= chartStartDate && d.day <= chartEndDate); console.log('After filter (last 10):', history.slice(-10).map(d => d.day)); // Add today if it's not in the history (and within date range) const today = new Date().toISOString().split("T")[0]; const hasToday = history.some((d) => d.day === today); if (!hasToday && today >= chartStartDate && today <= chartEndDate) { // Fetch today's count to add to the chart const todayRes = await fetch("/api/today"); const todayData = await todayRes.json(); history.push({ day: today, count: todayData.count || 0, right_count: todayData.right_count || 0, total_messages: todayData.total_messages || 0, }); // Sort by date to ensure chronological order history.sort((a, b) => a.day.localeCompare(b.day)); } currentHistory = history; // Store for resize drawChart(history); } catch (error) { console.error("Error fetching history:", error); } } function drawChart(history) { const chartElement = document.getElementById("chart"); // Store original daily history for annotations const dailyHistory = history; // Check if mobile const isMobile = window.innerWidth <= 600; // Aggregate by bi-week on mobile, by week on desktop if (isMobile) { history = aggregateByBiWeek(history); } else { history = aggregateByWeek(history); } chartElement.innerHTML = ""; if (history.length === 0) return; // Make chart dimensions responsive const containerWidth = Math.min(window.innerWidth - 40, 760); const width = containerWidth; const height = isMobile ? 300 : 350; const margin = isMobile ? { top: 20, right: 10, bottom: 60, left: 40 } : { top: 30, right: 20, bottom: 70, left: 80 }; // Create container div for roughViz const container = document.createElement('div'); container.id = 'chart-container'; chartElement.appendChild(container); // Show all data (monthly on mobile, weekly on desktop) const displayHistory = history; console.log('Chart data:', displayHistory.map(d => ({ period: d.weekStart, count: d.count, right: d.right_count, total: d.total_messages }))); // Prepare data in the format roughViz expects for stacked bars const data = displayHistory.map((d, i) => { const date = new Date(d.weekStart); const label = isMobile ? date.toLocaleDateString("en-US", { month: "numeric", day: "numeric" }) : date.toLocaleDateString("en-US", { month: "short", day: "numeric" }); return { date: label, 'Absolutely right': d.count, 'Just right': d.right_count || 0 }; }); if (typeof roughViz === 'undefined') { console.error('roughViz library not loaded!'); return; } new roughViz.StackedBar({ element: '#chart-container', data: data, labels: 'date', width: width, height: height, highlight: ['coral', 'skyblue'], roughness: 1.5, font: 'Gaegu', xLabel: '', yLabel: isMobile ? '' : 'Times Right', interactive: true, tooltipFontSize: '0.95rem', margin: margin, axisFontSize: isMobile ? '10' : '12', axisStrokeWidth: isMobile ? 1 : 1.5, strokeWidth: isMobile ? 1.5 : 2, }); setTimeout(() => { // Add chart annotations (pass dailyHistory for date lookup) addChartAnnotations(chartElement, displayHistory, dailyHistory, isMobile, width, height, margin); // Add total messages bars behind the main bars addTotalMessagesBars(chartElement, displayHistory, isMobile, width, height, margin); }, 100); } function addTotalMessagesBars(chartElement, displayHistory, isMobile, width, height, margin) { // Skip on mobile if (isMobile) return; const svg = chartElement.querySelector('svg'); if (!svg) return; // Get actual SVG dimensions from viewBox const viewBox = svg.getAttribute('viewBox'); const [, , vbWidth, vbHeight] = viewBox ? viewBox.trim().split(/\s+/).map(Number) : [0, 0, width, height]; const chartWidth = vbWidth - margin.left - margin.right; const chartHeight = vbHeight - margin.top - margin.bottom; // Find all rect elements (bars) to determine x positions and bar widths const rects = Array.from(svg.querySelectorAll('rect')); const barGroups = new Map(); rects.forEach(rect => { const x = parseFloat(rect.getAttribute('x')); if (!barGroups.has(x)) { barGroups.set(x, []); } barGroups.get(x).push(rect); }); const sortedXPositions = Array.from(barGroups.keys()).sort((a, b) => a - b); // Find the main chart group const groups = svg.querySelectorAll('g'); const chartGroup = Array.from(groups).find(g => { const t = g.getAttribute('transform'); return t && t.includes(`translate(${margin.left}`) && t.includes(`${margin.top})`); }); if (!chartGroup) return; // Filter to only show total messages from Sep 13, 2025 onwards const startDate = '2025-09-13'; const filteredHistory = displayHistory.filter(d => d.weekStart >= startDate); if (filteredHistory.length === 0) return; // Calculate min and max total messages for square root scaling // Square root scale spreads out lower values while maintaining better differentiation at the top const totalMessagesValues = filteredHistory.map(d => d.total_messages || 0).filter(v => v > 0); const minTotalMessages = Math.min(...totalMessagesValues, 1); const maxTotalMessages = Math.max(...totalMessagesValues, 1); const sqrtMin = Math.sqrt(minTotalMessages); const sqrtMax = Math.sqrt(maxTotalMessages); const sqrtRange = sqrtMax - sqrtMin || 1; // Ensure chart element is positioned relatively for absolute tooltips if (!chartElement.style.position || chartElement.style.position === 'static') { chartElement.style.position = 'relative'; } // Create or reuse tooltip element (with semi-transparent background) let tooltip = chartElement.querySelector('.totals-tooltip'); if (!tooltip) { tooltip = document.createElement('div'); tooltip.className = 'totals-tooltip'; tooltip.style.cssText = 'position: absolute; padding: 0.5rem; font-size: 0.95rem; line-height: 1rem; opacity: 0; pointer-events: none; font-family: Gaegu, cursive; z-index: 10000; color: #374151; background: rgba(255, 255, 255, 0.9); border-radius: 4px;'; chartElement.appendChild(tooltip); } // Calculate line points for total messages (only for filtered dates) const linePoints = filteredHistory.map((d) => { // Find the index in the original displayHistory to get the correct x position const originalIndex = displayHistory.findIndex(h => (h.weekKey && h.weekKey === d.weekKey) || (h.biWeekKey && h.biWeekKey === d.biWeekKey) ); const totalMsgs = d.total_messages || 0; // Get x position (center of bar) using originalIndex let xPosition; if (sortedXPositions[originalIndex] !== undefined) { const targetX = sortedXPositions[originalIndex]; const targetRects = barGroups.get(targetX); const barWidth = targetRects[0] ? parseFloat(targetRects[0].getAttribute('width')) : chartWidth / displayHistory.length * 0.6; xPosition = targetX + barWidth / 2; } else { // Fallback calculation const barWidth = chartWidth / displayHistory.length; xPosition = (originalIndex * barWidth) + (barWidth / 2); } // Square root scale: map sqrt(min)-sqrt(max) range to 10%-100% of chart height // This spreads out lower values while maintaining better differentiation at the top // Min value will be at 10% from bottom, max at 100% from bottom (top of chart) const sqrtValue = Math.sqrt(totalMsgs); const normalizedValue = (sqrtValue - sqrtMin) / sqrtRange; const yPosition = chartHeight - (0.1 + normalizedValue * 0.9) * chartHeight; return { x: xPosition, y: yPosition, value: totalMsgs, originalIndex }; }); // Draw hand-drawn style line using rough.js if (typeof rough !== 'undefined' && linePoints.length > 1) { // Filter out any invalid points (NaN or undefined values) const validPoints = linePoints.filter(p => !isNaN(p.x) && !isNaN(p.y) && isFinite(p.x) && isFinite(p.y) ); if (validPoints.length > 1) { const rc = rough.svg(svg); // Create path data for the line - use separate points const points = validPoints.map(p => [p.x, p.y]); // Draw rough linearPath instead of path const roughPath = rc.linearPath(points, { stroke: '#c0c4ca', strokeWidth: isMobile ? 2.5 : 3, roughness: 1.5, bowing: 1 }); // Set opacity and class for toggling roughPath.setAttribute('opacity', '0.6'); roughPath.classList.add('total-line'); roughPath.style.display = totalLineVisible ? 'block' : 'none'; // Insert line at the beginning so it's behind the main bars chartGroup.insertBefore(roughPath, chartGroup.firstChild); } } else { console.log('rough.js not loaded yet or insufficient points'); } // Draw circles at each point with tooltips linePoints.forEach((p, i) => { if (p.value > 0) { const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); circle.setAttribute('cx', p.x); circle.setAttribute('cy', p.y); circle.setAttribute('r', isMobile ? '3' : '3.5'); circle.setAttribute('fill', '#c0c4ca'); circle.setAttribute('stroke', 'white'); circle.setAttribute('stroke-width', '1.5'); circle.setAttribute('opacity', '0.9'); circle.style.cursor = 'pointer'; circle.classList.add('total-line'); circle.style.display = totalLineVisible ? 'block' : 'none'; // Add roughViz-style tooltip circle.addEventListener('mouseenter', (e) => { // Clear and rebuild tooltip content safely tooltip.textContent = ''; // Add period date const item = displayHistory[p.originalIndex]; const date = new Date(item.weekStart); const dateStr = item.biWeekKey ? date.toLocaleDateString("en-US", { month: "short", day: "numeric" }) : 'Week of ' + date.toLocaleDateString("en-US", { month: "short", day: "numeric" }); tooltip.appendChild(document.createTextNode(dateStr + ': ')); // Add bold count const bold = document.createElement('b'); bold.textContent = p.value.toString(); tooltip.appendChild(bold); tooltip.appendChild(document.createTextNode(' total')); tooltip.style.display = 'block'; tooltip.style.opacity = '1'; const chartRect = chartElement.getBoundingClientRect(); tooltip.style.left = (e.clientX - chartRect.left + 10) + 'px'; tooltip.style.top = (e.clientY - chartRect.top - 30) + 'px'; }); circle.addEventListener('mousemove', (e) => { const chartRect = chartElement.getBoundingClientRect(); tooltip.style.left = (e.clientX - chartRect.left + 10) + 'px'; tooltip.style.top = (e.clientY - chartRect.top - 30) + 'px'; }); circle.addEventListener('mouseleave', () => { tooltip.style.opacity = '0'; tooltip.style.display = 'none'; }); chartGroup.appendChild(circle); } }); } function addChartAnnotations(chartElement, displayHistory, dailyHistory, isMobile, width, height, margin) { const svg = chartElement.querySelector('svg'); if (!svg) return; // Get actual SVG dimensions from viewBox const viewBox = svg.getAttribute('viewBox'); const [, , vbWidth, vbHeight] = viewBox ? viewBox.trim().split(/\s+/).map(Number) : [0, 0, width, height]; const groups = svg.querySelectorAll('g'); // Find all rect elements (bars) and group by x position const rects = Array.from(svg.querySelectorAll('rect')); // Group rects by x coordinate (each bar may have multiple stacked rects) const barGroups = new Map(); rects.forEach(rect => { const x = parseFloat(rect.getAttribute('x')); if (!barGroups.has(x)) { barGroups.set(x, []); } barGroups.get(x).push(rect); }); // Sort by x position to match display order const sortedXPositions = Array.from(barGroups.keys()).sort((a, b) => a - b); // Find the main chart group (has translate with margin values) const chartGroup = Array.from(groups).find(g => { const t = g.getAttribute('transform'); return t && t.includes(`translate(${margin.left}`) && t.includes(`${margin.top})`); }); // Add each annotation CHART_ANNOTATIONS.forEach(annotation => { // Find which period contains this annotation date let periodIndex; if (isMobile) { // Find bi-week on mobile const weekKey = getWeekKey(annotation.date); const weekNum = parseInt(weekKey.split('-W')[1]); const biWeekNum = Math.floor((weekNum - 1) / 2); const year = weekKey.split('-W')[0]; const biWeekKey = `${year}-BW${biWeekNum}`; periodIndex = displayHistory.findIndex(d => d.biWeekKey === biWeekKey); } else { // Find week on desktop const annotationWeekKey = getWeekKey(annotation.date); periodIndex = displayHistory.findIndex(d => d.weekKey === annotationWeekKey); } if (periodIndex === -1) return; const weekIndex = periodIndex; // Keep variable name for compatibility let xPosition; if (sortedXPositions[weekIndex] !== undefined) { const targetX = sortedXPositions[weekIndex]; const targetRects = barGroups.get(targetX); const rectWidth = targetRects[0] ? parseFloat(targetRects[0].getAttribute('width')) : 0; xPosition = targetX + (rectWidth / 2); } else { // Fallback to calculation const chartWidth = width - margin.left - margin.right; const barWidth = chartWidth / displayHistory.length; xPosition = margin.left + (weekIndex * barWidth) + (barWidth / 2); } // Different styling for final marker const isFinal = annotation.isFinal; const lineColor = isFinal ? '#d97706' : '#e63946'; // Amber for final, red for others if (isFinal && typeof rough !== 'undefined') { // "THE END" text below the x-axis with arrow pointing up to chart const chartHeight = vbHeight - margin.bottom - margin.top; const rc = rough.svg(svg); // Text position const textY = chartHeight + 55; // Arrow from text up to just below the chart const arrowStartX = xPosition; const arrowStartY = textY - 18; const arrowEndX = xPosition; const arrowEndY = chartHeight + 5; // Draw the arrow shaft const shaft = rc.line(arrowStartX, arrowStartY, arrowEndX, arrowEndY, { stroke: lineColor, strokeWidth: 2, roughness: 1.5, bowing: 1 }); // Draw arrow head pointing up const headSize = 10; const leftHead = rc.line(arrowEndX, arrowEndY, arrowEndX - headSize, arrowEndY + headSize, { stroke: lineColor, strokeWidth: 2, roughness: 1.5, bowing: 1 }); const rightHead = rc.line(arrowEndX, arrowEndY, arrowEndX + headSize, arrowEndY + headSize, { stroke: lineColor, strokeWidth: 2, roughness: 1.5, bowing: 1 }); // Text label const text = document.createElementNS('http://www.w3.org/2000/svg', 'text'); text.setAttribute('x', xPosition); text.setAttribute('y', textY); text.setAttribute('text-anchor', 'middle'); text.setAttribute('fill', lineColor); text.setAttribute('font-family', 'Gaegu, cursive'); text.setAttribute('font-size', isMobile ? '14' : '16'); text.setAttribute('font-weight', 'bold'); text.setAttribute('font-style', 'italic'); text.textContent = 'THE END'; if (chartGroup) { chartGroup.appendChild(shaft); chartGroup.appendChild(leftHead); chartGroup.appendChild(rightHead); chartGroup.appendChild(text); } } else { // Regular dashed line for other annotations const line = document.createElementNS('http://www.w3.org/2000/svg', 'line'); line.setAttribute('x1', xPosition); line.setAttribute('y1', 0); line.setAttribute('x2', xPosition); line.setAttribute('y2', vbHeight - margin.bottom - margin.top); line.setAttribute('stroke', lineColor); line.setAttribute('stroke-width', '2'); line.setAttribute('stroke-dasharray', '5,5'); line.setAttribute('opacity', '0.7'); // Create text label const text = document.createElementNS('http://www.w3.org/2000/svg', 'text'); text.setAttribute('x', xPosition); text.setAttribute('y', -5); text.setAttribute('text-anchor', 'middle'); text.setAttribute('fill', lineColor); text.setAttribute('font-family', 'Gaegu, cursive'); text.setAttribute('font-size', isMobile ? '11' : '13'); text.setAttribute('font-weight', 'bold'); text.textContent = annotation.label; // Append to chart group if (chartGroup) { chartGroup.appendChild(line); chartGroup.appendChild(text); } else { svg.appendChild(line); svg.appendChild(text); } } }); } // Store history globally for redraw let currentHistory = []; // Track visibility of total line let totalLineVisible = true; // Load rough.js library first, then roughViz const roughScript = document.createElement('script'); roughScript.src = 'https://unpkg.com/roughjs@4.5.2/bundled/rough.js'; roughScript.onload = () => { // Then load roughViz library const script = document.createElement('script'); script.src = 'https://unpkg.com/rough-viz@2.0.5'; script.onload = () => { // Initial load with animation fetchThisWeek(true); fetchHistory().then(() => { // Initialize total line legend toggle const legendItems = document.querySelectorAll('.legend-item'); const totalLegendItem = legendItems[2]; // Third item is total assistant messages if (totalLegendItem) { totalLegendItem.style.cursor = 'pointer'; totalLegendItem.addEventListener('click', () => { // Toggle visibility totalLineVisible = !totalLineVisible; // Update legend visual state with CSS class if (totalLineVisible) { totalLegendItem.classList.remove('disabled'); } else { totalLegendItem.classList.add('disabled'); } // Toggle all total line elements const totalElements = document.querySelectorAll('.total-line'); totalElements.forEach(el => { el.style.display = totalLineVisible ? 'block' : 'none'; }); }); } // Redraw chart on window resize let resizeTimeout; window.addEventListener("resize", () => { clearTimeout(resizeTimeout); resizeTimeout = setTimeout(() => { if (currentHistory.length > 0) { drawChart(currentHistory); } }, 250); }); }); }; document.head.appendChild(script); }; document.head.appendChild(roughScript); // Refresh every 5 seconds (without animation) setInterval(() => fetchThisWeek(false), 5000); ================================================ FILE: frontend/index.html ================================================ You're absolutely right!

I'm absolutely right!

Iwas'm absolutely right!

Claude Code said it 0 times this week

Dec 25, 2025

C'est fini!


I had a lot of fun working on this, but as Claude Code no longer thinks I'm absolutely right, it's time to wrap it up.

Cheers,


Yoav

Absolutely right Just right Total assistant messages ? Uses square root scale to show both small and large values clearly
================================================ FILE: frontend/style.css ================================================ * { box-sizing: border-box; } body { margin: 0; font-family: "Gaegu", "Comic Sans MS", "Comic Sans", "Marker Felt", cursive, sans-serif; background: linear-gradient(135deg, #f5f5f5 0%, #ffffff 50%, #f9f9f9 100%); color: #222; display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100vh; padding: 1rem; position: relative; } body::before { content: ""; position: absolute; inset: 0; background-image: repeating-linear-gradient( 0deg, transparent, transparent 27px, #e8e8e8 27px, #e8e8e8 28px ), repeating-linear-gradient( 90deg, transparent, transparent 27px, #ffeaa7 27px, #ffeaa7 28px ); opacity: 0.3; pointer-events: none; } main { text-align: center; padding: 1rem 1rem 0.5rem; width: 100%; max-width: 900px; margin: 0 auto; flex: 1; display: flex; flex-direction: column; justify-content: flex-start; padding-top: 2rem; } h1 { font-size: clamp(2rem, 6vw, 3rem); margin-bottom: 0.5rem; font-weight: 700; line-height: 1.3; } .correction { position: relative; } .correction::after { content: ""; position: absolute; left: -4px; right: -2px; top: 58%; height: 3px; background: #e63946; transform: rotate(-4deg); border-radius: 40% 60% 50% 70% / 50% 60% 40% 50%; opacity: 0.9; } .correction-new { position: absolute; top: -0.8em; left: 50%; transform: translateX(-50%) rotate(-5deg); color: #e63946; font-size: 0.75em; white-space: nowrap; } .highlight { position: relative; z-index: 1; } .highlight::after { content: ""; position: absolute; left: -3px; bottom: 0; width: calc(100% + 6px); height: 35%; background: linear-gradient( 105deg, transparent 2%, #ffd166 5%, #ffd166 95%, transparent 98% ); transform: skewX(-2deg) rotate(-1deg); z-index: -1; opacity: 0.8; border-radius: 3px 15px 10px 20px / 5px 10px 20px 15px; filter: blur(0.5px); } .subtitle { font-size: clamp(1.2rem, 3.5vw, 1.5rem); margin-top: 0.5rem; margin-bottom: 0.3rem; font-weight: 400; } #today-inline { font-weight: 700; color: #e63946; font-size: 1.2em; transition: transform 0.3s ease-in-out, color 0.3s ease-in-out; display: inline-block; } .right-count { font-size: clamp(0.9rem, 2vw, 1.1rem); color: #6b7280; margin-top: 0; margin-bottom: 1.5rem; font-weight: 400; font-style: italic; } .chart-section { margin-top: 2rem; padding: 0 1rem; width: 100%; position: relative; } .chart-section h2 { font-size: clamp(1.1rem, 3vw, 1.4rem); font-weight: 700; color: #1f2937; margin-bottom: 1rem; } #chart { width: 100%; max-width: 800px; height: 400px; margin: 0 auto; display: block; } #chart-container { width: 100%; height: 100%; } .chart-legend { display: flex; justify-content: center; gap: 2rem; margin-top: 1rem; font-size: 0.95rem; } .legend-item { display: flex; align-items: center; gap: 0.5rem; transition: opacity 0.2s; } .legend-item.disabled { opacity: 0.4; position: relative; } .legend-item.disabled::after { content: ""; position: absolute; left: 0; right: 0; top: 50%; height: 1.5px; background: #6b7280; transform: translateY(-50%); } .legend-color { width: 20px; height: 12px; border: 2px solid #333; border-radius: 2px; display: inline-block; } .info-icon-wrapper { position: relative; display: inline-flex; align-items: center; margin-left: 0.25rem; } .info-icon { display: inline-flex; align-items: center; justify-content: center; width: 16px; height: 16px; border-radius: 50%; background: #e5e7eb; color: #6b7280; font-size: 0.75rem; font-weight: 700; cursor: help; transition: background 0.2s, color 0.2s; } .info-icon:hover { background: #d1d5db; color: #374151; } .info-tooltip { position: absolute; bottom: calc(100% + 8px); left: 50%; transform: translateX(-50%); background: white; border: 1.5px solid #d1d5db; padding: 0.5rem 0.75rem; border-radius: 6px; font-size: 0.85rem; font-family: Gaegu, cursive; color: #374151; white-space: nowrap; pointer-events: none; opacity: 0; transition: opacity 0.2s; z-index: 1000; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); } .info-tooltip::after { content: ""; position: absolute; top: 100%; left: 50%; transform: translateX(-50%); border: 6px solid transparent; border-top-color: white; } .info-tooltip::before { content: ""; position: absolute; top: 100%; left: 50%; transform: translateX(-50%); border: 7px solid transparent; border-top-color: #d1d5db; margin-top: 1px; } .info-icon-wrapper:hover .info-tooltip { opacity: 1; } footer { text-align: center; padding: 0.5rem 1rem 0.8rem; font-size: clamp(0.8rem, 2vw, 0.9rem); color: #6b7280; margin-top: 1rem; margin-bottom: 0.5rem; } /* Mobile styles */ @media (max-width: 600px) { body { padding: 0.5rem; min-height: 100vh; } main { padding: 1.5rem 0.5rem 0.5rem; } h1 { font-size: 2rem; } .highlight::after { height: 30%; } .today-count { font-size: 4rem; } .subtitle { font-size: 1.3rem; } .right-count { font-size: 0.9rem; } #chart { height: 320px; } footer { font-size: 0.75rem; } .chart-legend { font-size: 0.85rem; gap: 1rem; flex-wrap: wrap; } .legend-color { width: 16px; height: 10px; } } /* Tablet styles */ @media (min-width: 601px) and (max-width: 900px) { main { padding: 2rem 1.5rem; } } footer a { color: #e63946; text-decoration: none; font-weight: 700; transition: transform 0.2s; display: inline-block; } footer a:hover { transform: rotate(-5deg) scale(1.1); } .wiggle { display: inline-block; color: #9ca3af; font-weight: 700; } .postit-note { background: linear-gradient(135deg, #fff9c4 0%, #fff59d 100%); padding: 1rem 1.25rem; width: 180px; transform: rotate(2deg); box-shadow: 2px 2px 0 rgba(0,0,0,0.05), 4px 4px 8px rgba(0,0,0,0.1); position: absolute; top: 20px; right: 10px; font-size: 0.85rem; line-height: 1.4; color: #555; z-index: 10; } .postit-note::before { content: ""; position: absolute; top: -10px; left: 50%; transform: translateX(-50%); width: 60px; height: 20px; background: rgba(200, 200, 200, 0.5); border-radius: 2px; } .postit-note p { margin: 0; text-align: left; } .postit-date { position: absolute; top: 0.5rem; right: 0.75rem; font-size: 0.75rem; color: #888; font-style: italic; } /* Hide margin note on smaller screens, show below chart instead */ @media (max-width: 1100px) { .postit-note { position: relative; top: auto; right: auto; margin: 2rem auto 1rem; width: 280px; transform: rotate(-1deg); } } /* Hide total messages legend on mobile */ @media (max-width: 600px) { .legend-item:nth-child(3) { display: none; } } ================================================ FILE: scripts/README.md ================================================ # Claude "Absolutely Right" Counter Script Track patterns like "You're absolutely right!" in Claude Code conversations. ## Usage ```bash # Backfill historical data python3 backfill.py --upload http://localhost:3003 [SECRET] # Real-time monitoring (will backfill all of today's data) python3 watcher.py --upload http://localhost:3003 [SECRET] ``` Backfill asks for confirmation before bulk uploads. ## Patterns Defined in `claude_counter.py`: - **absolutely**: `You(?:'re| are) absolutely right` - **right**: `You(?:'re| are) right` Add patterns by editing the `PATTERNS` dict: ```python PATTERNS = { "absolutely": r"You(?:'re| are) absolutely right", "right": r"You(?:'re| are) right", "perfect": r"Perfect!" # New pattern } ``` ## Environment ```bash export CLAUDE_PROJECTS=/path/to/projects # Default: ~/.claude/projects ``` ## Data Files Stored in `~/.absolutelyright/`: - `daily_{pattern}_counts.json` - Per-pattern daily counts - `project_counts.json` - Project breakdown - `processed_ids.json` - Processed message IDs ## API Uploads to `/api/set`: ```json { "day": "2024-01-15", "count": 5, "right_count": 12, "secret": "optional_secret" } ``` ================================================ FILE: scripts/backfill.py ================================================ #!/usr/bin/env python3 import sys from claude_counter import * def scan_all_projects(): compiled_patterns = { name: re.compile(pattern, re.IGNORECASE) for name, pattern in PATTERNS.items() } daily_counts = {name: defaultdict(int) for name in PATTERNS} total_counts = {name: 0 for name in PATTERNS} project_breakdown = defaultdict(lambda: defaultdict(int)) total_messages_per_day = defaultdict(int) seen_message_ids = set() # Track processed message IDs to avoid duplicates if not os.path.exists(CLAUDE_PROJECTS_BASE): print(f"Error: Projects directory not found at {CLAUDE_PROJECTS_BASE}") print("Set CLAUDE_PROJECTS env variable to your Claude projects path") return daily_counts, project_breakdown, total_messages_per_day print("Scanning all Claude projects...") for project_dir in Path(CLAUDE_PROJECTS_BASE).iterdir(): if project_dir.is_dir() and not project_dir.name.startswith("."): project_name = get_project_display_name(project_dir.name) for jsonl_file in project_dir.glob("*.jsonl"): try: with open(jsonl_file, "r") as f: for line in f: try: entry = json.loads(line) result = process_message_entry(entry, compiled_patterns) if not result: continue msg_id = result["msg_id"] # Skip if we've already processed this message if msg_id in seen_message_ids: continue seen_message_ids.add(msg_id) date_str = result["date_str"] # Count total assistant messages total_messages_per_day[date_str] += 1 # Count pattern matches (once per message, not per text block) message_patterns = set() for text, matched_patterns in result["text_blocks"]: message_patterns.update(matched_patterns.keys()) for pattern_name in message_patterns: daily_counts[pattern_name][date_str] += 1 total_counts[pattern_name] += 1 if pattern_name == "absolutely": project_breakdown[date_str][project_name] += 1 except: continue except: pass for name, count in total_counts.items(): unique_days = len(daily_counts[name]) print(f"Found {count} '{name}' across {unique_days} days") return daily_counts, project_breakdown, total_messages_per_day def main(): """Main backfill process""" print("Claude Pattern Counter Backfill") print("=" * 50) # Check for upload parameters api_url = None secret = None for i, arg in enumerate(sys.argv): if arg == "--upload" and i + 2 < len(sys.argv): api_url = sys.argv[i + 1] secret = sys.argv[i + 2] break elif arg == "--upload" and i + 1 < len(sys.argv): api_url = sys.argv[i + 1] break # Show current settings print(f"Projects directory: {CLAUDE_PROJECTS_BASE}") print("Tracking patterns:") for name, pattern in PATTERNS.items(): print(f" {name}: {pattern}") if api_url: print(f"Will upload to: {api_url}") print("-" * 50) # Scan all projects daily_counts, project_breakdown, total_messages_per_day = scan_all_projects() if not any(daily_counts.values()): print("No data found.") return # Get all dates that have any data (pattern matches OR total messages) all_dates = set() for pattern_counts in daily_counts.values(): all_dates.update(pattern_counts.keys()) all_dates.update(total_messages_per_day.keys()) sorted_dates = sorted(all_dates) # Skip the first day (exclude from display and upload) if sorted_dates: first_day = sorted_dates[0] sorted_dates = sorted_dates[1:] print(f"\nSkipping first day ({first_day}) from output and upload") print("\nDaily counts:") print("-" * 80) # Output format based on arguments if "--json" in sys.argv: # JSON output for piping to other tools output = {pattern: dict(counts) for pattern, counts in daily_counts.items()} output["by_date"] = { date: dict(project_breakdown[date]) for date in sorted_dates if date in project_breakdown } print(json.dumps(output, indent=2)) else: # Human-readable output for date in sorted_dates: abs_count = daily_counts["absolutely"].get(date, 0) right_count = daily_counts["right"].get(date, 0) total_msgs = total_messages_per_day.get(date, 0) projects = project_breakdown.get(date, {}) project_info = "" if len(projects) == 1: project_info = f" (in {list(projects.keys())[0]})" elif len(projects) > 1: # Find project with highest count top_project = max(projects.items(), key=lambda x: x[1])[0] other_count = len(projects) - 1 if other_count == 1: project_info = f" (in {top_project} and 1 other project)" else: project_info = ( f" (in {top_project} and {other_count} other projects)" ) print( f"{date}: absolutely={abs_count:3d}, right={right_count:3d}, total={total_msgs:3d}{project_info}" ) print("-" * 50) print(f"Total 'absolutely right': {sum(daily_counts['absolutely'].values())}") print(f"Total 'right': {sum(daily_counts['right'].values())}") # Upload to API if requested (only absolutely and right for now) if api_url: print("\n" + "-" * 50) total_to_upload = sum( 1 for date in sorted_dates if daily_counts["absolutely"].get(date, 0) > 0 or daily_counts["right"].get(date, 0) > 0 or total_messages_per_day.get(date, 0) > 0 ) print(f"Found {total_to_upload} days with data to upload.") confirm = input("Continue with upload? (y/N): ").strip().lower() if confirm not in ["y", "yes"]: print("Upload cancelled.") return print("Uploading to API...") success = 0 failed = 0 for date in sorted_dates: abs_count = daily_counts["absolutely"].get(date, 0) right_count = daily_counts["right"].get(date, 0) total_msgs = total_messages_per_day.get(date, 0) if abs_count > 0 or right_count > 0 or total_msgs > 0: upload_text = f" Uploading {date}: absolutely={abs_count:2d}, right={right_count:2d}, total={total_msgs:3d}..." print(f"{upload_text:<75}", end="") result = upload_to_api( api_url, secret, date, abs_count, right_count, total_msgs ) if result == True: print("✓") success += 1 elif result == "STOP": print("✗") failed += 1 break else: print("✗") failed += 1 print("-" * 50) print(f"Upload complete: {success} successful, {failed} failed") if success > 0: print(f"View at: {api_url}") if __name__ == "__main__": main() ================================================ FILE: scripts/claude_counter.py ================================================ #!/usr/bin/env python3 import os import json import re import urllib.request from datetime import datetime, timezone from pathlib import Path from collections import defaultdict CLAUDE_PROJECTS_BASE = os.environ.get( "CLAUDE_PROJECTS", os.path.expanduser("~/.claude/projects") ) DATA_DIR = os.path.expanduser("~/.absolutelyright") PATTERNS = { "absolutely": r"You(?:'re| are) absolutely right", "right": r"You(?:'re| are) right", } # Example additional patterns (uncomment to track): # "issue": r"I see the issue", # "perfect": r"Perfect!", # "excellent": r"Excellent!" def upload_to_api(api_url, secret, date_str, count, right_count=None, total_messages=None): """Upload counts to API. Returns True/False/'STOP'""" if not api_url: return False try: data = {"day": date_str, "count": count} if secret: data["secret"] = secret if right_count is not None: data["right_count"] = right_count if total_messages is not None: data["total_messages"] = total_messages req = urllib.request.Request( f"{api_url}/api/set", data=json.dumps(data).encode("utf-8"), headers={"Content-Type": "application/json"}, ) with urllib.request.urlopen(req, timeout=5) as response: if response.status == 200: return True elif response.status == 401: print(f"\n🚫 AUTHORIZATION FAILED!") print(f" Check your secret key and try again.") return "STOP" else: print(f" API error for {date_str}: {response.status}") return False except urllib.error.HTTPError as e: if e.code == 401: print(f"\n🚫 AUTHORIZATION FAILED!") print(f" Check your secret key and try again.") return "STOP" else: print(f" API error for {date_str}: HTTP {e.code}") return False except Exception as e: print(f" API error for {date_str}: {e}") return False def process_message_entry(entry, compiled_patterns): """ Process a single JSONL entry and extract message info + pattern matches. Returns dict with: - msg_id: The message UUID - date_str: Date in YYYY-MM-DD format - text_blocks: List of (text, matched_patterns) tuples Returns None if entry should be skipped. """ if entry.get("type") != "assistant": return None msg_id = entry.get("uuid") or entry.get("requestId") if not msg_id: return None # Parse timestamp timestamp = entry.get("timestamp", "") if timestamp: entry_time = datetime.fromisoformat(timestamp.replace("Z", "+00:00")) date_str = entry_time.strftime("%Y-%m-%d") else: date_str = get_utc_today() # Extract text blocks and check for pattern matches text_blocks = [] message = entry.get("message", {}) if "content" in message: for content_item in message.get("content", []): if isinstance(content_item, dict) and content_item.get("type") == "text": text = content_item.get("text", "") # Check for pattern matches matched_patterns = {} for pattern_name, pattern_regex in compiled_patterns.items(): if pattern_regex.search(text): matched_patterns[pattern_name] = True text_blocks.append((text, matched_patterns)) return { "msg_id": msg_id, "date_str": date_str, "text_blocks": text_blocks, } def get_project_display_name(project_dir_name): name = project_dir_name for prefix in ["-Users-", "-home-", "-var-"]: if name.startswith(prefix): parts = name.split("-", 3) if len(parts) > 3: name = parts[3] break return name def get_utc_today(): """Get today's date in UTC (same format as JSONL timestamps)""" return datetime.now(timezone.utc).strftime("%Y-%m-%d") def ensure_data_dir(): os.makedirs(DATA_DIR, exist_ok=True) ================================================ FILE: scripts/watcher.py ================================================ #!/usr/bin/env python3 import sys import time from claude_counter import * # Additional data files for watcher PROJECT_COUNTS_FILE = os.path.join(DATA_DIR, "project_counts.json") PROCESSED_IDS_FILE = os.path.join(DATA_DIR, "processed_ids.json") def load_processed_ids(): """Load set of already processed message IDs""" if os.path.exists(PROCESSED_IDS_FILE): try: with open(PROCESSED_IDS_FILE, "r") as f: return set(json.load(f)) except: pass return set() def save_processed_ids(ids_set): """Save processed message IDs""" with open(PROCESSED_IDS_FILE, "w") as f: json.dump(list(ids_set), f) def load_project_counts(): """Load per-project counts""" if os.path.exists(PROJECT_COUNTS_FILE): try: with open(PROJECT_COUNTS_FILE, "r") as f: return json.load(f) except: pass return {} def save_project_counts(counts): """Save per-project counts""" with open(PROJECT_COUNTS_FILE, "w") as f: json.dump(counts, f, indent=2) def load_pattern_counts(pattern_name): """Load daily counts for a specific pattern""" filename = os.path.join(DATA_DIR, f"daily_{pattern_name}_counts.json") if os.path.exists(filename): try: with open(filename, "r") as f: return json.load(f) except: pass return {} def save_pattern_counts(pattern_name, counts): """Save daily counts for a specific pattern""" filename = os.path.join(DATA_DIR, f"daily_{pattern_name}_counts.json") with open(filename, "w") as f: json.dump(counts, f, indent=2) def load_total_messages_counts(): """Load daily counts of total assistant messages""" filename = os.path.join(DATA_DIR, "daily_total_messages.json") if os.path.exists(filename): try: with open(filename, "r") as f: return json.load(f) except: pass return {} def save_total_messages_counts(counts): """Save daily counts of total assistant messages""" filename = os.path.join(DATA_DIR, "daily_total_messages.json") with open(filename, "w") as f: json.dump(counts, f, indent=2) def backfill_today_total_messages(): """Scan all projects and count today's total messages (deduplicated)""" today_utc = get_utc_today() seen_message_ids = set() if not os.path.exists(CLAUDE_PROJECTS_BASE): return 0 print(f"Backfilling today's ({today_utc}) total message count...") for project_dir in Path(CLAUDE_PROJECTS_BASE).iterdir(): if project_dir.is_dir() and not project_dir.name.startswith("."): for jsonl_file in project_dir.glob("*.jsonl"): try: with open(jsonl_file, "r") as f: for line in f: try: entry = json.loads(line) if entry.get("type") == "assistant": msg_id = entry.get("uuid") or entry.get("requestId") if not msg_id: continue timestamp = entry.get("timestamp", "") if timestamp: entry_time = datetime.fromisoformat( timestamp.replace("Z", "+00:00") ) date_str = entry_time.strftime("%Y-%m-%d") if date_str == today_utc and msg_id not in seen_message_ids: seen_message_ids.add(msg_id) except: continue except: pass return len(seen_message_ids) def backfill_today_patterns(compiled_patterns, processed_ids, project_counts): """Scan all projects for today's pattern matches and mark them as processed""" today_utc = get_utc_today() pattern_matches = {name: 0 for name in PATTERNS} seen_today = set() # Track messages seen during this backfill to avoid duplicates if not os.path.exists(CLAUDE_PROJECTS_BASE): return pattern_matches print(f"Backfilling today's ({today_utc}) pattern matches...") for project_dir in Path(CLAUDE_PROJECTS_BASE).iterdir(): if project_dir.is_dir() and not project_dir.name.startswith("."): project_name = get_project_display_name(project_dir.name) for jsonl_file in project_dir.glob("*.jsonl"): try: with open(jsonl_file, "r") as f: for line in f: try: entry = json.loads(line) result = process_message_entry(entry, compiled_patterns) if not result: continue msg_id = result["msg_id"] date_str = result["date_str"] # Only process today's messages if date_str != today_utc: continue # Skip if already counted in this backfill (deduplication) if msg_id in seen_today: continue seen_today.add(msg_id) # Mark as processed for the main loop processed_ids.add(msg_id) # Process text blocks for pattern matches (count once per message) message_patterns = set() for text, matched_patterns in result["text_blocks"]: message_patterns.update(matched_patterns.keys()) for pattern_name in message_patterns: pattern_matches[pattern_name] += 1 # Update project counts (only for "absolutely") if pattern_name == "absolutely": if project_name not in project_counts: project_counts[project_name] = 0 project_counts[project_name] += 1 except: continue except: pass return pattern_matches def main(): """Main watcher loop""" ensure_data_dir() # Check for upload parameters from command line api_url = None api_secret = None for i, arg in enumerate(sys.argv): if arg == "--upload" and i + 1 < len(sys.argv): api_url = sys.argv[i + 1] if i + 2 < len(sys.argv) and not sys.argv[i + 2].startswith("--"): api_secret = sys.argv[i + 2] break print("Claude Pattern Watcher") print("=" * 50) print(f"Watching: {CLAUDE_PROJECTS_BASE}") print(f"Data directory: {DATA_DIR}") print("Tracking patterns:") for name, pattern in PATTERNS.items(): print(f" {name}: {pattern}") if api_url: print(f"API URL: {api_url}") print("-" * 50) # Compile patterns compiled_patterns = { name: re.compile(pattern, re.IGNORECASE) for name, pattern in PATTERNS.items() } # Initialize processed_ids = load_processed_ids() project_counts = load_project_counts() pattern_counts = {name: load_pattern_counts(name) for name in PATTERNS} total_messages_counts = load_total_messages_counts() # Backfill today's total message count on startup today_utc = get_utc_today() today_total_actual = backfill_today_total_messages() total_messages_counts[today_utc] = today_total_actual save_total_messages_counts(total_messages_counts) print(f"Found {today_total_actual} total messages for today") # Backfill today's pattern matches on startup (replaces today's counts) # Reset project_counts since we're doing a full recount project_counts = {} backfill_pattern_matches = backfill_today_patterns(compiled_patterns, processed_ids, project_counts) for pattern_name, count in backfill_pattern_matches.items(): if count > 0: pattern_counts[pattern_name][today_utc] = count # SET, not ADD save_pattern_counts(pattern_name, pattern_counts[pattern_name]) print(f"Found {count} '{pattern_name}' matches for today") # Save processed IDs and project counts after backfill save_processed_ids(processed_ids) save_project_counts(project_counts) # Upload today's data on startup if API is configured if api_url: today_utc = get_utc_today() today_local = datetime.now().strftime("%Y-%m-%d") today_abs = pattern_counts["absolutely"].get(today_utc, 0) today_right = pattern_counts["right"].get(today_utc, 0) today_total = total_messages_counts.get(today_utc, 0) timezone_note = "" if today_utc != today_local: timezone_note = f" (UTC {today_utc}, local {today_local})" print( f"Uploading today's counts{timezone_note}: absolutely={today_abs}, right={today_right}, total_messages={today_total}" ) if upload_to_api(api_url, api_secret, today_utc, today_abs, today_right, today_total): print(" ✓ Upload successful") else: print(" ✗ Upload failed") print("-" * 50) if not os.path.exists(CLAUDE_PROJECTS_BASE): print(f"Error: Claude projects directory not found at {CLAUDE_PROJECTS_BASE}") print("Set CLAUDE_PROJECTS environment variable to your Claude projects path") return try: while True: new_matches_by_pattern = {name: 0 for name in PATTERNS} new_total_messages = 0 for project_dir in Path(CLAUDE_PROJECTS_BASE).iterdir(): if project_dir.is_dir() and not project_dir.name.startswith("."): project_name = get_project_display_name(project_dir.name) # Scan all JSONL files in this project for jsonl_file in project_dir.glob("*.jsonl"): # Single pass: count total messages and check for pattern matches try: with open(jsonl_file, "r") as f: for line in f: try: entry = json.loads(line) result = process_message_entry(entry, compiled_patterns) if not result: continue msg_id = result["msg_id"] date_str = result["date_str"] if msg_id in processed_ids: continue # Mark as processed processed_ids.add(msg_id) # Update total messages count if date_str not in total_messages_counts: total_messages_counts[date_str] = 0 total_messages_counts[date_str] += 1 new_total_messages += 1 # Process text blocks for pattern matches (count once per message) message_patterns = set() first_match_text = None for text, matched_patterns in result["text_blocks"]: if matched_patterns: message_patterns.update(matched_patterns.keys()) if first_match_text is None: first_match_text = text if message_patterns: for pattern_name in message_patterns: new_matches_by_pattern[pattern_name] += 1 # Update daily counts if date_str not in pattern_counts[pattern_name]: pattern_counts[pattern_name][date_str] = 0 pattern_counts[pattern_name][date_str] += 1 # Update project counts (only for "absolutely") if pattern_name == "absolutely": if project_name not in project_counts: project_counts[project_name] = 0 project_counts[project_name] += 1 # Print notification (once per message) match_types = list(message_patterns) print( f"[{datetime.now().strftime('%H:%M:%S')}] {', '.join(match_types).upper()} in {project_name}: {first_match_text.strip()[:100]}" ) except: continue except: pass if any(new_matches_by_pattern.values()) or new_total_messages > 0: # Save all state save_project_counts(project_counts) save_processed_ids(processed_ids) for pattern_name, counts in pattern_counts.items(): save_pattern_counts(pattern_name, counts) save_total_messages_counts(total_messages_counts) updates = [ f"{name}: +{count}" for name, count in new_matches_by_pattern.items() if count > 0 ] if new_total_messages > 0: updates.append(f"total_messages: +{new_total_messages}") print(f"Updated: {', '.join(updates)}") # Upload to API if configured if api_url: today_utc = get_utc_today() today_abs = pattern_counts["absolutely"].get(today_utc, 0) today_right = pattern_counts["right"].get(today_utc, 0) today_total = total_messages_counts.get(today_utc, 0) if upload_to_api( api_url, api_secret, today_utc, today_abs, today_right, today_total ): print( f" ✓ Uploaded to API: absolutely={today_abs}, right={today_right}, total_messages={today_total}" ) time.sleep(int(os.environ.get("CHECK_INTERVAL", "2"))) except KeyboardInterrupt: print("\n" + "-" * 50) print("Stopping watcher...") for name in PATTERNS: total = sum(pattern_counts[name].values()) print(f"Final '{name}' count: {total}") if __name__ == "__main__": main() ================================================ FILE: src/main.rs ================================================ use axum::{ http::{header, HeaderValue, Request}, middleware, response::Response, routing::{get, post}, Json, Router, }; use chrono::Utc; use serde::{Deserialize, Serialize}; use std::env; use std::fs::OpenOptions; use std::io::Write; use std::{collections::HashMap, net::SocketAddr, sync::Arc}; use tokio_rusqlite::Connection; use tower_http::services::ServeDir; use tower_http::set_header::SetResponseHeaderLayer; #[derive(Clone, Debug, Serialize, Deserialize)] struct DayCount { day: String, count: u32, right_count: u32, total_messages: u32, } #[tokio::main] async fn main() { // Initialize SQLite database - use /app/data on Fly.io, local file otherwise let db_path = if std::path::Path::new("/app/data").exists() { "/app/data/counts.db" } else { "counts.db" }; let db = Connection::open(db_path).await.unwrap(); // Create table if it doesn't exist db.call(|conn| { conn.execute( "CREATE TABLE IF NOT EXISTS day_counts ( day TEXT PRIMARY KEY, count INTEGER NOT NULL, right_count INTEGER DEFAULT 0 )", [], )?; // Add right_count column if it doesn't exist (for existing databases) let _ = conn.execute( "ALTER TABLE day_counts ADD COLUMN right_count INTEGER DEFAULT 0", [], ); let _ = conn.execute( "ALTER TABLE day_counts ADD COLUMN total_messages INTEGER DEFAULT 0", [], ); Ok(()) }) .await .unwrap(); let db = Arc::new(db); // Build router let app = Router::new() .route("/api/today", get(get_today)) .route("/api/history", get(get_history)) .route("/api/set", post(set_day)) // Serve static files from ./frontend with cache control headers .nest_service( "/", ServeDir::new("frontend").append_index_html_on_directories(true), ) .layer(SetResponseHeaderLayer::overriding( header::CACHE_CONTROL, HeaderValue::from_static("no-cache, no-store, must-revalidate"), )) .layer(SetResponseHeaderLayer::overriding( header::PRAGMA, HeaderValue::from_static("no-cache"), )) .layer(SetResponseHeaderLayer::overriding( header::EXPIRES, HeaderValue::from_static("0"), )) .layer(middleware::from_fn(log_pageview)) .with_state(db); let addr = SocketAddr::from(([0, 0, 0, 0], 3003)); println!("listening on http://{addr}"); axum::serve(tokio::net::TcpListener::bind(addr).await.unwrap(), app) .await .unwrap(); } async fn get_today( state: axum::extract::State>, ) -> ( [(header::HeaderName, HeaderValue); 1], Json>, ) { let today = Utc::now().format("%Y-%m-%d").to_string(); let (count, right_count, total_messages) = state .call(move |conn| { let mut stmt = conn.prepare("SELECT count, right_count, total_messages FROM day_counts WHERE day = ?1")?; let result = stmt .query_row([&today], |row| { Ok(( row.get::<_, u32>(0)?, row.get::<_, u32>(1).unwrap_or(0), row.get::<_, u32>(2).unwrap_or(0) )) }) .unwrap_or((0, 0, 0)); Ok(result) }) .await .unwrap(); let mut map = HashMap::new(); map.insert("count", count); map.insert("right_count", right_count); map.insert("total_messages", total_messages); // Cache for 1 minutes ( [( header::CACHE_CONTROL, HeaderValue::from_static("public, max-age=60"), )], Json(map), ) } async fn get_history( state: axum::extract::State>, ) -> ([(header::HeaderName, HeaderValue); 1], Json>) { let history = state .call(|conn| { let mut stmt = conn.prepare("SELECT day, count, right_count, total_messages FROM day_counts ORDER BY day")?; let days = stmt .query_map([], |row| { Ok(DayCount { day: row.get(0)?, count: row.get(1)?, right_count: row.get(2).unwrap_or(0), total_messages: row.get(3).unwrap_or(0), }) })? .collect::, _>>()?; Ok(days) }) .await .unwrap(); // Cache for 5 minutes ( [( header::CACHE_CONTROL, HeaderValue::from_static("public, max-age=300"), )], Json(history), ) } #[derive(Deserialize)] struct SetRequest { day: String, count: u32, right_count: Option, total_messages: Option, secret: Option, } async fn set_day( state: axum::extract::State>, Json(payload): Json, ) -> Result, (axum::http::StatusCode, &'static str)> { // Check secret if ABSOLUTELYRIGHT_SECRET is set if let Ok(expected_secret) = env::var("ABSOLUTELYRIGHT_SECRET") { match payload.secret { Some(provided_secret) if provided_secret == expected_secret => { // Secret matches, continue } _ => { // No secret provided or wrong secret return Err((axum::http::StatusCode::UNAUTHORIZED, "Invalid secret")); } } } // If ABSOLUTELYRIGHT_SECRET is not set, allow access (for local dev) let right_count = payload.right_count.unwrap_or(0); let total_messages = payload.total_messages.unwrap_or(0); state .call(move |conn| { conn.execute( "INSERT INTO day_counts (day, count, right_count, total_messages) VALUES (?1, ?2, ?3, ?4) ON CONFLICT(day) DO UPDATE SET count = ?2, right_count = ?3, total_messages = ?4", [ &payload.day, &payload.count.to_string(), &right_count.to_string(), &total_messages.to_string(), ], )?; Ok(()) }) .await .unwrap(); Ok(Json("ok")) } async fn log_pageview( req: Request, next: middleware::Next, ) -> Response { let path = req.uri().path().to_string(); let method = req.method().to_string(); // Only log GET requests to main page if method == "GET" && (path == "/" || path == "/index.html") { let timestamp = Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(); let log_entry = format!("{timestamp} - Pageview: {path}\n"); // Append to log file - use /app/data on Fly.io, local file otherwise let log_path = if std::path::Path::new("/app/data").exists() { "/app/data/pageviews.log" } else { "pageviews.log" }; if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(log_path) { let _ = file.write_all(log_entry.as_bytes()); } } next.run(req).await }