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