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:
<img width="1100" height="1200" alt="screenshot-rocks" src="https://github.com/user-attachments/assets/5464b87b-edb6-460c-b625-d06c33684d9a" />
## 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
================================================
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>You're absolutely right!</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="Tracking how often Claude Code tells me I'm absolutely right">
<!-- Open Graph -->
<meta property="og:title" content="I'm absolutely right!">
<meta property="og:description" content="Tracking how often Claude Code tells me I'm absolutely right">
<meta property="og:image" content="https://absolutelyright.lol/og-image.png">
<meta property="og:url" content="https://absolutelyright.lol">
<meta property="og:type" content="website">
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="I'm absolutely right!">
<meta name="twitter:description" content="Tracking how often Claude Code tells me I'm absolutely right">
<meta name="twitter:image" content="https://absolutelyright.lol/og-image.png">
<link rel="stylesheet" href="style.css">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="icon" href="/favicon.ico" type="image/x-icon">
<link href="https://fonts.googleapis.com/css2?family=Gaegu:wght@400;700&display=swap" rel="stylesheet">
<script src="https://cdn.counter.dev/script.js" data-id="4fbcc31f-5f37-40c3-8788-8fb56c79209b" data-utcoffset="3"></script>
</head>
<body>
<main>
<h1 id="title-active">I'm <span class="highlight">absolutely right!</span></h1>
<h1 id="title-zero" style="display: none;">I<span class="correction"><span class="correction-new">was</span>'m</span> <span class="highlight">absolutely right!</span></h1>
<p class="subtitle" style="opacity: 0">Claude Code said it <span id="today-inline">0</span> <span id="times-label">times</span> this week</p>
<p id="right-count" class="right-count"></p>
<section class="chart-section">
<div class="postit-note">
<span class="postit-date">Dec 25, 2025</span>
<p>C'est fini!</p>
<br/>
<p>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.</p>
<p>Cheers,</p>
<br/>
<p><a style="text-decoration: none;" href="https://linkedin.com/in/yoavf">Yoav</a></p>
</div>
<div id="chart"></div>
<div class="chart-legend">
<span class="legend-item">
<span class="legend-color" style="background: coral;"></span>
Absolutely right
</span>
<span class="legend-item">
<span class="legend-color" style="background: skyblue;"></span>
Just right
</span>
<span class="legend-item">
<span class="legend-color" style="background: #6b7280; opacity: 0.7; border-style: dashed;"></span>
Total assistant messages
<span class="info-icon-wrapper">
<span class="info-icon">?</span>
<span class="info-tooltip">Uses square root scale to show both small and large values clearly</span>
</span>
</span>
</div>
</section>
</main>
<footer>
<p><a href="https://github.com/yoavf/absolutelyright" target="_blank">github</a> • made with <span class="wiggle">impostor syndrome</span> by <a href="https://github.com/yoavf" target="_blank">@yoavf</a></p>
</footer>
<script type="module" src="frontend.js"></script>
</body>
</html>
================================================
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<Arc<Connection>>,
) -> (
[(header::HeaderName, HeaderValue); 1],
Json<HashMap<&'static str, u32>>,
) {
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<Arc<Connection>>,
) -> ([(header::HeaderName, HeaderValue); 1], Json<Vec<DayCount>>) {
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::<Result<Vec<_>, _>>()?;
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<u32>,
total_messages: Option<u32>,
secret: Option<String>,
}
async fn set_day(
state: axum::extract::State<Arc<Connection>>,
Json(payload): Json<SetRequest>,
) -> Result<Json<&'static str>, (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<axum::body::Body>,
next: middleware::Next,
) -> Response<axum::body::Body> {
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
}
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
SYMBOL INDEX (37 symbols across 5 files)
FILE: frontend/frontend.js
constant CHART_ANNOTATIONS (line 2) | const CHART_ANNOTATIONS = [
function parseLocalDate (line 9) | function parseLocalDate(dateStr) {
function formatDate (line 15) | function formatDate(date) {
function getWeekKey (line 23) | function getWeekKey(dateStr) {
function getWeekStart (line 33) | function getWeekStart(dateStr) {
function aggregateByWeek (line 42) | function aggregateByWeek(history) {
function aggregateByBiWeek (line 71) | function aggregateByBiWeek(history) {
function fetchThisWeek (line 109) | async function fetchThisWeek(animate = false) {
function fetchHistory (line 191) | async function fetchHistory() {
function drawChart (line 229) | function drawChart(history) {
function addTotalMessagesBars (line 318) | function addTotalMessagesBars(chartElement, displayHistory, isMobile, wi...
function addChartAnnotations (line 505) | function addChartAnnotations(chartElement, displayHistory, dailyHistory,...
FILE: scripts/backfill.py
function scan_all_projects (line 6) | def scan_all_projects():
function main (line 73) | def main():
FILE: scripts/claude_counter.py
function upload_to_api (line 26) | def upload_to_api(api_url, secret, date_str, count, right_count=None, to...
function process_message_entry (line 69) | def process_message_entry(entry, compiled_patterns):
function get_project_display_name (line 117) | def get_project_display_name(project_dir_name):
function get_utc_today (line 128) | def get_utc_today():
function ensure_data_dir (line 133) | def ensure_data_dir():
FILE: scripts/watcher.py
function load_processed_ids (line 11) | def load_processed_ids():
function save_processed_ids (line 22) | def save_processed_ids(ids_set):
function load_project_counts (line 28) | def load_project_counts():
function save_project_counts (line 39) | def save_project_counts(counts):
function load_pattern_counts (line 45) | def load_pattern_counts(pattern_name):
function save_pattern_counts (line 57) | def save_pattern_counts(pattern_name, counts):
function load_total_messages_counts (line 64) | def load_total_messages_counts():
function save_total_messages_counts (line 76) | def save_total_messages_counts(counts):
function backfill_today_total_messages (line 83) | def backfill_today_total_messages():
function backfill_today_patterns (line 122) | def backfill_today_patterns(compiled_patterns, processed_ids, project_co...
function main (line 186) | def main():
FILE: src/main.rs
type DayCount (line 19) | struct DayCount {
function main (line 27) | async fn main() {
function get_today (line 94) | async fn get_today(
function get_history (line 135) | async fn get_history(
type SetRequest (line 168) | struct SetRequest {
function set_day (line 176) | async fn set_day(
function log_pageview (line 216) | async fn log_pageview(
Condensed preview — 15 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (84K chars).
[
{
"path": ".gitignore",
"chars": 998,
"preview": "# Created by https://www.toptal.com/developers/gitignore/api/rust,macos\n# Edit at https://www.toptal.com/developers/giti"
},
{
"path": "CLAUDE.md",
"chars": 2029,
"preview": "# CLAUDE.md\n\nThis file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.\n\n## "
},
{
"path": "Cargo.toml",
"chars": 372,
"preview": "[package]\nname = \"absolutelyright\"\nversion = \"0.2.0\"\nedition = \"2021\"\n\n[dependencies]\naxum = \"0.7\"\ntokio = { version = \""
},
{
"path": "Dockerfile",
"chars": 536,
"preview": "# Build stage\nFROM rust:1.80 as builder\n\nWORKDIR /app\nCOPY Cargo.toml Cargo.lock ./\nCOPY src ./src\n\nRUN cargo build --re"
},
{
"path": "LICENSE",
"chars": 1067,
"preview": "MIT License\n\nCopyright (c) 2025 Yoav Farhi\n\nPermission is hereby granted, free of charge, to any person obtaining a copy"
},
{
"path": "README.md",
"chars": 1062,
"preview": "# absolutelyright.lol\n\nA scientifically rigorous tracking system for how often Claude Code validates my life choices.\n\nT"
},
{
"path": "fly.toml",
"chars": 905,
"preview": "# fly.toml app configuration file\n#\n# See https://fly.io/docs/reference/configuration/ for information about how to use "
},
{
"path": "frontend/frontend.js",
"chars": 24129,
"preview": "// Chart annotations configuration\nconst CHART_ANNOTATIONS = [\n\t{ date: '2025-09-29', label: 'Sonnet 4.5' },\n\t{ date: '2"
},
{
"path": "frontend/index.html",
"chars": 3497,
"preview": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <title>You're absolutely right!</title>\n <meta name="
},
{
"path": "frontend/style.css",
"chars": 6697,
"preview": "* {\n\tbox-sizing: border-box;\n}\n\nbody {\n\tmargin: 0;\n\tfont-family: \"Gaegu\", \"Comic Sans MS\", \"Comic Sans\", \"Marker Felt\", "
},
{
"path": "scripts/README.md",
"chars": 1187,
"preview": "# Claude \"Absolutely Right\" Counter Script\n\nTrack patterns like \"You're absolutely right!\" in Claude Code conversations."
},
{
"path": "scripts/backfill.py",
"chars": 8234,
"preview": "#!/usr/bin/env python3\nimport sys\nfrom claude_counter import *\n\n\ndef scan_all_projects():\n compiled_patterns = {\n "
},
{
"path": "scripts/claude_counter.py",
"chars": 4182,
"preview": "#!/usr/bin/env python3\nimport os\nimport json\nimport re\nimport urllib.request\nfrom datetime import datetime, timezone\nfro"
},
{
"path": "scripts/watcher.py",
"chars": 15928,
"preview": "#!/usr/bin/env python3\nimport sys\nimport time\nfrom claude_counter import *\n\n# Additional data files for watcher\nPROJECT_"
},
{
"path": "src/main.rs",
"chars": 7430,
"preview": "use axum::{\n http::{header, HeaderValue, Request},\n middleware,\n response::Response,\n routing::{get, post},\n"
}
]
About this extraction
This page contains the full source code of the yoavf/absolutelyright GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 15 files (76.4 KB), approximately 19.7k tokens, and a symbol index with 37 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.