Subir archivos a "finalexam"

This commit is contained in:
Pablo Pérez Arcas 2026-04-29 06:50:53 +00:00
parent 5ac5eec6e7
commit 98cf36606b
15 changed files with 949 additions and 0 deletions

13
finalexam/Dockerfile Normal file
View File

@ -0,0 +1,13 @@
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install --omit=dev
COPY src ./src
ENV NODE_ENV=production
EXPOSE 10000
CMD ["npm", "start"]

234
finalexam/README.md Normal file
View File

@ -0,0 +1,234 @@
# TaskNotes Cloud — Final Exam Project
## 1. Description of the application
TaskNotes Cloud is a small web application for saving tasks and study notes.
The user can create, list, mark as completed and delete notes. Each note has a title, description and priority.
The application is useful for students who want to organize work for seminars, exams or small projects.
## 2. Cloud and architecture used
The proposed public cloud is **Render**.
The application has three main components:
1. **Frontend container**
- React + Vite application.
- Served by Nginx.
- Public HTTPS URL.
2. **Backend container**
- Node.js + Express REST API.
- Provides endpoints under `/api/notes`.
- Has a `/health` endpoint for health checks.
- Logs HTTP access requests with Morgan.
3. **PostgreSQL database**
- Managed Render Postgres database.
- Stores persistent notes data.
- Data remains available after backend restart or redeploy.
Communication:
- Browser → Frontend over HTTPS.
- Frontend → Backend API over HTTPS.
- Backend → PostgreSQL using the `DATABASE_URL` environment variable.
## 3. Docker / cloud objects
Local objects:
- `frontend` service
- `backend` service
- `db` service
- `postgres_data` persistent volume
Cloud objects in `render.yaml`:
- `tasknotes-frontend`: Docker web service
- `tasknotes-backend`: Docker web service
- `tasknotes-db`: Render Postgres database
## 4. Uploaded files and their content
- `render.yaml` — Infrastructure as Code configuration for Render.
- `docker-compose.yml` — local three-container version for testing.
- `prepare-app.sh` — prepares and tests the application locally; also gives deployment instructions.
- `remove-app.sh` — removes local containers and local persistent volume.
- `backend/Dockerfile` — builds the backend container.
- `backend/src/server.js` — Express backend API and database initialization.
- `backend/src/backup.js` — exports notes to a JSON backup.
- `frontend/Dockerfile` — builds React frontend and serves it through Nginx.
- `frontend/src/main.jsx` — React user interface.
- `frontend/src/styles.css` — visual styling.
- `scripts/backup-local.sh` — local PostgreSQL backup script.
- `scripts/logs-local.sh` — local backend log viewer.
## 5. Configuration
The application is configurable using environment variables:
Backend:
- `DATABASE_URL` — PostgreSQL connection string.
- `CORS_ORIGIN` — allowed frontend origins.
- `PORT` — backend port.
- `NODE_ENV` — production/development mode.
Frontend:
- `VITE_API_URL` — public URL of the backend API.
Secrets are not stored in Git. The database connection string is injected by Render from the managed database.
## 6. How to run locally
Requirements:
- Docker
- Docker Compose
- Git
- Bash terminal
Run:
```bash
chmod +x prepare-app.sh remove-app.sh scripts/*.sh
./prepare-app.sh
```
Open:
- Frontend: `http://localhost:8080`
- Backend health: `http://localhost:10000/health`
## 7. How to deploy to the public cloud
1. Upload the `sk1` directory to Git.
2. Make sure `render.yaml` is in the repository root or configure the correct Blueprint path.
3. Create a Render Blueprint from `render.yaml`.
4. Render will create:
- frontend service
- backend service
- PostgreSQL database
5. After the backend is deployed, copy the backend public URL.
6. Set the frontend environment variable:
```text
VITE_API_URL=https://YOUR-BACKEND-URL.onrender.com
```
7. Redeploy the frontend.
8. Open the frontend HTTPS URL in the browser.
## 8. How to use the application
1. Open the public frontend URL in a browser.
2. Write a title and details.
3. Select priority.
4. Click **Add note**.
5. Mark notes as completed or delete them.
## 9. Backup instructions
Local backup:
```bash
./scripts/backup-local.sh
```
This creates a SQL dump in the `backups/` directory.
Backend JSON export:
```bash
cd backend
DATABASE_URL="your_database_url" npm run backup
```
Cloud backup:
- Use Render Postgres backup/export options, or connect with `pg_dump` using the external database URL.
- Example:
```bash
pg_dump "$DATABASE_URL" > tasknotes-cloud-backup.sql
```
## 10. Access logs from the internet
Local logs:
```bash
./scripts/logs-local.sh
```
Cloud logs:
- Open the backend service logs in Render, or use the Render CLI if configured.
- The backend uses Morgan with `combined` format, so it logs IP, method, URL, status code, response time and user agent.
## 11. Conditions for scripts
`prepare-app.sh` can be run when:
- Docker is installed.
- Docker Compose is available.
- Ports `8080`, `10000` and `5432` are free.
- The user is in the project root directory.
`remove-app.sh` can be run when:
- Docker is installed.
- The local containers were created with Docker Compose.
## 12. Cost analysis for one year
Assumption:
- 1000 users per day.
- Database/file size: 50 GB.
- Small educational application.
- Two small web services and one PostgreSQL database.
Example Render estimate:
- Frontend web service: Free or Starter. For production estimate, Starter: 7 USD/month.
- Backend web service: Starter: 7 USD/month.
- PostgreSQL Basic-256mb: 6 USD/month.
- PostgreSQL storage: 50 GB × 0.30 USD/GB/month = 15 USD/month.
- Total monthly estimate: 7 + 7 + 6 + 15 = 35 USD/month.
- Total yearly estimate: 35 × 12 = 420 USD/year.
This is an approximate educational estimate. Real cost depends on traffic, bandwidth, region, scaling and selected plans.
## 13. External resources and generative AI use
External resources used:
- Render documentation for Blueprints, Docker web services, environment variables, HTTPS and PostgreSQL.
- Docker documentation for Dockerfiles and Docker Compose.
- PostgreSQL documentation for database concepts and backup using `pg_dump`.
Generative AI use:
- ChatGPT was used to help design the structure of the project, generate example configuration files, improve documentation and prepare defense explanations.
- The final implementation was reviewed and adapted for this exam project.
## 14. Defense summary
Short explanation:
> My project is TaskNotes Cloud, a web application for students to save tasks and study notes. It has a React frontend, a Node.js backend and a PostgreSQL database. The deployment is defined in configuration files, mainly `render.yaml` and Dockerfiles. The app is publicly accessible using HTTPS, stores data persistently in PostgreSQL, restarts automatically in the cloud, and can be backed up with `pg_dump`.
Important points to mention:
- Three components: frontend, backend, database.
- HTTPS certificate is provided by Render.
- Secrets are environment variables, not committed to Git.
- Database is persistent.
- Backend health check is `/health`.
- Logs can be viewed in Render.
- Backup can be done with `pg_dump`.
- Local testing is possible with Docker Compose.

