First commit
This commit is contained in:
commit
23eb7d5125
14
.dockerignore
Normal file
14
.dockerignore
Normal file
@ -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
|
||||
31
Dockerfile
Normal file
31
Dockerfile
Normal file
@ -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;"]
|
||||
131
README.md
Normal file
131
README.md
Normal file
@ -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 <repository-url>
|
||||
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).
|
||||
10
docker-compose.yml
Normal file
10
docker-compose.yml
Normal file
@ -0,0 +1,10 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
mood-tracker:
|
||||
build: .
|
||||
ports:
|
||||
- "8080:80"
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
13
index.html
Normal file
13
index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Mood Tracker - Year in Pixels</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
43
nginx.conf
Normal file
43
nginx.conf
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
26
package.json
Normal file
26
package.json
Normal file
@ -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"
|
||||
}
|
||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
7
src/App.tsx
Normal file
7
src/App.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
import YearInPixelsMoodTracker from './YearInPixelsMoodTracker'
|
||||
|
||||
function App() {
|
||||
return <YearInPixelsMoodTracker />
|
||||
}
|
||||
|
||||
export default App
|
||||
495
src/YearInPixelsMoodTracker.tsx
Normal file
495
src/YearInPixelsMoodTracker.tsx
Normal file
@ -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<string, string> = {
|
||||
"": "bg-zinc-200 dark:bg-zinc-700",
|
||||
G: "bg-green-500",
|
||||
A: "bg-amber-400",
|
||||
R: "bg-red-500",
|
||||
};
|
||||
|
||||
const MOOD_LABELS: Record<string, string> = {
|
||||
"": "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<number>(now.getFullYear());
|
||||
const [moods, setMoods] = useState<Record<string, "" | "G" | "A" | "R">>({});
|
||||
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<string, "" | "G" | "A" | "R"> = {};
|
||||
|
||||
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<string, string> = {};
|
||||
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<string, "" | "G" | "A" | "R"> = {};
|
||||
|
||||
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 (
|
||||
<div
|
||||
className="w-7 h-7 sm:w-8 sm:h-8 md:w-9 md:h-9 rounded-md bg-zinc-100 dark:bg-zinc-800 opacity-50"
|
||||
aria-hidden
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const isToday = key === todayKey;
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-label={`${year}-${MONTHS[m0]} ${d}: ${MOOD_LABELS[mood]}`}
|
||||
title={`${year}-${pad(m0 + 1)}-${pad(d)} (${MOOD_LABELS[mood]})`}
|
||||
onClick={() => handleCellClick(key)}
|
||||
onPointerDown={(e) => handlePointerDown(e, key)}
|
||||
onPointerEnter={(e) => handlePointerEnter(e, key)}
|
||||
onPointerUp={handlePointerUp}
|
||||
className={[
|
||||
"group w-7 h-7 sm:w-8 sm:h-8 md:w-9 md:h-9 rounded-md border border-zinc-300/60 dark:border-zinc-600/60 flex items-center justify-center select-none",
|
||||
MOOD_STYLES[mood],
|
||||
isToday && year === new Date().getFullYear()
|
||||
? "ring-2 ring-offset-1 ring-black/40 dark:ring-white/50"
|
||||
: "",
|
||||
].join(" ")}
|
||||
>
|
||||
{showNumbers && (
|
||||
<span className="text-[10px] sm:text-[11px] md:text-xs font-medium text-black/80 dark:text-white/90">
|
||||
{d}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen w-full bg-white dark:bg-zinc-950 text-zinc-900 dark:text-zinc-50 p-4 md:p-6">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col md:flex-row md:items-end md:justify-between gap-4 mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl md:text-3xl font-bold tracking-tight">Year in Pixels – Mood (RAG)</h1>
|
||||
<p className="text-sm text-zinc-600 dark:text-zinc-400 mt-1">
|
||||
12 columns (months) × 31 rows (days). Click to cycle or select a color to paint and drag across days.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="flex items-center gap-2 border border-zinc-300 dark:border-zinc-700 rounded-xl p-2">
|
||||
<button
|
||||
className="px-2 py-1 rounded-lg border border-zinc-300 dark:border-zinc-700"
|
||||
onClick={() => setYear((y) => y - 1)}
|
||||
aria-label="Previous year"
|
||||
>
|
||||
◀
|
||||
</button>
|
||||
<input
|
||||
type="number"
|
||||
className="w-24 px-3 py-1 rounded-lg border border-zinc-300 dark:border-zinc-700 bg-transparent text-center"
|
||||
value={year}
|
||||
onChange={(e) => setYear(parseInt(e.target.value || `${now.getFullYear()}`, 10))}
|
||||
aria-label="Year"
|
||||
/>
|
||||
<button
|
||||
className="px-2 py-1 rounded-lg border border-zinc-300 dark:border-zinc-700"
|
||||
onClick={() => setYear((y) => y + 1)}
|
||||
aria-label="Next year"
|
||||
>
|
||||
▶
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Paint palette */}
|
||||
<div className="flex items-center gap-2 border border-zinc-300 dark:border-zinc-700 rounded-xl p-2">
|
||||
<span className="text-xs mr-1">Paint:</span>
|
||||
{(
|
||||
[
|
||||
{ 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) => (
|
||||
<button
|
||||
key={opt.k}
|
||||
className={[
|
||||
"w-7 h-7 rounded-lg border border-zinc-300/70 dark:border-zinc-600/70 flex items-center justify-center",
|
||||
opt.cls,
|
||||
paintColor === opt.k ? "ring-2 ring-black/40 dark:ring-white/70" : "",
|
||||
].join(" ")}
|
||||
onClick={() => setPaintColor(opt.k)}
|
||||
title={`Paint: ${opt.name}`}
|
||||
aria-label={`Paint color: ${opt.name}`}
|
||||
>
|
||||
{opt.k === "" ? (
|
||||
<span className="text-[10px] font-medium text-zinc-800 dark:text-zinc-100">Off</span>
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Toggles */}
|
||||
<div className="flex items-center gap-2 border border-zinc-300 dark:border-zinc-700 rounded-xl p-2">
|
||||
<label className="flex items-center gap-1 text-xs">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showNumbers}
|
||||
onChange={(e) => setShowNumbers(e.target.checked)}
|
||||
/>
|
||||
Day numbers
|
||||
</label>
|
||||
<label className="flex items-center gap-1 text-xs">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={compact}
|
||||
onChange={(e) => setCompact(e.target.checked)}
|
||||
/>
|
||||
Compact
|
||||
</label>
|
||||
<label className="flex items-center gap-1 text-xs">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showLegend}
|
||||
onChange={(e) => setShowLegend(e.target.checked)}
|
||||
/>
|
||||
Legend
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 border border-zinc-300 dark:border-zinc-700 rounded-xl p-2">
|
||||
<button className="px-3 py-1.5 rounded-lg border border-zinc-300 dark:border-zinc-700" onClick={exportJSON}>
|
||||
Export JSON
|
||||
</button>
|
||||
<button className="px-3 py-1.5 rounded-lg border border-zinc-300 dark:border-zinc-700" onClick={exportCSV}>
|
||||
Export CSV
|
||||
</button>
|
||||
<label className="px-3 py-1.5 rounded-lg border border-zinc-300 dark:border-zinc-700 cursor-pointer">
|
||||
Import JSON
|
||||
<input type="file" accept="application/json" className="hidden" onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) importJSON(f);
|
||||
e.currentTarget.value = "";
|
||||
}} />
|
||||
</label>
|
||||
<button className="px-3 py-1.5 rounded-lg border border-zinc-300 dark:border-zinc-700" onClick={resetYear}>
|
||||
Reset year
|
||||
</button>
|
||||
<button className="px-3 py-1.5 rounded-lg border border-zinc-300 dark:border-zinc-700" onClick={() => window.print()}>
|
||||
Print
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
{showLegend && (
|
||||
<div className="mb-4 text-sm flex flex-wrap items-center gap-3">
|
||||
<div className="flex items-center gap-1.5"><span className="inline-block w-3.5 h-3.5 rounded-sm bg-green-500" /> Green</div>
|
||||
<div className="flex items-center gap-1.5"><span className="inline-block w-3.5 h-3.5 rounded-sm bg-amber-400" /> Amber</div>
|
||||
<div className="flex items-center gap-1.5"><span className="inline-block w-3.5 h-3.5 rounded-sm bg-red-500" /> Red</div>
|
||||
<div className="flex items-center gap-1.5"><span className="inline-block w-3.5 h-3.5 rounded-sm bg-zinc-200 dark:bg-zinc-700" /> No entry</div>
|
||||
<div className="flex items-center gap-1.5"><span className="inline-block w-3.5 h-3.5 rounded-sm ring-2 ring-black/40 dark:ring-white/70" /> Today</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary */}
|
||||
<div className="mb-6 text-sm text-zinc-700 dark:text-zinc-300">
|
||||
<span className="mr-4">Green: <strong>{stats.G}</strong></span>
|
||||
<span className="mr-4">Amber: <strong>{stats.A}</strong></span>
|
||||
<span className="mr-4">Red: <strong>{stats.R}</strong></span>
|
||||
</div>
|
||||
|
||||
{/* Month headers */}
|
||||
<div className="grid grid-cols-12 gap-1 pl-8 sm:pl-10 md:pl-12 mb-2">
|
||||
{MONTHS.map((m) => (
|
||||
<div
|
||||
key={m}
|
||||
className="justify-self-start w-7 sm:w-8 md:w-9 text-center text-[11px] sm:text-sm font-medium text-zinc-600 dark:text-zinc-400"
|
||||
>
|
||||
{m}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Grid wrapper with day labels at left */}
|
||||
<div className={[
|
||||
"relative",
|
||||
compact ? "-mt-1" : "",
|
||||
].join(" ")}>
|
||||
<div className="absolute -left-0 top-0 flex flex-col gap-1 pr-2">
|
||||
{Array.from({ length: 31 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-7 sm:h-8 md:h-9 w-8 sm:w-10 md:w-12 text-[10px] sm:text-xs md:text-sm text-right pr-1 text-zinc-500 dark:text-zinc-400 flex items-center justify-end"
|
||||
>
|
||||
{i + 1}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* The 31 × 12 day grid */}
|
||||
<div className="grid grid-cols-12 gap-1 pl-8 sm:pl-10 md:pl-12"
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerUp}
|
||||
onPointerLeave={handlePointerUp}
|
||||
>
|
||||
{Array.from({ length: 31 }).map((_, dIndex) => (
|
||||
<React.Fragment key={dIndex}>
|
||||
{Array.from({ length: 12 }).map((_, mIndex) => (
|
||||
<Cell key={`${mIndex}-${dIndex}`} m0={mIndex} d={dIndex + 1} />
|
||||
))}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer note */}
|
||||
<div className="mt-8 text-xs text-zinc-500 dark:text-zinc-400">
|
||||
Tip: Turn on a paint color to quickly fill multiple days by dragging across the grid. Toggle “Day numbers” for a cleaner look.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
3
src/index.css
Normal file
3
src/index.css
Normal file
@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
@ -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(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
13
tailwind.config.js
Normal file
13
tailwind.config.js
Normal file
@ -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'
|
||||
}
|
||||
25
tsconfig.json
Normal file
25
tsconfig.json
Normal file
@ -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" }]
|
||||
}
|
||||
10
tsconfig.node.json
Normal file
10
tsconfig.node.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
13
vite.config.ts
Normal file
13
vite.config.ts
Normal file
@ -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
|
||||
}
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user