commit 23eb7d51259884be8adc7b8bc755e6169fa4ce12 Author: Greg Date: Mon Sep 1 23:06:39 2025 +0200 First commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ecd7b31 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +node_modules +npm-debug.log +.git +.gitignore +README.md +Dockerfile +.dockerignore +dist +.env +.env.local +.env.development.local +.env.test.local +.env.production.local +.DS_Store \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a1ee93f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,31 @@ +# Build stage +FROM node:18-alpine as builder + +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# Install dependencies +RUN npm ci --only=production + +# Copy source code +COPY . . + +# Build the application +RUN npm run build + +# Production stage +FROM nginx:alpine + +# Copy built app from builder stage +COPY --from=builder /app/dist /usr/share/nginx/html + +# Copy nginx configuration +COPY nginx.conf /etc/nginx/conf.d/default.conf + +# Expose port 80 +EXPOSE 80 + +# Start nginx +CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..d980dd5 --- /dev/null +++ b/README.md @@ -0,0 +1,131 @@ +# Year in Pixels - Mood Tracker + +A React-based web application for tracking daily moods throughout the year using a visual "Year in Pixels" format. Track your mood with a simple Red-Amber-Green (RAG) system and visualize your year at a glance. + +## Features + +### 🎨 Visual Mood Tracking +- **Year in Pixels Grid**: 12 columns (months) Γ— 31 rows (days) visual layout +- **RAG System**: Simple 3-color mood tracking (Red, Amber, Green) +- **Interactive Cells**: Click to cycle through moods or use paint mode for bulk editing +- **Today Highlight**: Current date is highlighted when viewing the current year + +### ✨ User Experience +- **Drag Painting**: Select a color and drag across multiple days for quick entry +- **Responsive Design**: Works on desktop, tablet, and mobile devices +- **Dark Mode Support**: Automatic dark/light theme adaptation +- **Print Friendly**: Built-in print functionality for physical copies + +### πŸ’Ύ Data Management +- **Local Storage**: All data persists locally in your browser +- **Export Options**: Download your data as JSON or CSV +- **Import Support**: Upload previously exported JSON files +- **Year Navigation**: Switch between different years with preserved data + +### βš™οΈ Customization +- **Toggle Day Numbers**: Show/hide day numbers in cells +- **Compact Mode**: Reduced spacing for smaller displays +- **Show/Hide Legend**: Toggle mood color legend display +- **Year Reset**: Clear all entries for the current year + +## Quick Start + +### Local Development + +1. **Clone and Install** + ```bash + git clone + cd mood_tracker + npm install + ``` + +2. **Start Development Server** + ```bash + npm run dev + ``` + Open [http://localhost:5173](http://localhost:5173) in your browser. + +3. **Build for Production** + ```bash + npm run build + npm run preview + ``` + +### Docker Deployment + +**Using Docker Compose (Recommended)** +```bash +docker-compose up -d +``` +Access the app at [http://localhost:8080](http://localhost:8080) + +**Using Docker directly** +```bash +docker build -t mood-tracker . +docker run -p 8080:80 mood-tracker +``` + +## How to Use + +1. **Set Your Year**: Use the year selector to choose the year you want to track +2. **Track Your Mood**: Click on any day to cycle through moods (Empty β†’ Green β†’ Amber β†’ Red β†’ Empty) +3. **Paint Mode**: Select a color from the paint palette and drag across multiple days for quick entry +4. **View Stats**: See your mood summary (count of Green, Amber, Red days) at the top +5. **Export Data**: Save your progress as JSON or CSV files +6. **Import Data**: Upload previously exported JSON files to restore data + +### Mood Colors +- **Green**: Good/positive mood days +- **Amber**: Neutral/okay mood days +- **Red**: Difficult/negative mood days +- **Gray**: No entry/untracked days + +## Technology Stack + +- **Frontend**: React 18 with TypeScript +- **Styling**: Tailwind CSS +- **Build Tool**: Vite +- **Data Storage**: Browser localStorage +- **Deployment**: Docker with Nginx + +## Browser Compatibility + +- Modern browsers with ES2020+ support +- Chrome/Edge 80+, Firefox 72+, Safari 13.1+ +- Mobile browsers (iOS Safari, Chrome Mobile) + +## Data Privacy + +- **100% Local**: All mood data is stored locally in your browser +- **No Server**: No data is sent to external servers +- **Export Control**: You control your data with export/import features +- **No Analytics**: No tracking or analytics included + +## File Structure + +``` +mood_tracker/ +β”œβ”€β”€ src/ +β”‚ β”œβ”€β”€ App.tsx # Main app component +β”‚ β”œβ”€β”€ YearInPixelsMoodTracker.tsx # Core mood tracker component +β”‚ β”œβ”€β”€ main.tsx # React app entry point +β”‚ └── index.css # Global styles +β”œβ”€β”€ public/ # Static assets +β”œβ”€β”€ Dockerfile # Multi-stage Docker build +β”œβ”€β”€ docker-compose.yml # Docker Compose configuration +β”œβ”€β”€ nginx.conf # Nginx configuration +β”œβ”€β”€ package.json # Dependencies and scripts +└── vite.config.ts # Vite build configuration +``` + +## Contributing + +1. Fork the repository +2. Create a feature branch (`git checkout -b feature/amazing-feature`) +3. Commit your changes (`git commit -m 'Add amazing feature'`) +4. Push to the branch (`git push origin feature/amazing-feature`) +5. Open a Pull Request + +## License + +This project is open source and available under the [MIT License](LICENSE). \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f9cc14a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,10 @@ +version: '3.8' + +services: + mood-tracker: + build: . + ports: + - "8080:80" + restart: unless-stopped + environment: + - NODE_ENV=production \ No newline at end of file diff --git a/index.html b/index.html new file mode 100644 index 0000000..9e5077c --- /dev/null +++ b/index.html @@ -0,0 +1,13 @@ + + + + + + + Mood Tracker - Year in Pixels + + +
+ + + \ No newline at end of file diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..e7df279 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,43 @@ +server { + listen 80; + server_name localhost; + + root /usr/share/nginx/html; + index index.html; + + # Security headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "no-referrer-when-downgrade" always; + add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'" always; + + # Gzip compression + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_types + text/plain + text/css + text/xml + text/javascript + application/javascript + application/xml+rss + application/json; + + # Handle client routing, return all requests to index.html + location / { + try_files $uri $uri/ /index.html; + } + + # Cache static assets + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # Disable access to hidden files + location ~ /\. { + deny all; + } +} \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..b8a9058 --- /dev/null +++ b/package.json @@ -0,0 +1,26 @@ +{ + "name": "mood-tracker", + "version": "1.0.0", + "description": "Year in Pixels Mood Tracker - A React app for tracking daily moods", + "private": true, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "@vitejs/plugin-react": "^4.0.0", + "autoprefixer": "^10.4.14", + "postcss": "^8.4.24", + "tailwindcss": "^3.3.2", + "typescript": "^5.0.0", + "vite": "^4.4.0" + }, + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "type": "module" +} \ No newline at end of file diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..e99ebc2 --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..5176045 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,7 @@ +import YearInPixelsMoodTracker from './YearInPixelsMoodTracker' + +function App() { + return +} + +export default App \ No newline at end of file diff --git a/src/YearInPixelsMoodTracker.tsx b/src/YearInPixelsMoodTracker.tsx new file mode 100644 index 0000000..a7c134c --- /dev/null +++ b/src/YearInPixelsMoodTracker.tsx @@ -0,0 +1,495 @@ +import React, { useEffect, useMemo, useRef, useState } from "react"; + +// Year-in-Pixels Mood Tracker +// - 12 columns (months) +// - 31 rows (days) +// - Each cell = one calendar day for the selected year +// - 3 moods: Green (G), Amber (A), Red (R) +// - Data persists to localStorage per-year +// - Click to cycle Empty β†’ G β†’ A β†’ R β†’ Empty +// - Drag painting with a selected color (optional): pick a color, then drag +// - Export/Import JSON, Export CSV, Print +// - Highlights today's date when viewing the current year + +// Tailwind color classes for moods +const MOOD_STYLES: Record = { + "": "bg-zinc-200 dark:bg-zinc-700", + G: "bg-green-500", + A: "bg-amber-400", + R: "bg-red-500", +}; + +const MOOD_LABELS: Record = { + "": "No entry", + G: "Green", + A: "Amber", + R: "Red", +}; + +const MONTHS = [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", +]; + +function pad(n: number) { + return String(n).padStart(2, "0"); +} + +function daysInMonth(year: number, monthIndex0: number) { + // monthIndex0 = 0..11 + return new Date(year, monthIndex0 + 1, 0).getDate(); +} + +function ymd(year: number, m0: number, d: number) { + return `${year}-${pad(m0 + 1)}-${pad(d)}`; +} + +function download(filename: string, contents: string, type = "text/plain") { + const blob = new Blob([contents], { type }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); +} + +export default function YearInPixelsMoodTracker() { + const now = new Date(); + const [year, setYear] = useState(now.getFullYear()); + const [moods, setMoods] = useState>({}); + const [paintColor, setPaintColor] = useState<"" | "G" | "A" | "R">(""); + const [showNumbers, setShowNumbers] = useState(true); + const [compact, setCompact] = useState(false); + const [showLegend, setShowLegend] = useState(true); + + // drag-paint state + const isDraggingRef = useRef(false); + const paintColorRef = useRef<"" | "G" | "A" | "R">(paintColor); + useEffect(() => { + paintColorRef.current = paintColor; + }, [paintColor]); + + // Load/save from localStorage + const storageKey = useMemo(() => `year-in-pixels-mood-${year}`, [year]); + + useEffect(() => { + try { + const raw = localStorage.getItem(storageKey); + if (raw) { + if (raw.length > 100000) { + console.error("Stored data too large, clearing"); + localStorage.removeItem(storageKey); + setMoods({}); + return; + } + + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === "object") { + const validatedMoods: Record = {}; + + for (const [key, value] of Object.entries(parsed)) { + if (typeof key === "string" && /^\d{4}-\d{2}-\d{2}$/.test(key)) { + if (value === "" || value === "G" || value === "A" || value === "R") { + validatedMoods[key] = value as "" | "G" | "A" | "R"; + } + } + } + + setMoods(validatedMoods); + } else { + setMoods({}); + } + } else { + setMoods({}); + } + } catch (e) { + console.error("Failed to load moods", e); + localStorage.removeItem(storageKey); + setMoods({}); + } + }, [storageKey]); + + useEffect(() => { + try { + localStorage.setItem(storageKey, JSON.stringify(moods)); + } catch (e) { + console.error("Failed to save moods", e); + } + }, [moods, storageKey]); + + // Helpers + const todayKey = useMemo(() => { + const t = new Date(); + return ymd(t.getFullYear(), t.getMonth(), t.getDate()); + }, []); + + function cycleMood(curr: "" | "G" | "A" | "R") { + if (curr === "") return "G"; + if (curr === "G") return "A"; + if (curr === "A") return "R"; + return ""; // R β†’ empty + } + + function setMoodFor(key: string, value: "" | "G" | "A" | "R") { + setMoods((prev) => ({ ...prev, [key]: value })); + } + + function handleCellClick(key: string) { + if (paintColor) { + setMoodFor(key, paintColor); + } else { + setMoods((prev) => ({ ...prev, [key]: cycleMood(prev[key] || "") })); + } + } + + function handlePointerDown(e: React.PointerEvent, key: string) { + (e.target as HTMLElement).setPointerCapture?.(e.pointerId); + isDraggingRef.current = true; + if (paintColorRef.current) { + setMoodFor(key, paintColorRef.current); + } else { + setMoods((prev) => ({ ...prev, [key]: cycleMood(prev[key] || "") })); + } + } + + function handlePointerEnter(_e: React.PointerEvent, key: string) { + if (!isDraggingRef.current) return; + if (paintColorRef.current) setMoodFor(key, paintColorRef.current); + } + + function handlePointerUp() { + isDraggingRef.current = false; + } + + // Stats + const stats = useMemo(() => { + const counts = { G: 0, A: 0, R: 0 } as Record<"G" | "A" | "R", number>; + for (const [k, v] of Object.entries(moods)) { + if (!k.startsWith(`${year}-`)) continue; + if (v === "G" || v === "A" || v === "R") counts[v]++; + } + return counts; + }, [moods, year]); + + function resetYear() { + const next = { ...moods }; + Object.keys(next) + .filter((k) => k.startsWith(`${year}-`)) + .forEach((k) => delete next[k]); + setMoods(next); + } + + function exportJSON() { + const data: Record = {}; + Object.entries(moods) + .filter(([k]) => k.startsWith(`${year}-`)) + .forEach(([k, v]) => (data[k] = v)); + download(`mood-${year}.json`, JSON.stringify({ year, data }, null, 2), "application/json"); + } + + function exportCSV() { + const rows: string[] = ["date,month,day,mood"]; + for (let m = 0; m < 12; m++) { + const dim = daysInMonth(year, m); + for (let d = 1; d <= dim; d++) { + const key = ymd(year, m, d); + const mood = moods[key] || ""; + rows.push(`${key},${MONTHS[m]},${d},${mood}`); + } + } + download(`mood-${year}.csv`, rows.join("\n"), "text/csv"); + } + + function importJSON(file: File) { + if (file.size > 1024 * 1024) { + alert("File too large. Maximum size is 1MB."); + return; + } + + if (!file.type.includes("json") && !file.name.endsWith(".json")) { + alert("Please select a valid JSON file."); + return; + } + + const reader = new FileReader(); + reader.onload = () => { + try { + const result = String(reader.result); + if (result.length > 1024 * 1024) { + alert("File content too large."); + return; + } + + const parsed = JSON.parse(result); + if (parsed && typeof parsed === "object" && parsed.data) { + const validatedData: Record = {}; + + for (const [key, value] of Object.entries(parsed.data)) { + if (typeof key === "string" && /^\d{4}-\d{2}-\d{2}$/.test(key)) { + if (value === "" || value === "G" || value === "A" || value === "R") { + validatedData[key] = value as "" | "G" | "A" | "R"; + } + } + } + + setMoods((prev) => ({ ...prev, ...validatedData })); + if (parsed.year && typeof parsed.year === "number" && parsed.year >= 1900 && parsed.year <= 3000) { + setYear(parsed.year); + } + } else { + alert("Invalid JSON format."); + } + } catch (e) { + alert("Failed to parse JSON. Please ensure the file is valid JSON."); + } + }; + reader.readAsText(file); + } + + // Rendering helpers + const Cell = ({ m0, d }: { m0: number; d: number }) => { + const dim = daysInMonth(year, m0); + const active = d <= dim; + const key = ymd(year, m0, d); + const mood = moods[key] || ""; + + if (!active) { + return ( +
+ ); + } + + const isToday = key === todayKey; + + return ( + + ); + }; + + return ( +
+
+ {/* Header */} +
+
+

Year in Pixels – Mood (RAG)

+

+ 12 columns (months) Γ— 31 rows (days). Click to cycle or select a color to paint and drag across days. +

+
+ + {/* Controls */} +
+
+ + setYear(parseInt(e.target.value || `${now.getFullYear()}`, 10))} + aria-label="Year" + /> + +
+ + {/* Paint palette */} +
+ Paint: + {( + [ + { k: "", name: "Off", cls: "bg-zinc-200 dark:bg-zinc-700" }, + { k: "G", name: "Green", cls: "bg-green-500" }, + { k: "A", name: "Amber", cls: "bg-amber-400" }, + { k: "R", name: "Red", cls: "bg-red-500" }, + ] as const + ).map((opt) => ( + + ))} +
+ + {/* Toggles */} +
+ + + +
+ + {/* Actions */} +
+ + + + + +
+
+
+ + {/* Legend */} + {showLegend && ( +
+
Green
+
Amber
+
Red
+
No entry
+
Today
+
+ )} + + {/* Summary */} +
+ Green: {stats.G} + Amber: {stats.A} + Red: {stats.R} +
+ + {/* Month headers */} +
+ {MONTHS.map((m) => ( +
+ {m} +
+ ))} +
+ + {/* Grid wrapper with day labels at left */} +
+
+ {Array.from({ length: 31 }).map((_, i) => ( +
+ {i + 1} +
+ ))} +
+ + {/* The 31 Γ— 12 day grid */} +
+ {Array.from({ length: 31 }).map((_, dIndex) => ( + + {Array.from({ length: 12 }).map((_, mIndex) => ( + + ))} + + ))} +
+
+ + {/* Footer note */} +
+ Tip: Turn on a paint color to quickly fill multiple days by dragging across the grid. Toggle β€œDay numbers” for a cleaner look. +
+
+
+ ); +} diff --git a/src/index.css b/src/index.css new file mode 100644 index 0000000..bd6213e --- /dev/null +++ b/src/index.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; \ No newline at end of file diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..cbe1cdf --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,10 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App.tsx' +import './index.css' + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + , +) \ No newline at end of file diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000..a892cd0 --- /dev/null +++ b/tailwind.config.js @@ -0,0 +1,13 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: [ + "./index.html", + "./src/**/*.{js,ts,jsx,tsx}", + "./*.{js,ts,jsx,tsx}" + ], + theme: { + extend: {}, + }, + plugins: [], + darkMode: 'media' +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..7a7611e --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} \ No newline at end of file diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..099658c --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..eb9f078 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + build: { + outDir: 'dist', + }, + server: { + host: '0.0.0.0', + port: 3000 + } +}) \ No newline at end of file