View File

@ -0,0 +1,6 @@
#!/usr/bin/env bash
set -euo pipefail
mkdir -p backups
docker exec tasknotes-db pg_dump -U tasknotes -d tasknotes > "backups/tasknotes-$(date +%Y%m%d-%H%M%S).sql"
echo "Backup created in backups/"

32
finalexam/backup.js Normal file
View File

@ -0,0 +1,32 @@
import pg from "pg";
import fs from "fs";
import dotenv from "dotenv";
dotenv.config();
const { Pool } = pg;
const DATABASE_URL = process.env.DATABASE_URL;
if (!DATABASE_URL) {
console.error("Missing DATABASE_URL environment variable");
process.exit(1);
}
const pool = new Pool({
connectionString: DATABASE_URL,
ssl: process.env.NODE_ENV === "production" ? { rejectUnauthorized: false } : false
});
const result = await pool.query("SELECT * FROM notes ORDER BY id ASC");
const backup = {
exportedAt: new Date().toISOString(),
table: "notes",
rows: result.rows
};
fs.mkdirSync("backups", { recursive: true });
const filename = `backups/notes-backup-${new Date().toISOString().replace(/[:.]/g, "-")}.json`;
fs.writeFileSync(filename, JSON.stringify(backup, null, 2));
console.log(`Backup written to ${filename}`);
await pool.end();

View File

@ -0,0 +1,41 @@
services:
db:
image: postgres:16-alpine
container_name: tasknotes-db
restart: unless-stopped
environment:
POSTGRES_DB: tasknotes
POSTGRES_USER: tasknotes
POSTGRES_PASSWORD: tasknotes_password
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5432:5432"
backend:
build: ./backend
container_name: tasknotes-backend
restart: unless-stopped
environment:
DATABASE_URL: postgres://tasknotes:tasknotes_password@db:5432/tasknotes
CORS_ORIGIN: http://localhost:5173,http://localhost:8080
NODE_ENV: development
PORT: 10000
ports:
- "10000:10000"
depends_on:
- db
frontend:
build: ./frontend
container_name: tasknotes-frontend
restart: unless-stopped
environment:
VITE_API_URL: http://localhost:10000
ports:
- "8080:80"
depends_on:
- backend
volumes:
postgres_data:

1
finalexam/index.html Normal file
View File

@ -0,0 +1 @@
<!doctype html><html><head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" /><title>TaskNotes Cloud</title></head><body><div id="root"></div><script type="module" src="/src/main.jsx"></script></body></html>

4
finalexam/logs-local.sh Normal file
View File

@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
docker compose logs -f backend

176
finalexam/main.jsx Normal file
View File

@ -0,0 +1,176 @@
import React, { useEffect, useMemo, useState } from 'react';
import { createRoot } from 'react-dom/client';
import './styles.css';
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:10000';
function formatDate(date) {
if (!date) return 'No deadline';
return new Date(date + 'T00:00:00').toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
}
function isOverdue(task) {
if (!task.deadline || task.completed) return false;
const today = new Date();
today.setHours(0, 0, 0, 0);
return new Date(task.deadline + 'T00:00:00') < today;
}
function App() {
const [tasks, setTasks] = useState([]);
const [status, setStatus] = useState('Loading...');
const [filter, setFilter] = useState('all');
const [form, setForm] = useState({
title: '',
description: '',
subject: '',
task_type: 'assignment',
priority: 'normal',
deadline: '',
});
async function loadTasks() {
try {
const res = await fetch(`${API_URL}/api/tasks`);
if (!res.ok) throw new Error('API error');
const data = await res.json();
setTasks(data);
setStatus('Connected to backend and PostgreSQL');
} catch (error) {
setStatus('Backend is not reachable. Check VITE_API_URL and backend logs.');
}
}
useEffect(() => { loadTasks(); }, []);
async function addTask(event) {
event.preventDefault();
if (!form.title.trim()) return;
await fetch(`${API_URL}/api/tasks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form),
});
setForm({ title: '', description: '', subject: '', task_type: 'assignment', priority: 'normal', deadline: '' });
loadTasks();
}
async function toggleTask(id) {
await fetch(`${API_URL}/api/tasks/${id}/toggle`, { method: 'PATCH' });
loadTasks();
}
async function deleteTask(id) {
await fetch(`${API_URL}/api/tasks/${id}`, { method: 'DELETE' });
loadTasks();
}
const stats = useMemo(() => {
const total = tasks.length;
const completed = tasks.filter(t => t.completed).length;
const pending = total - completed;
const high = tasks.filter(t => t.priority === 'high' && !t.completed).length;
const overdue = tasks.filter(isOverdue).length;
return { total, pending, completed, high, overdue };
}, [tasks]);
const filteredTasks = tasks.filter((task) => {
if (filter === 'pending') return !task.completed;
if (filter === 'completed') return task.completed;
if (filter === 'high') return task.priority === 'high' && !task.completed;
if (filter === 'overdue') return isOverdue(task);
return true;
});
return (
<main className="page">
<section className="hero">
<div>
<span className="badge">Final cloud exam project</span>
<h1>Student Study Planner</h1>
<p>
Manage assignments, exams, projects and study deadlines. Data is stored in PostgreSQL,
so it survives restarts and redeploys.
</p>
</div>
<div className="heroCard">
<strong>Cloud architecture</strong>
<span>React frontend</span>
<span>Node.js backend API</span>
<span>PostgreSQL persistent database</span>
</div>
</section>
<section className="stats">
<article><span>Total tasks</span><strong>{stats.total}</strong></article>
<article><span>Pending</span><strong>{stats.pending}</strong></article>
<article><span>Completed</span><strong>{stats.completed}</strong></article>
<article><span>High priority</span><strong>{stats.high}</strong></article>
<article className={stats.overdue ? 'danger' : ''}><span>Overdue</span><strong>{stats.overdue}</strong></article>
</section>
<section className="panel">
<div className="panelHeader">
<div>
<h2>Add a study task</h2>
<p>Create a clear plan for homework, exams and projects.</p>
</div>
<span className={status.includes('not') ? 'status error' : 'status ok'}>{status}</span>
</div>
<form onSubmit={addTask} className="taskForm">
<input value={form.title} onChange={e => setForm({ ...form, title: e.target.value })} placeholder="Task title, e.g. Prepare Kubernetes defense" />
<input value={form.subject} onChange={e => setForm({ ...form, subject: e.target.value })} placeholder="Subject, e.g. Cloud Technologies" />
<textarea value={form.description} onChange={e => setForm({ ...form, description: e.target.value })} placeholder="Details or study notes" />
<select value={form.task_type} onChange={e => setForm({ ...form, task_type: e.target.value })}>
<option value="assignment">Assignment</option>
<option value="exam">Exam</option>
<option value="project">Project</option>
<option value="reading">Reading</option>
<option value="personal">Personal</option>
</select>
<select value={form.priority} onChange={e => setForm({ ...form, priority: e.target.value })}>
<option value="low">Low priority</option>
<option value="normal">Normal priority</option>
<option value="high">High priority</option>
</select>
<input type="date" value={form.deadline} onChange={e => setForm({ ...form, deadline: e.target.value })} />
<button type="submit">Add task</button>
</form>
</section>
<section className="toolbar">
{['all', 'pending', 'completed', 'high', 'overdue'].map(item => (
<button key={item} className={filter === item ? 'active' : ''} onClick={() => setFilter(item)}>{item}</button>
))}
</section>
<section className="taskList">
{filteredTasks.length === 0 ? (
<div className="empty">No tasks in this view. Add your first study task.</div>
) : filteredTasks.map(task => (
<article key={task.id} className={`task ${task.completed ? 'done' : ''} priority-${task.priority}`}>
<div className="taskTop">
<div>
<h3>{task.title}</h3>
<p>{task.description || 'No details added.'}</p>
</div>
<span className={`pill ${task.priority}`}>{task.priority}</span>
</div>
<div className="meta">
<span>Subject: <b>{task.subject || 'General'}</b></span>
<span>Type: <b>{task.task_type}</b></span>
<span className={isOverdue(task) ? 'overdue' : ''}>Deadline: <b>{formatDate(task.deadline)}</b></span>
</div>
<div className="actions">
<button onClick={() => toggleTask(task.id)}>{task.completed ? 'Reopen' : 'Mark completed'}</button>
<button className="delete" onClick={() => deleteTask(task.id)}>Delete</button>
</div>
</article>
))}
</section>
</main>
);
}
createRoot(document.getElementById('root')).render(<App />);

10
finalexam/nginx.conf Normal file
View File

@ -0,0 +1,10 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri /index.html;
}
}

16
finalexam/package.json Normal file
View File

@ -0,0 +1,16 @@
{
"name": "tasknotes-frontend",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "vite build",
"preview": "vite preview --host 0.0.0.0"
},
"dependencies": {
"@vitejs/plugin-react": "^4.3.1",
"vite": "^5.4.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
}
}

40
finalexam/prepare-app.sh Normal file
View File

@ -0,0 +1,40 @@
#!/usr/bin/env bash
set -euo pipefail
echo "== TaskNotes Cloud: prepare app =="
if ! command -v docker >/dev/null 2>&1; then
echo "Docker is required for local testing."
exit 1
fi
if ! command -v git >/dev/null 2>&1; then
echo "Git is required."
exit 1
fi
echo "1) Testing the application locally with Docker Compose..."
docker compose up -d --build
echo "2) Waiting for backend health endpoint..."
for i in {1..30}; do
if curl -fsS http://localhost:10000/health >/dev/null 2>&1; then
echo "Backend is healthy."
break
fi
sleep 2
done
echo "3) Local app is running:"
echo " Frontend: http://localhost:8080"
echo " Backend health: http://localhost:10000/health"
echo ""
echo "4) Cloud deployment:"
echo " Push this repository to GitHub/GitLab/Bitbucket and create a Render Blueprint using render.yaml."
echo " After backend is deployed, set VITE_API_URL in the frontend service to:"
echo " https://tasknotes-backend.onrender.com"
echo " If Render adds a suffix to the URL, use the real backend URL shown by Render."
echo ""
echo "Optional validation if Render CLI is installed:"
echo " render blueprints validate render.yaml"

11
finalexam/remove-app.sh Normal file
View File

@ -0,0 +1,11 @@
#!/usr/bin/env bash
set -euo pipefail
echo "== TaskNotes Cloud: remove local app =="
docker compose down -v
echo "Local containers and local PostgreSQL volume removed."
echo ""
echo "Cloud removal:"
echo "Delete/suspend the Render Blueprint or services from Render, or use Render CLI/API if available for your account."
echo "Do not delete only the frontend; remove backend and database too if you want to stop all resources."

40
finalexam/render.yaml Normal file
View File

@ -0,0 +1,40 @@
services:
- type: web
name: tasknotes-backend
runtime: docker
plan: free
region: frankfurt
dockerContext: ./backend
dockerfilePath: ./backend/Dockerfile
healthCheckPath: /health
autoDeploy: true
envVars:
- key: NODE_ENV
value: production
- key: PORT
value: 10000
- key: DATABASE_URL
fromDatabase:
name: tasknotes-db
property: connectionString
- key: CORS_ORIGIN
value: "*"
- type: web
name: tasknotes-frontend
runtime: docker
plan: free
region: frankfurt
dockerContext: ./frontend
dockerfilePath: ./frontend/Dockerfile
autoDeploy: true
envVars:
- key: VITE_API_URL
sync: false
databases:
- name: tasknotes-db
databaseName: tasknotes
user: tasknotes
plan: free
region: frankfurt

214
finalexam/server.js Normal file
View File

@ -0,0 +1,214 @@
const express = require('express');
const cors = require('cors');
const morgan = require('morgan');
const { Pool } = require('pg');
const app = express();
const PORT = process.env.PORT || 10000;
const DATABASE_URL = process.env.DATABASE_URL;
const CORS_ORIGIN = process.env.CORS_ORIGIN || 'http://localhost:8080';
if (!DATABASE_URL) {
console.error('DATABASE_URL environment variable is required');
process.exit(1);
}
const allowedOrigins = CORS_ORIGIN.split(',').map((origin) => origin.trim());
app.use(
cors({
origin(origin, callback) {
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error(`CORS not allowed for origin: ${origin}`));
}
},
})
);
app.use(express.json());
app.use(morgan('combined'));
const useSsl = process.env.NODE_ENV === 'production';
const pool = new Pool({
connectionString: DATABASE_URL,
ssl: useSsl ? { rejectUnauthorized: false } : false,
});
async function initDb() {
await pool.query(`
CREATE TABLE IF NOT EXISTS tasks (
id SERIAL PRIMARY KEY,
title VARCHAR(200) NOT NULL,
description TEXT DEFAULT '',
subject VARCHAR(120) DEFAULT '',
task_type VARCHAR(50) DEFAULT 'assignment',
priority VARCHAR(20) DEFAULT 'normal',
deadline DATE,
completed BOOLEAN DEFAULT false,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
`);
}
function normalizeTask(row) {
return {
id: row.id,
title: row.title,
description: row.description || '',
subject: row.subject || '',
taskType: row.task_type || 'assignment',
priority: row.priority || 'normal',
deadline: row.deadline,
completed: row.completed,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
app.get('/health', async (req, res) => {
try {
await pool.query('SELECT 1');
res.json({
status: 'ok',
database: 'connected',
app: 'Student Study Planner',
});
} catch (error) {
console.error('Health check failed:', error);
res.status(500).json({
status: 'error',
database: 'disconnected',
message: error.message,
});
}
});
app.get('/api/tasks', async (req, res) => {
try {
const result = await pool.query(`
SELECT *
FROM tasks
ORDER BY completed ASC, deadline ASC NULLS LAST, created_at DESC;
`);
res.json(result.rows.map(normalizeTask));
} catch (error) {
console.error('Error getting tasks:', error);
res.status(500).json({ message: 'Error getting tasks' });
}
});
app.post('/api/tasks', async (req, res) => {
try {
const {
title,
description = '',
subject = '',
taskType = 'assignment',
priority = 'normal',
deadline = null,
} = req.body;
if (!title || !title.trim()) {
return res.status(400).json({ message: 'Title is required' });
}
const result = await pool.query(
`
INSERT INTO tasks
(title, description, subject, task_type, priority, deadline)
VALUES
($1, $2, $3, $4, $5, $6)
RETURNING *;
`,
[
title.trim(),
description,
subject,
taskType,
priority,
deadline || null,
]
);
res.status(201).json(normalizeTask(result.rows[0]));
} catch (error) {
console.error('Error creating task:', error);
res.status(500).json({ message: 'Error creating task' });
}
});
app.patch('/api/tasks/:id/toggle', async (req, res) => {
try {
const { id } = req.params;
const result = await pool.query(
`
UPDATE tasks
SET completed = NOT completed,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1
RETURNING *;
`,
[id]
);
if (result.rows.length === 0) {
return res.status(404).json({ message: 'Task not found' });
}
res.json(normalizeTask(result.rows[0]));
} catch (error) {
console.error('Error updating task:', error);
res.status(500).json({ message: 'Error updating task' });
}
});
app.delete('/api/tasks/:id', async (req, res) => {
try {
const { id } = req.params;
const result = await pool.query(
`
DELETE FROM tasks
WHERE id = $1
RETURNING *;
`,
[id]
);
if (result.rows.length === 0) {
return res.status(404).json({ message: 'Task not found' });
}
res.json({
message: 'Task deleted',
task: normalizeTask(result.rows[0]),
});
} catch (error) {
console.error('Error deleting task:', error);
res.status(500).json({ message: 'Error deleting task' });
}
});
app.use((req, res) => {
res.status(404).json({ message: 'Route not found' });
});
initDb()
.then(() => {
app.listen(PORT, '0.0.0.0', () => {
console.log(`Student Study Planner backend running on port ${PORT}`);
console.log(`Environment: ${process.env.NODE_ENV || 'development'}`);
console.log(`SSL for database: ${useSsl ? 'enabled' : 'disabled'}`);
});
})
.catch((error) => {
console.error('Database initialization failed:', error);
process.exit(1);
});

111
finalexam/styles.css Normal file
View File

@ -0,0 +1,111 @@
:root {
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #172033;
background: #eef3ff;
}
* { box-sizing: border-box; }
body { margin: 0; }
button, input, textarea, select { font: inherit; }
.page { min-height: 100vh; padding: 28px; }
.hero {
display: grid;
grid-template-columns: 1.7fr .8fr;
gap: 28px;
align-items: center;
padding: 46px;
border-radius: 32px;
color: white;
background: linear-gradient(135deg, #182848 0%, #3f51d7 58%, #7b61ff 100%);
box-shadow: 0 24px 60px rgba(35, 50, 100, .28);
}
.badge {
display: inline-flex;
padding: 10px 18px;
border-radius: 999px;
background: rgba(255,255,255,.15);
border: 1px solid rgba(255,255,255,.35);
font-weight: 700;
}
h1 { font-size: clamp(42px, 7vw, 82px); line-height: .95; margin: 30px 0 20px; }
.hero p { max-width: 850px; font-size: 21px; line-height: 1.6; margin: 0; }
.heroCard {
display: grid;
gap: 12px;
padding: 24px;
border-radius: 24px;
background: rgba(255,255,255,.14);
border: 1px solid rgba(255,255,255,.28);
backdrop-filter: blur(8px);
}
.heroCard strong { font-size: 22px; }
.heroCard span { opacity: .92; }
.stats { display: grid; grid-template-columns: repeat(5, 1fr); gap: 16px; margin: 28px 0; }
.stats article {
padding: 22px;
border-radius: 24px;
background: white;
box-shadow: 0 16px 35px rgba(25, 44, 90, .10);
}
.stats span { display: block; color: #64708a; font-weight: 700; }
.stats strong { display: block; margin-top: 8px; font-size: 38px; }
.stats .danger strong { color: #c82424; }
.panel, .task, .empty {
background: rgba(255,255,255,.92);
border: 1px solid rgba(113, 130, 180, .25);
box-shadow: 0 18px 45px rgba(25, 44, 90, .10);
border-radius: 28px;
}
.panel { padding: 28px; }
.panelHeader { display: flex; justify-content: space-between; gap: 18px; align-items: start; margin-bottom: 20px; }
.panel h2 { margin: 0; font-size: 30px; }
.panel p { margin: 6px 0 0; color: #6b7488; }
.status { padding: 10px 14px; border-radius: 999px; font-weight: 800; font-size: 14px; }
.status.ok { background: #e6f8ee; color: #137340; }
.status.error { background: #ffe2e2; color: #a31212; }
.taskForm { display: grid; grid-template-columns: 1.2fr .8fr .7fr .7fr .7fr auto; gap: 14px; }
.taskForm textarea { grid-column: 1 / 4; min-height: 80px; resize: vertical; }
.taskForm input, .taskForm textarea, .taskForm select {
width: 100%; border: 1px solid #cfd8ee; border-radius: 18px; padding: 16px 18px; background: white; color: #192136;
}
.taskForm button, .actions button, .toolbar button {
border: 0; border-radius: 18px; padding: 15px 20px; font-weight: 800; cursor: pointer;
background: #473bd1; color: white;
}
.taskForm button { min-width: 130px; }
.toolbar { display: flex; flex-wrap: wrap; gap: 10px; margin: 26px 0 18px; }
.toolbar button { background: white; color: #39445e; border: 1px solid #d4dcef; text-transform: capitalize; }
.toolbar button.active { background: #182848; color: white; }
.taskList { display: grid; gap: 16px; }
.task { padding: 24px; border-left: 9px solid #6b78ff; }
.task.priority-high { border-left-color: #e43f5a; }
.task.priority-normal { border-left-color: #6b78ff; }
.task.priority-low { border-left-color: #22a06b; }
.task.done { opacity: .72; }
.task.done h3 { text-decoration: line-through; }
.taskTop { display: flex; justify-content: space-between; gap: 18px; }
.task h3 { font-size: 24px; margin: 0 0 8px; }
.task p { margin: 0; color: #596579; line-height: 1.5; }
.pill { align-self: start; padding: 8px 12px; border-radius: 999px; font-weight: 900; text-transform: uppercase; font-size: 12px; }
.pill.high { color: #a8122a; background: #ffe1e7; }
.pill.normal { color: #27259a; background: #e7e8ff; }
.pill.low { color: #0c6b42; background: #ddf7e9; }
.meta { display: flex; flex-wrap: wrap; gap: 14px; margin: 18px 0; color: #5f6b80; }
.overdue { color: #c82424 !important; }
.actions { display: flex; gap: 12px; }
.actions button { background: #23345f; }
.actions .delete { background: #f1f3f9; color: #b51f30; }
.empty { padding: 34px; text-align: center; color: #64708a; font-weight: 700; }
@media (max-width: 950px) {
.hero { grid-template-columns: 1fr; padding: 30px; }
.stats { grid-template-columns: repeat(2, 1fr); }
.taskForm { grid-template-columns: 1fr; }
.taskForm textarea { grid-column: auto; }
.panelHeader { flex-direction: column; }
}