first commit

This commit is contained in:
2025-12-02 21:26:08 +01:00
commit 790f4a9f1d
23 changed files with 4085 additions and 0 deletions
+66
View File
@@ -0,0 +1,66 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
*.egg-info/
dist/
build/
# Virtual environments
venv/
env/
ENV/
.venv/
# Testing
.pytest_cache/
.coverage
htmlcov/
*.cover
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# Git
.git/
.gitignore
# Docker
Dockerfile
docker-compose.yml
.dockerignore
# Documentation
*.md
!README.md
# Environment
.env
.env.local
.env.*
# CSV files
*.csv
# Logs
*.log
logs/
# OS
.DS_Store
Thumbs.db
# CI/CD
.github/
.gitlab-ci.yml
# Others
*.bak
*.tmp
*.swp
+15
View File
@@ -0,0 +1,15 @@
# ZynkTime Environment Configuration
# Copy this file to .env and fill in your values
# Required: Kleer API Key
KLEER_API_KEY=your_api_key_here
# Optional: Username (defaults to "Christopher Juhlin")
KLEER_USERNAME=Christopher Juhlin
# Optional: Company ID (defaults to "1336")
KLEER_COMPANY_ID=1336
# Server Configuration (for advanced users)
# SERVER_HOST=0.0.0.0
# SERVER_PORT=8000
+54
View File
@@ -0,0 +1,54 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Virtual environments
venv/
env/
ENV/
.venv
# Testing
.pytest_cache/
.coverage
htmlcov/
*.cover
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# Environment variables
.env
.env.local
# CSV files (user data)
*.csv
# Logs
*.log
# OS
.DS_Store
Thumbs.db
+39
View File
@@ -0,0 +1,39 @@
# Agent Guidelines for zynktime
## Setup
- **Install dependencies**: `pip install -r requirements.txt`
- **Set environment**: `export KLEER_API_KEY=your_key_here` (required)
- **Optional env vars**: `KLEER_USERNAME`, `KLEER_COMPANY_ID`
## Run Commands
- **Run API server**: `python main.py` (starts FastAPI on port 8000)
- **Run CLI mode**: `python main.py cli`
- **Run tests**: `pytest` or `pytest test_main.py -v`
- **Run single test**: `pytest test_main.py::TestServices::test_validate_date_valid -v`
## Project Structure
- **config.py**: Application configuration and environment variables
- **models.py**: Pydantic models for data validation
- **api_client.py**: Kleer API client with error handling
- **services.py**: Business logic (CSV parsing, time extraction, event creation)
- **main.py**: FastAPI application and endpoints
- **cli.py**: CLI interface for interactive time reporting
- **test_main.py**: Unit tests with pytest
## Code Style
**Imports**: Standard library first, third-party second, local imports last. Group by category with blank lines between groups.
**Formatting**: 4 spaces indentation, no trailing whitespace. Use type hints from `typing` (List, Dict, Any, Optional).
**Naming**: snake_case for functions/variables, PascalCase for classes, UPPER_CASE for constants. Be descriptive.
**Error Handling**: Use specific exceptions. Log with `logging.error()`. For API endpoints, raise `HTTPException` with proper status codes (404, 500, 502).
**Logging**: Use appropriate levels - INFO (normal ops), WARNING (skippable issues), ERROR (failures), DEBUG (detailed info).
**Configuration**: Use `Config` class from config.py. Never hardcode values - use Config constants or environment variables.
**API Design**: Use Pydantic models for validation. Document endpoints with docstrings. Return meaningful error messages.
**Testing**: Write tests for new features. Use mocks for external API calls. Maintain test coverage.
+66
View File
@@ -0,0 +1,66 @@
# CSV Format Examples
ZynkTime supports flexible CSV formats with automatic delimiter and column detection.
## Example 1: Standard Time Reporting Format
```csv
reg_period,xreg_period,T,Time code,Time code (T),Vouch.date,Project Activity,Project Activity (T),Work package,Work package (T),Hours,Workflow status,Text
202544,,A,0,Standard Time,10/27/2025,,,,,2.00,N,
202544,,A,0,Standard Time,10/27/2025,,,,,2.00,N,
202544,,A,0,Standard Time,10/27/2025,,,,,4.00,N,
202544,,A,0,Standard Time,10/28/2025,,,,,4.00,N,
202544,,A,0,Standard Time,10/28/2025,,,,,1.00,N,
```
**Result**:
- 2025-10-27: 8.0 hours
- 2025-10-28: 5.0 hours
## Example 2: Semicolon-Separated Format
```csv
project;task;user;status;notes;date;client;region;type;category;hours;approved
ProjectA;Task1;John;Done;Notes;01/15/2024;ClientX;EU;Dev;Backend;8.5;Yes
ProjectA;Task2;John;Done;Notes;01/16/2024;ClientX;EU;Dev;Backend;7,5;Yes
```
**Result**:
- 2024-01-15: 8.5 hours
- 2024-01-16: 7.5 hours
## Features
### Automatic Detection
-**Delimiter**: Auto-detects comma (`,`) or semicolon (`;`)
-**Date Column**: Searches for "date", "vouch", or similar keywords
-**Hours Column**: Searches for "hour", "time" keywords
-**Date Formats**: Supports `MM/DD/YYYY` and `YYYY-MM-DD`
-**Decimal Separator**: Handles both comma and dot (`,` or `.`)
### Data Processing
- Header row automatically skipped
- Whitespace trimmed from values
- Hours below threshold (0.2) filtered out
- Multiple entries for same date are aggregated
- Invalid rows logged and skipped gracefully
## Testing Your CSV
To test if your CSV will work:
```bash
# Method 1: Use the API
curl -X POST "http://localhost:8000/report-time?project_name=YourProject" \
-F "file=@your_file.csv"
# Method 2: Use the CLI
python main.py cli
# Then follow the interactive prompts
```
The application will:
1. Auto-detect your CSV format
2. Show detected columns in logs
3. Display parsed work time before submission
4. Ask for confirmation before reporting
+485
View File
@@ -0,0 +1,485 @@
# Docker Deployment Guide
Complete guide for deploying ZynkTime with Docker.
## Quick Start
### 1. Basic Setup
```bash
# Clone and navigate to project
cd zynktime
# Create environment file
cp .env.example .env
# Edit with your API key
nano .env
# Start the application
docker-compose up -d
# Access at http://localhost:8000
```
### 2. Verify Deployment
```bash
# Check container status
docker-compose ps
# View logs
docker-compose logs -f
# Test health endpoint
curl http://localhost:8000/health
```
## Configuration
### Environment Variables
Edit `.env` file:
```env
# Required
KLEER_API_KEY=your_actual_api_key_here
# Optional (with defaults)
KLEER_USERNAME=Christopher Juhlin
KLEER_COMPANY_ID=1336
```
### Docker Compose Options
**Development Mode** (with live reload):
```yaml
services:
zynktime:
build: .
volumes:
- ./static:/app/static:ro
- ./:/app:ro # Mount source code
environment:
- RELOAD=true
```
**Production Mode** (optimized):
```yaml
services:
zynktime:
image: zynktime:1.0.0
restart: always
read_only: true
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
```
## Common Operations
### Starting and Stopping
```bash
# Start in background
docker-compose up -d
# Start in foreground (see logs)
docker-compose up
# Stop containers
docker-compose down
# Stop and remove volumes
docker-compose down -v
```
### Viewing Logs
```bash
# Follow all logs
docker-compose logs -f
# Last 100 lines
docker-compose logs --tail=100
# Specific service logs
docker-compose logs -f zynktime
```
### Updating the Application
```bash
# Pull latest code
git pull
# Rebuild and restart
docker-compose up -d --build
# Or force recreate
docker-compose up -d --force-recreate
```
### Accessing the Container
```bash
# Open bash shell
docker-compose exec zynktime bash
# Run CLI mode
docker-compose exec zynktime python main.py cli
# Run tests
docker-compose exec zynktime pytest -v
```
## Production Deployment
### With Nginx Reverse Proxy
**nginx.conf:**
```nginx
server {
listen 80;
server_name time.yourdomain.com;
location / {
proxy_pass http://localhost:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
**docker-compose.yml:**
```yaml
version: '3.8'
services:
zynktime:
build: .
restart: always
expose:
- "8000"
networks:
- nginx-proxy
networks:
nginx-proxy:
external: true
```
### With Traefik
```yaml
version: '3.8'
services:
zynktime:
build: .
restart: always
networks:
- traefik
labels:
- "traefik.enable=true"
- "traefik.http.routers.zynktime.rule=Host(`time.example.com`)"
- "traefik.http.routers.zynktime.entrypoints=websecure"
- "traefik.http.routers.zynktime.tls.certresolver=letsencrypt"
- "traefik.http.services.zynktime.loadbalancer.server.port=8000"
networks:
traefik:
external: true
```
### SSL/HTTPS Setup
**Option 1: Let's Encrypt with Traefik**
```yaml
services:
traefik:
image: traefik:v2.9
command:
- "[email protected]"
- "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
- "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web"
```
**Option 2: Manual SSL with Nginx**
```nginx
server {
listen 443 ssl http2;
server_name time.yourdomain.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
location / {
proxy_pass http://localhost:8000;
}
}
```
## Resource Management
### Memory and CPU Limits
```yaml
services:
zynktime:
build: .
deploy:
resources:
limits:
cpus: '1.0'
memory: 512M
reservations:
cpus: '0.5'
memory: 256M
```
### Scaling
```bash
# Run multiple instances
docker-compose up -d --scale zynktime=3
# With load balancer
docker-compose up -d --scale zynktime=3 nginx
```
## Monitoring
### Health Checks
```bash
# Check health status
docker inspect zynktime | jq '.[0].State.Health'
# Watch health status
watch -n 5 'docker inspect zynktime | jq ".[0].State.Health"'
```
### Resource Usage
```bash
# Real-time stats
docker stats zynktime
# Detailed info
docker-compose exec zynktime top
```
### Log Management
**Rotate logs:**
```yaml
services:
zynktime:
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
```
**Send to external logging:**
```yaml
services:
zynktime:
logging:
driver: "syslog"
options:
syslog-address: "tcp://logs.example.com:514"
```
## Backup and Restore
### Backup Configuration
```bash
# Backup .env file
cp .env .env.backup
# Backup logs (if using volume)
docker-compose exec zynktime tar -czf /tmp/logs.tar.gz /app/logs
docker cp zynktime:/tmp/logs.tar.gz ./logs-backup.tar.gz
```
### Disaster Recovery
```bash
# Export container
docker commit zynktime zynktime-backup:$(date +%Y%m%d)
docker save zynktime-backup:latest | gzip > zynktime-backup.tar.gz
# Restore from backup
docker load < zynktime-backup.tar.gz
docker-compose up -d
```
## Troubleshooting
### Container Won't Start
```bash
# Check logs
docker-compose logs zynktime
# Check container details
docker inspect zynktime
# Verify environment
docker-compose config
```
### Permission Issues
```bash
# Fix ownership (if needed)
sudo chown -R 1000:1000 ./static ./logs
# Check user inside container
docker-compose exec zynktime id
```
### Network Issues
```bash
# Test connectivity
docker-compose exec zynktime curl http://localhost:8000/health
# Check network
docker network inspect zynktime-network
# Recreate network
docker-compose down
docker network prune
docker-compose up -d
```
### API Key Issues
```bash
# Verify environment variables
docker-compose exec zynktime env | grep KLEER
# Test API connection
docker-compose exec zynktime python -c "from config import Config; Config.validate_config()"
```
## Security Best Practices
### 1. Run as Non-Root User
Already configured in Dockerfile (user: zynktime, UID: 1000)
### 2. Read-Only Filesystem
```yaml
services:
zynktime:
read_only: true
tmpfs:
- /tmp
```
### 3. Drop Capabilities
```yaml
services:
zynktime:
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
```
### 4. Network Isolation
```yaml
services:
zynktime:
networks:
- internal
# Only expose necessary ports
```
### 5. Secrets Management
```bash
# Use Docker secrets instead of .env
echo "my_api_key" | docker secret create kleer_api_key -
# In compose file:
services:
zynktime:
secrets:
- kleer_api_key
```
## Performance Optimization
### Image Size Optimization
```dockerfile
# Multi-stage build
FROM python:3.11-slim as builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt
FROM python:3.11-slim
COPY --from=builder /root/.local /root/.local
COPY . /app
```
### Caching Strategies
```bash
# Use BuildKit for better caching
DOCKER_BUILDKIT=1 docker-compose build
# Cache requirements separately
RUN --mount=type=cache,target=/root/.cache/pip \
pip install -r requirements.txt
```
## CI/CD Integration
### GitHub Actions
```yaml
name: Build and Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Build and push
run: |
docker build -t zynktime:latest .
docker-compose up -d
```
### GitLab CI
```yaml
deploy:
stage: deploy
script:
- docker-compose build
- docker-compose up -d
only:
- main
```
## Additional Resources
- [Docker Documentation](https://docs.docker.com/)
- [Docker Compose Reference](https://docs.docker.com/compose/)
- [FastAPI Deployment](https://fastapi.tiangolo.com/deployment/)
- [Python Docker Best Practices](https://docs.docker.com/language/python/build-images/)
+97
View File
@@ -0,0 +1,97 @@
# Docker Quick Start Guide
Get ZynkTime running in 3 simple steps!
## Prerequisites
- Docker installed ([Get Docker](https://docs.docker.com/get-docker/))
- Docker Compose installed (included with Docker Desktop)
## Step 1: Configure
```bash
# Copy environment template
cp .env.example .env
# Edit with your API key
nano .env
```
Add your API key:
```env
KLEER_API_KEY=your_actual_api_key_here
KLEER_USERNAME=Your Name
KLEER_COMPANY_ID=1336
```
## Step 2: Start
```bash
# Build and start the application
docker-compose up -d
```
This will:
- Build the Docker image
- Start the container
- Expose the application on port 8000
## Step 3: Use
Open your browser:
```
http://localhost:8000
```
You should see the beautiful ZynkTime web interface!
## Common Commands
```bash
# View logs
docker-compose logs -f
# Stop application
docker-compose down
# Restart after changes
docker-compose restart
# Rebuild after code changes
docker-compose up -d --build
```
## Troubleshooting
**Container won't start?**
```bash
docker-compose logs zynktime
```
**API key not working?**
```bash
# Check environment variables
docker-compose exec zynktime env | grep KLEER
```
**Port already in use?**
Edit `docker-compose.yml` and change the port:
```yaml
ports:
- "8080:8000" # Change 8080 to any free port
```
## Next Steps
- See [DOCKER.md](DOCKER.md) for advanced configuration
- See [README.md](README.md) for full documentation
- See [UI_FEATURES.md](UI_FEATURES.md) for UI details
## Support
For issues, check the logs first:
```bash
docker-compose logs -f zynktime
```
Happy time reporting! 🎉
+35
View File
@@ -0,0 +1,35 @@
# Use Python 3.11 slim image
FROM python:3.11-slim
# Set working directory
WORKDIR /app
# Set environment variables
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
# Install dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY . .
# Create a non-root user
RUN useradd -m -u 1000 zynktime && \
chown -R zynktime:zynktime /app
# Switch to non-root user
USER zynktime
# Expose port
EXPOSE 8000
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD python -c "import requests; requests.get('http://localhost:8000/health')" || exit 1
# Run the application
CMD ["python", "main.py"]
+312
View File
@@ -0,0 +1,312 @@
# ZynkTime
A time reporting application for the Kleer API with both FastAPI web interface and CLI.
## Features
- 🎨 **Modern Web UI**: Beautiful, responsive web interface with drag-and-drop file upload
- 📊 **CSV Processing**: Parse and extract work time from CSV files
- 🌐 **REST API**: FastAPI endpoints for time reporting
- 💻 **CLI Interface**: Interactive command-line time reporting
-**Input Validation**: Pydantic models for data validation
- 🧪 **Tested**: Unit tests with pytest
- 📝 **Well-Documented**: Comprehensive docstrings and type hints
## Quick Start
### 🐳 Docker (Recommended)
The easiest way to run ZynkTime is with Docker:
```bash
# 1. Clone the repository
cd zynktime
# 2. Copy environment file
cp .env.example .env
# 3. Edit .env and add your API key
nano .env # or use your favorite editor
# 4. Start with Docker Compose
docker-compose up -d
# 5. Open your browser
# Navigate to: http://localhost:8000
```
**Docker Commands:**
```bash
# Start the application
docker-compose up -d
# View logs
docker-compose logs -f
# Stop the application
docker-compose down
# Rebuild after code changes
docker-compose up -d --build
# Access container shell
docker-compose exec zynktime bash
```
### 🐍 Manual Installation
If you prefer to run without Docker:
```bash
# Clone the repository
cd zynktime
# Create virtual environment
python -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
# Install dependencies
pip install -r requirements.txt
```
### Configuration
**Option 1: Environment File (Recommended)**
```bash
# Copy example file
cp .env.example .env
# Edit with your values
nano .env
```
**Option 2: Export Variables**
```bash
export KLEER_API_KEY="your_api_key_here"
export KLEER_USERNAME="Your Name" # Optional, defaults to "Christopher Juhlin"
export KLEER_COMPANY_ID="1336" # Optional, defaults to "1336"
```
### Running the Application
**Web UI Mode (Recommended):**
```bash
python main.py
# Open your browser and navigate to: http://localhost:8000
```
**CLI Mode:**
```bash
python main.py cli
# Interactive CLI for processing CSV files
```
### Using the Web UI
1. **Open your browser** to `http://localhost:8000`
2. **Upload your CSV file** by dragging and dropping or clicking to browse
3. **Select a project** from the dropdown
4. **Click "Report Time"** to submit
5. View the **results summary** with total hours and days reported
The web interface features:
- 🎯 Drag-and-drop file upload
- 📋 Real-time project list
- 💫 Smooth animations and transitions
- 📱 Fully responsive design
- 🔔 Toast notifications for feedback
- ✅ Connection status indicator
## API Endpoints
### GET /
Serves the modern web UI interface.
### GET /api
Returns API information and available endpoints.
### GET /health
Health check endpoint - verifies API key configuration.
**Response:**
```json
{
"status": "healthy",
"api_key_configured": true
}
```
### GET /projects
Lists all active projects for the configured user.
**Response:**
```json
{
"Project Name": {
"id": 123,
"activity": {"id": 456}
}
}
```
### POST /report-time
Upload a CSV file and report time to a specific project.
**Parameters:**
- `project_name` (query): Name of the project
- `file` (form): CSV file with time entries
**Response:**
```json
{
"message": "Time reported successfully.",
"reported_days": 5,
"total_hours": 40.0,
"project_name": "Project Name",
"user_name": "Your Name"
}
```
### GET /docs
Interactive API documentation (Swagger UI).
## CSV Format
The application **automatically detects** the CSV delimiter (comma or semicolon) and column structure.
### Supported Formats
**Format 1: Comma-separated with headers**
```csv
reg_period,xreg_period,T,Time code,Time code (T),Vouch.date,Project Activity,Project Activity (T),Work package,Work package (T),Hours,Workflow status,Text
202544,,A,0,Standard Time,10/27/2025,,,,,2.00,N,
202544,,A,0,Standard Time,10/28/2025,,,,,4.00,N,
```
**Format 2: Semicolon-separated**
```csv
...;...;...;...;...;01/15/2024;...;...;...;...;8.5;...
...;...;...;...;...;01/16/2024;...;...;...;...;7,5;...
```
### Column Detection
- **Date column**: Automatically detected by searching for columns containing "date" or "vouch" in the header
- **Hours column**: Automatically detected by searching for columns containing "hour" in the header
- **Date formats**: Supports both `MM/DD/YYYY` and `YYYY-MM-DD`
- **Hours format**: Supports decimal hours with comma (`,`) or dot (`.`) as separator
- **Whitespace**: Automatically trimmed from values
## Development
### Project Structure
```
zynktime/
├── static/
│ ├── index.html # Modern web UI
│ ├── css/
│ │ └── style.css # Responsive styling
│ └── js/
│ └── app.js # Frontend logic
├── main.py # FastAPI application entry point
├── cli.py # CLI interface
├── config.py # Configuration management
├── models.py # Pydantic models
├── api_client.py # Kleer API client
├── services.py # Business logic
├── test_main.py # Unit tests
├── requirements.txt # Python dependencies
├── AGENTS.md # Guidelines for AI agents
└── README.md # This file
```
### Running Tests
```bash
# Run all tests
pytest
# Run with verbose output
pytest -v
# Run specific test
pytest test_main.py::TestServices::test_validate_date_valid -v
# Run with coverage
pytest --cov=. --cov-report=html
```
### Code Quality
The codebase follows:
- **Type hints**: Full typing support
- **Error handling**: Specific exceptions with proper logging
- **Separation of concerns**: Modular architecture
- **Documentation**: Comprehensive docstrings
- **Testing**: Unit tests for core functionality
## Environment Variables
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `KLEER_API_KEY` | Yes | - | API key for Kleer API |
| `KLEER_USERNAME` | No | Christopher Juhlin | Username for API requests |
| `KLEER_COMPANY_ID` | No | 1336 | Company ID for API requests |
## Docker Deployment
### Production Deployment
For production, you can customize the docker-compose.yml:
```yaml
version: '3.8'
services:
zynktime:
image: zynktime:latest
restart: always
ports:
- "80:8000" # Expose on port 80
environment:
- KLEER_API_KEY=${KLEER_API_KEY}
- KLEER_USERNAME=${KLEER_USERNAME}
- KLEER_COMPANY_ID=${KLEER_COMPANY_ID}
env_file:
- .env
```
### Behind a Reverse Proxy
If deploying behind nginx or traefik:
```yaml
# docker-compose.yml
services:
zynktime:
image: zynktime:latest
networks:
- proxy
labels:
- "traefik.enable=true"
- "traefik.http.routers.zynktime.rule=Host(`time.example.com`)"
```
### Health Checks
The Docker container includes health checks:
- **Endpoint**: `/health`
- **Interval**: 30 seconds
- **Timeout**: 10 seconds
- **Retries**: 3
Monitor with:
```bash
docker-compose ps
docker inspect zynktime | grep Health
```
## License
This project is for internal use with the Kleer API.
BIN
View File
Binary file not shown.
+206
View File
@@ -0,0 +1,206 @@
# ZynkTime Web UI Features
## 🎨 Modern Design
The ZynkTime web interface features a beautiful, modern design with:
### Visual Features
- **Gradient Background**: Eye-catching purple gradient background
- **Glassmorphism Cards**: Clean white cards with subtle shadows
- **Smooth Animations**: Fade-in, slide-in, and hover animations
- **Responsive Layout**: Works perfectly on desktop, tablet, and mobile
- **Professional Typography**: Inter font family for clean readability
### Color Scheme
- **Primary**: Indigo (#6366f1) - Main actions and accents
- **Secondary**: Purple (#8b5cf6) - Gradient accents
- **Success**: Green (#10b981) - Success states
- **Danger**: Red (#ef4444) - Error states
- **Warning**: Amber (#f59e0b) - Warning states
## 📋 Main Features
### 1. Header Section
- **Logo & Branding**: ZynkTime logo with clock icon
- **Status Badge**: Real-time connection status
- 🟢 Green dot = Connected and healthy
- 🔴 Red dot = API key not configured
- ⚫ Gray dot = Checking connection
### 2. File Upload Section
**Drag & Drop Zone**
- Large, inviting upload area
- Upload icon with clear instructions
- "Drop your CSV file here or browse files"
- Supported formats indicator
- Hover effect with color change
- Drag-over visual feedback
**File Preview**
- Shows selected filename
- Displays file size
- Remove button to clear selection
- Smooth slide-in animation
### 3. Project Selection
- Dropdown with all available projects
- Clean select styling with custom arrow
- Disabled until projects load
- Updates dynamically when refreshed
### 4. Submit Button
- Full-width primary button
- Gradient background (indigo to purple)
- Right arrow icon
- Disabled state when file or project not selected
- Hover lift effect
- Loading state during submission
### 5. Results Display
**Success Card**
- Animated slide-in appearance
- Checkmark icon
- Success message
- 4-column stats grid:
- Days Reported
- Total Hours
- Project Name
- User Name
- Large, bold numbers for quick scanning
### 6. Available Projects Section
- Lists all active projects
- Each project shows:
- Project name
- Project ID
- Activity ID
- Refresh button with spin animation
- Hover effects on project items
## 🔔 Toast Notifications
Beautiful toast notifications appear in top-right corner:
- **Success**: Green border, checkmark icon
- **Error**: Red border, X icon
- **Warning**: Amber border, triangle icon
- **Info**: Blue border, info icon
Features:
- Slide-in animation from right
- Auto-dismiss after 3 seconds
- Slide-out animation on dismiss
- Multiple toasts stack vertically
## ⏳ Loading States
### Full-Screen Overlay
- Semi-transparent dark background
- Blur effect (backdrop-filter)
- Spinning loader animation
- "Processing your request..." message
- Prevents interaction during loading
### Inline Loading
- "Loading projects..." text
- Shown while fetching data
- Replaced with content when ready
## 📱 Responsive Design
### Desktop (> 768px)
- Max width: 1200px
- Centered layout
- 2rem padding
- Multi-column grid layouts
### Mobile (< 768px)
- Full-width layout
- 1rem padding
- Header stacks vertically
- Toast notifications full-width
- Single-column layouts
- Optimized touch targets
## 🎭 Interactions & Animations
### Hover Effects
- Cards lift slightly on hover
- Buttons change shade
- Links show underline
- Icons change color
- Smooth 0.2s transitions
### Click Effects
- Buttons press down (active state)
- Ripple-like visual feedback
- State changes are immediate
### Loading Animations
- Spinner rotation (0.8s linear infinite)
- Pulse animation for status dots
- Smooth fade transitions
## 🎯 User Experience
### Visual Feedback
- Every action has a response
- Status indicators update in real-time
- Form validation with button states
- Clear error messages in toasts
- Success celebrations with stats
### Accessibility
- Semantic HTML structure
- Proper heading hierarchy
- ARIA labels where needed
- Keyboard navigation support
- Focus states on interactive elements
- High contrast text
- Readable font sizes (min 14px)
### Progressive Enhancement
- Works without JavaScript for basic tasks
- Graceful degradation
- Error handling with user-friendly messages
- Offline detection
## 🚀 Performance
### Optimizations
- Minimal DOM manipulation
- CSS animations (GPU accelerated)
- Debounced event handlers
- Efficient event delegation
- Lazy loading where possible
### Fast Loading
- No external dependencies (except Google Fonts)
- Inline critical CSS option
- Minimal JavaScript bundle
- Optimized SVG icons
## 📊 Data Display
### Stats Cards
- Large, bold numbers
- Descriptive labels
- Grid layout for easy scanning
- Highlighted with primary color
- Rounded corners for modern look
### Project List
- Clean, scannable format
- Metadata clearly displayed
- Subtle hover states
- Easy to identify projects
- Refresh option always visible
## 💅 Polish & Details
- Rounded corners throughout (0.5rem - 1rem)
- Consistent spacing (multiples of 0.5rem)
- Box shadows for depth
- Gradient accents for visual interest
- Smooth color transitions
- Professional iconography
- Thoughtful micro-interactions
+234
View File
@@ -0,0 +1,234 @@
import logging
from typing import Any, Dict, Optional
import requests
from fastapi import HTTPException
from config import Config
class KleerAPIError(Exception):
"""Custom exception for Kleer API errors."""
pass
class KleerAPI:
"""Client for interacting with the Kleer API."""
def __init__(self, api_url: Optional[str] = None, company_id: Optional[str] = None):
"""
Initialize Kleer API client.
Args:
api_url: Base URL for the Kleer API
company_id: Company ID for API requests
Raises:
ValueError: If API key is not configured
"""
self.api_url = api_url or Config.API_URL
self.company_id = company_id or Config.get_company_id()
self.api_key = Config.get_api_key()
if not self.api_key:
raise ValueError("KLEER_API_KEY environment variable is not set")
def _get_headers(self) -> Dict[str, str]:
"""Get headers for API requests."""
return {
'X-token': self.api_key,
'Content-Type': 'application/json'
}
def api_request(
self,
endpoint: str,
method: str = 'GET',
data: Optional[Any] = None
) -> Optional[Any]:
"""
Make a request to the Kleer API.
Args:
endpoint: API endpoint path
method: HTTP method (GET, POST, PUT)
data: Request payload for POST/PUT requests
Returns:
JSON response from the API
Raises:
HTTPException: If the request fails
"""
url = f"{self.api_url}/{endpoint}"
headers = self._get_headers()
response = None
try:
if method == 'GET':
response = requests.get(url, headers=headers)
elif method == 'POST':
response = requests.post(url, headers=headers, json=data)
elif method == 'PUT':
response = requests.put(url, headers=headers, json=data)
else:
raise ValueError(f"Unsupported HTTP method: {method}")
response.raise_for_status()
return response.json()
except requests.HTTPError as e:
logging.error(f"HTTP error during API request: {e}")
raise HTTPException(
status_code=response.status_code if response else 502,
detail=f"API request failed: {str(e)}"
)
except requests.RequestException as e:
logging.error(f"API request failed: {e}")
raise HTTPException(status_code=502, detail=f"API request failed: {str(e)}")
def get_events(self, user_id: int, start_date: str, end_date: str) -> Dict[str, Any]:
"""
Retrieve events for a user within a date range.
Args:
user_id: User ID
start_date: Start date in YYYY-MM-DD format
end_date: End date in YYYY-MM-DD format
Returns:
Event data from API
"""
endpoint = (
f"company/{self.company_id}/event"
f"?userId={user_id}&startDate={start_date}&endDate={end_date}"
)
return self.api_request(endpoint, method='GET') or {}
def approve_events(self, user_id: int, start_date: str, end_date: str) -> Dict[str, Any]:
"""
Approve events for a user within a date range.
Args:
user_id: User ID
start_date: Start date in YYYY-MM-DD format
end_date: End date in YYYY-MM-DD format
Returns:
Approval response from API
"""
endpoint = f"company/{self.company_id}/event/approve"
payload = {
"user-id": {"id": user_id},
"start-date": start_date,
"end-date": end_date
}
return self.api_request(endpoint, method='POST', data=payload) or {}
def put_event(self, event: Dict[str, Any]) -> Optional[Any]:
"""
Submit a time event to the Kleer API.
Args:
event: Time event data
Returns:
API response
Raises:
HTTPException: If the request fails
"""
url = f"company/{self.company_id}/event"
try:
response = self.api_request(url, method='PUT', data=event)
logging.info(f"Successfully reported event for date: {event.get('date')}")
return response
except HTTPException as e:
logging.error(
f"Failed to put event for date {event.get('date')}. "
f"Status: {e.status_code}, Detail: {e.detail}"
)
raise
except Exception as e:
logging.error(
f"Unexpected error while putting event for date {event.get('date')}: {e}"
)
raise HTTPException(
status_code=500,
detail=f"An unexpected error occurred: {str(e)}"
)
def get_projects(self, user_id: int) -> Dict[str, Dict[str, Any]]:
"""
Retrieve active projects for a user.
Args:
user_id: User ID to filter projects
Returns:
Dictionary mapping project names to project info
"""
url = f"company/{self.company_id}/client-project?filter=active"
data = self.api_request(url, method='GET')
if not data:
logging.warning("No data received from projects API.")
return {}
projects_data = data.get('client-project-readables') if isinstance(data, dict) else data
if not isinstance(projects_data, list):
logging.error("Unexpected projects data format.")
return {}
projects = {}
for project in projects_data:
if not isinstance(project, dict):
logging.warning(f"Unexpected project data format: {project}")
continue
project_id = project.get('id')
project_name = project.get('name')
activity_id = None
users = project.get('users', [])
for user in users:
if user.get('user', {}).get('id') != user_id:
continue
activities = user.get('activities', [])
for activity in activities:
activity_id = activity.get('activity', {})
if activity_id:
break
if activity_id:
break
if not project_id or not project_name:
logging.info(f"Skipping project with missing ID or name: {project}")
continue
if activity_id:
projects[project_name] = {"id": project_id, "activity": activity_id}
else:
logging.info(
f"Project '{project_name}' (ID: {project_id}) has no associated activity."
)
logging.info(f"Found {len(projects)} valid projects.")
return projects
def get_user_info(self) -> Optional[Dict[str, Any]]:
"""
Retrieve user information from the Kleer API.
Returns:
User information dictionary or None if not found
"""
url = f"company/{self.company_id}/user"
data = self.api_request(url, method='GET')
if data and isinstance(data, dict):
logging.info("User info retrieved from Kleer API.")
return data
logging.warning("No user information found or data format is incorrect.")
return None
+147
View File
@@ -0,0 +1,147 @@
import logging
import pathlib
import sys
import traceback
from typing import Any, Dict, Optional
from api_client import KleerAPI
from config import Config
from services import (
create_time_event,
extract_work_time,
get_user_by_name,
parse_csv,
parse_xlsx,
)
def run_cli():
"""Run the CLI interface for time reporting."""
# Validate configuration
try:
Config.validate_config()
except ValueError as e:
logging.error(str(e))
sys.exit(1)
# Find CSV and XLSX files
current_directory = pathlib.Path.cwd()
data_files = list(current_directory.glob('*.csv')) + list(current_directory.glob('*.xlsx'))
if not data_files:
logging.error("No CSV or XLSX files found in the current directory.")
sys.exit(1)
logging.info(f"Data files found: {', '.join(f.name for f in data_files)}")
# Initialize API client
kleer_api = KleerAPI()
# Process each CSV file
for data_file in data_files:
logging.info(f"Processing file: {data_file.name}")
try:
# Get user information
users_data = kleer_api.get_user_info()
username = Config.get_username()
try:
user_id = get_user_by_name(users_data, username)
except ValueError as e:
logging.error(str(e))
continue
logging.info(f"Using user ID: {user_id} for events.")
# Get projects
projects = kleer_api.get_projects(user_id=user_id)
if not projects:
logging.warning("No projects found for user.")
continue
logging.info("Available projects from Kleer API:")
for name, value in projects.items():
logging.info(f"- {name}: {value}")
# Parse CSV file
if data_file.suffix.lower() == '.xlsx':
content = data_file.read_bytes()
rows = parse_xlsx(content)
else:
with data_file.open(mode='r', newline='', encoding='utf-8') as f:
content = f.read()
rows = parse_csv(content)
work_time = extract_work_time(rows)
if not work_time:
logging.info(f"No valid work time found in {data_file.name}.")
continue
# Let user select project
project_info = _select_project(projects)
if not project_info:
logging.warning("No project selected, skipping file.")
continue
# Display work time summary
logging.info("Total work time by date:")
total_work_time = 0.0
for date, total_time in sorted(work_time.items()):
logging.info(f"{date}: {total_time:.2f} hours")
total_work_time += total_time
# Create and submit event
event = create_time_event(
user_id=user_id,
project_id=project_info["id"],
activity=project_info["activity"],
date=date,
hours=total_time
)
kleer_api.put_event(event)
logging.info(f"Total work time for all dates: {total_work_time:.2f} hours")
except Exception as e:
logging.error(f"Error processing {csv_file.name}: {e}")
logging.error(traceback.format_exc())
def _select_project(projects: Dict[str, Dict[str, Any]]) -> Optional[Dict[str, Any]]:
"""
Let user select a project from available projects.
Args:
projects: Dictionary of available projects
Returns:
Selected project info or None if cancelled
"""
while True:
print("\nAvailable projects:")
project_list = list(projects.items())
for idx, (name, value) in enumerate(project_list, start=1):
print(f"{idx}. {name} (ID: {value['id']}, Activity: {value['activity']})")
project_choice = input("\nSelect a project by number (or 'q' to quit): ")
if project_choice.lower() == 'q':
return None
try:
project_index = int(project_choice) - 1
if 0 <= project_index < len(project_list):
_, project_info = project_list[project_index]
return project_info
else:
logging.error("Invalid project selection. Please try again.")
except ValueError:
logging.error("Invalid input. Please enter a number or 'q' to quit.")
if __name__ == "__main__":
run_cli()
+49
View File
@@ -0,0 +1,49 @@
import os
from typing import Optional
class Config:
"""Application configuration management."""
# API Configuration
API_URL: str = "https://api.kleer.se/v1"
DEFAULT_COMPANY_ID: str = "1336"
API_KEY : str = "rjAXm8PM70kqTUt" # To be set via environment variable
# Date Formats
CSV_DATE_FORMAT: str = '%m/%d/%Y'
API_DATE_FORMAT: str = '%Y-%m-%d'
XLSX_SHEET_NAME: str = "AGRESSO"
# CSV Column Indices
DATE_COL_INDEX: int = 5
HOURS_COL_INDEX: int = 10
# Business Rules
MIN_HOURS_THRESHOLD: float = 0.2
# Server Configuration
SERVER_HOST: str = "0.0.0.0"
SERVER_PORT: int = 8000
# Environment Variables
@staticmethod
def get_api_key() -> Optional[str]:
"""Get Kleer API key from environment."""
return os.getenv('KLEER_API_KEY',Config.API_KEY)
@staticmethod
def get_username() -> str:
"""Get username from environment with fallback."""
return os.getenv('KLEER_USERNAME', 'Christopher Juhlin')
@staticmethod
def get_company_id() -> str:
"""Get company ID from environment with fallback."""
return os.getenv('KLEER_COMPANY_ID', Config.DEFAULT_COMPANY_ID)
@staticmethod
def validate_config() -> None:
"""Validate required configuration is present."""
if not Config.get_api_key():
raise ValueError("KLEER_API_KEY environment variable is not set")
+42
View File
@@ -0,0 +1,42 @@
version: '3.8'
services:
zynktime:
build:
context: .
dockerfile: Dockerfile
container_name: zynktime
restart: unless-stopped
ports:
- "8000:8000"
environment:
- KLEER_API_KEY=${KLEER_API_KEY}
- KLEER_USERNAME=${KLEER_USERNAME:-Christopher Juhlin}
- KLEER_COMPANY_ID=${KLEER_COMPANY_ID:-1336}
env_file:
- .env
volumes:
# Mount for development (comment out for production)
- ./static:/app/static:ro
# Volume for logs (optional)
- ./logs:/app/logs
networks:
- zynktime-network
healthcheck:
test: ["CMD", "python", "-c", "import requests; requests.get('http://localhost:8000/health')"]
interval: 30s
timeout: 10s
retries: 3
start_period: 5s
labels:
- "com.zynktime.description=ZynkTime - Time Reporting Application"
- "com.zynktime.version=1.0.0"
networks:
zynktime-network:
driver: bridge
name: zynktime-network
volumes:
logs:
driver: local
+334
View File
@@ -0,0 +1,334 @@
"""
ZynkTime - Time reporting application for Kleer API.
This module provides FastAPI endpoints for time reporting and a CLI interface.
"""
import logging
import sys
from datetime import datetime
import fastapi
import uvicorn
from fastapi import File, HTTPException, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse, FileResponse
from fastapi.staticfiles import StaticFiles
from api_client import KleerAPI
from cli import run_cli
from config import Config
from models import TimeReportResponse
from services import (
create_time_event,
extract_work_time,
get_user_by_name,
parse_csv,
parse_xlsx,
validate_date,
)
# Configure logging
logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s')
# Initialize FastAPI app
app = fastapi.FastAPI(
title="ZynkTime API",
description="Time reporting API for Kleer",
version="1.0.0"
)
# Add CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Mount static files
app.mount("/static", StaticFiles(directory="static"), name="static")
@app.get("/", response_class=HTMLResponse)
async def root():
"""Serve the web UI."""
return FileResponse("static/index.html")
@app.get("/api")
def api_info():
"""API information endpoint."""
return {
"name": "ZynkTime API",
"version": "1.0.0",
"endpoints": {
"projects": "/projects",
"report_time": "/report-time"
}
}
@app.get("/health")
def health_check():
"""Health check endpoint."""
try:
Config.validate_config()
return {"status": "healthy", "api_key_configured": True}
except ValueError:
return {"status": "unhealthy", "api_key_configured": False}
@app.get("/projects")
def list_projects():
"""
List all active projects for the configured user.
Returns:
Dictionary of project names to project information
Raises:
HTTPException: If user not found or API request fails
"""
try:
kleer_api = KleerAPI()
except ValueError as e:
raise HTTPException(status_code=500, detail=str(e))
users_data = kleer_api.get_user_info()
username = Config.get_username()
try:
user_id = get_user_by_name(users_data, username)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
projects = kleer_api.get_projects(user_id=user_id)
return projects
@app.post("/report-time", response_model=TimeReportResponse)
async def report_time_from_csv(
project_name: str,
file: UploadFile = File(...)
):
"""
Upload a CSV file and report time to a specified Kleer project.
Args:
project_name: Name of the project to report time to
file: CSV file containing time entries
Returns:
TimeReportResponse with summary of reported time
Raises:
HTTPException: If validation fails or API request fails
"""
# Initialize API client
try:
kleer_api = KleerAPI()
except ValueError as e:
raise HTTPException(status_code=500, detail=str(e))
# Get user ID
users_data = kleer_api.get_user_info()
username = Config.get_username()
try:
user_id = get_user_by_name(users_data, username)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
# Get project info
projects = kleer_api.get_projects(user_id=user_id)
project_info = projects.get(project_name)
if not project_info:
available_projects = ", ".join(projects.keys())
raise HTTPException(
status_code=404,
detail=f"Project '{project_name}' not found. Available projects: {available_projects}"
)
# Process file content
content = await file.read()
filename = (file.filename or "").lower()
content_type = (file.content_type or "").lower()
try:
if filename.endswith(".xlsx") or "spreadsheetml" in content_type:
rows = parse_xlsx(content)
else:
rows = parse_csv(content.decode('utf-8-sig'))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=400, detail=f"Failed to parse file: {str(e)}")
work_time = extract_work_time(rows)
if not work_time:
raise HTTPException(
status_code=400,
detail="No valid work time found in the uploaded CSV."
)
# Submit events
total_work_time = 0.0
failed_dates = []
for date, total_time in work_time.items():
try:
event = create_time_event(
user_id=user_id,
project_id=project_info["id"],
activity=project_info["activity"],
date=date,
hours=total_time
)
kleer_api.put_event(event)
total_work_time += total_time
except Exception as e:
logging.error(f"Failed to report time for {date}: {e}")
failed_dates.append(date)
# Check if any submissions failed
if failed_dates:
raise HTTPException(
status_code=500,
detail=f"Failed to report time for dates: {', '.join(failed_dates)}"
)
return TimeReportResponse(
message="Time reported successfully.",
reported_days=len(work_time),
total_hours=total_work_time,
project_name=project_name,
user_name=username
)
@app.get("/salary")
def get_salary_info():
"""
Placeholder endpoint for salary information.
Returns:
Static message indicating feature is under development.
"""
# Initialize API client
try:
kleer_api = KleerAPI()
except ValueError as e:
raise HTTPException(status_code=500, detail=str(e))
# Get user ID
users_data = kleer_api.get_user_info()
username = Config.get_username()
try:
user_id = get_user_by_name(users_data, username)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
return {"message": "Salary information feature is under development."}
def main():
"""Main entry point for the application."""
if len(sys.argv) > 1 and sys.argv[1] == 'cli':
# Run CLI mode
run_cli()
else:
# Run API server
print("Starting ZynkTime FastAPI server...")
print("To run CLI mode, use: python main.py cli")
print(f"Server will be available at http://{Config.SERVER_HOST}:{Config.SERVER_PORT}")
print("API documentation available at /docs")
uvicorn.run(
app,
host=Config.SERVER_HOST,
port=Config.SERVER_PORT
)
def _get_user_id(kleer_api: KleerAPI) -> int:
"""Resolve configured username to user ID using API data."""
users_data = kleer_api.get_user_info()
username = Config.get_username()
try:
return get_user_by_name(users_data, username)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
def _validate_date_range(start_date: str, end_date: str) -> None:
"""Validate date strings and ordering."""
if not (validate_date(start_date) and validate_date(end_date)):
raise HTTPException(status_code=400, detail="Dates must be in YYYY-MM-DD format")
if datetime.fromisoformat(start_date) > datetime.fromisoformat(end_date):
raise HTTPException(
status_code=400,
detail="Start date must be on or before end date"
)
@app.get("/events")
def list_events(start_date: str, end_date: str):
"""
List events for the configured user between two dates (inclusive).
"""
_validate_date_range(start_date, end_date)
try:
kleer_api = KleerAPI()
except ValueError as e:
raise HTTPException(status_code=500, detail=str(e))
user_id = _get_user_id(kleer_api)
events = kleer_api.get_events(user_id=user_id, start_date=start_date, end_date=end_date)
return {
"user_id": user_id,
"start_date": start_date,
"end_date": end_date,
"events": events.get("event-readables", events)
}
@app.post("/approve-events")
def approve_events(start_date: str, end_date: str):
"""
Approve events for the configured user in a date range.
"""
_validate_date_range(start_date, end_date)
try:
kleer_api = KleerAPI()
except ValueError as e:
raise HTTPException(status_code=500, detail=str(e))
user_id = _get_user_id(kleer_api)
approval_response = kleer_api.approve_events(
user_id=user_id,
start_date=start_date,
end_date=end_date
)
return {
"message": "Events approved",
"user_id": user_id,
"start_date": start_date,
"end_date": end_date,
"approval": approval_response
}
if __name__ == "__main__":
main()
+45
View File
@@ -0,0 +1,45 @@
from typing import Dict, Any
from pydantic import BaseModel, Field
class ProjectInfo(BaseModel):
"""Project information model."""
id: int
activity: Dict[str, Any]
class TimeReportResponse(BaseModel):
"""Response model for time reporting endpoint."""
message: str
reported_days: int
total_hours: float
project_name: str
user_name: str
class TimeEvent(BaseModel):
"""Time event model for Kleer API."""
foreign_id: str = Field(default="", alias="foreign-id")
user: Dict[str, int]
client_project: int = Field(alias="client-project")
activity: Dict[str, Any]
date: str
hours: float
comment: str = ""
internal_comment: str = Field(default="", alias="internal-comment")
class Config:
populate_by_name = True
class UserInfo(BaseModel):
"""User information model."""
id: int
name: str
class ProjectResponse(BaseModel):
"""Project response model."""
id: int
name: str
activity_id: Dict[str, Any]
+13
View File
@@ -0,0 +1,13 @@
# Core dependencies
fastapi==0.120.3
uvicorn[standard]==0.38.0
pydantic==2.12.3
requests==2.32.5
openpyxl==3.1.5
# Development dependencies
pytest==8.4.2
pytest-asyncio==1.2.0
pytest-cov==7.0.0
httpx==0.28.1 # For testing FastAPI endpoints
openpyxl==3.1.5
+231
View File
@@ -0,0 +1,231 @@
import csv
import io
import logging
from datetime import date, datetime
from typing import Dict, List
from openpyxl import load_workbook
from config import Config
def validate_date(date_text: str) -> bool:
"""
Validate if date string is in ISO format (YYYY-MM-DD).
Args:
date_text: Date string to validate
Returns:
True if valid, False otherwise
"""
try:
datetime.fromisoformat(date_text)
return True
except ValueError:
return False
def parse_csv(file_content: str) -> List[List[str]]:
"""
Parse CSV content into rows with automatic delimiter detection.
Args:
file_content: CSV file content as string
Returns:
List of rows, where each row is a list of values
"""
# Use csv.Sniffer to auto-detect delimiter
try:
sample = file_content[:1024] # Use first 1KB for detection
sniffer = csv.Sniffer()
delimiter = sniffer.sniff(sample).delimiter
logging.info(f"Detected CSV delimiter: '{delimiter}'")
except Exception as e:
# Fallback to comma if detection fails
logging.warning(f"Could not detect delimiter, using comma: {e}")
delimiter = ','
reader = csv.reader(io.StringIO(file_content), delimiter=delimiter)
return list(reader)
def parse_xlsx(file_bytes: bytes) -> List[List[str]]:
"""
Parse XLSX content into rows from the configured worksheet.
Args:
file_bytes: XLSX file content as bytes
Returns:
List of rows, where each row is a list of values
Raises:
ValueError: If the worksheet is missing or the file cannot be read
"""
try:
workbook = load_workbook(io.BytesIO(file_bytes), read_only=True, data_only=True)
except Exception as exc: # pragma: no cover - relies on openpyxl internals
raise ValueError(f"Failed to read XLSX file: {exc}") from exc
if Config.XLSX_SHEET_NAME not in workbook.sheetnames:
raise ValueError(f"Worksheet '{Config.XLSX_SHEET_NAME}' not found in XLSX file")
sheet = workbook[Config.XLSX_SHEET_NAME]
rows: List[List[str]] = []
for row in sheet.iter_rows(values_only=True):
cleaned_row = []
for cell in row:
if cell is None:
cleaned_row.append("")
elif isinstance(cell, datetime):
cleaned_row.append(cell.date().isoformat())
elif isinstance(cell, date):
cleaned_row.append(cell.isoformat())
else:
cleaned_row.append(str(cell).strip())
if any(value for value in cleaned_row):
rows.append(cleaned_row)
return rows
def extract_work_time(rows: List[List[str]]) -> Dict[str, float]:
"""
Extract and aggregate work time from CSV rows.
Args:
rows: A list of lists representing the rows of a CSV file
Returns:
A dictionary mapping dates (in 'YYYY-MM-DD' format) to total hours worked
"""
work_time: Dict[str, float] = {}
# Try to detect column indices from header
date_col_idx = Config.DATE_COL_INDEX
hours_col_idx = Config.HOURS_COL_INDEX
if rows and len(rows) > 0:
header = [str(col).strip().lower() for col in rows[0]]
# Look for date-related columns
for idx, col in enumerate(header):
if 'date' in col or 'vouch' in col:
date_col_idx = idx
logging.info(f"Detected date column at index {idx}: '{rows[0][idx]}'")
break
# Look for hours-related columns
for idx, col in enumerate(header):
if 'hour' in col or 'time' in col and 'code' not in col:
hours_col_idx = idx
logging.info(f"Detected hours column at index {idx}: '{rows[0][idx]}'")
break
for i, row in enumerate(rows):
# Skip header row
if i == 0:
continue
# Skip short rows
if len(row) <= max(date_col_idx, hours_col_idx):
logging.debug(
f"Skipping row {i+1} as it has fewer than "
f"{max(date_col_idx, hours_col_idx)+1} columns."
)
continue
date_str = row[date_col_idx].strip() if date_col_idx < len(row) else ""
hours_str = row[hours_col_idx].strip() if hours_col_idx < len(row) else ""
if not date_str or not hours_str:
logging.debug(f"Skipping row {i+1} due to missing date or hours.")
continue
try:
# Parse date, trying API format first, then CSV format
try:
parsed_date = datetime.strptime(date_str, Config.API_DATE_FORMAT)
except ValueError:
parsed_date = datetime.strptime(date_str, Config.CSV_DATE_FORMAT)
api_formatted_date = parsed_date.strftime(Config.API_DATE_FORMAT)
# Parse hours, allowing for comma as decimal separator and whitespace
hours = float(hours_str.replace(',', '.').strip())
if hours > Config.MIN_HOURS_THRESHOLD:
work_time[api_formatted_date] = work_time.get(api_formatted_date, 0.0) + hours
else:
logging.debug(
f"Skipping row {i+1} with hours below threshold. Hours: {hours}"
)
except (ValueError, IndexError) as e:
logging.debug(f"Skipping row {i+1} due to parsing error: {e}")
logging.info(f"Extracted work time for {len(work_time)} unique dates.")
return work_time
def create_time_event(
user_id: int,
project_id: int,
activity: Dict,
date: str,
hours: float
) -> Dict:
"""
Create a time event dictionary for the Kleer API.
Args:
user_id: User ID
project_id: Project ID
activity: Activity dictionary
date: Date in YYYY-MM-DD format
hours: Number of hours
Returns:
Time event dictionary
"""
return {
"foreign-id": "",
"user": {"id": user_id},
"client-project": project_id,
"activity": activity,
"date": date,
"hours": hours,
"comment": "",
"internal-comment": ""
}
def get_user_by_name(users_data: Dict, username: str) -> int:
"""
Get user ID by username from users data.
Args:
users_data: User data from API
username: Username to search for
Returns:
User ID
Raises:
ValueError: If user not found
"""
if not users_data or "users" not in users_data:
raise ValueError("Invalid user data received from API")
users = {
user.get('name'): user.get('id')
for user in users_data.get("users", [])
if isinstance(user, dict)
}
user_id = users.get(username)
if not user_id:
raise ValueError(f"User '{username}' not found")
return user_id
+750
View File
@@ -0,0 +1,750 @@
:root {
--primary: #0f766e;
--primary-dark: #0b4f48;
--primary-light: #16a394;
--secondary: #f97316;
--success: #16a34a;
--danger: #ef4444;
--warning: #f59e0b;
--info: #2563eb;
--bg-primary: #0b1220;
--bg-secondary: #11192a;
--bg-tertiary: #0f172a;
--panel: rgba(255, 255, 255, 0.05);
--text-primary: #f8fafc;
--text-secondary: #cbd5e1;
--text-muted: #94a3b8;
--border: rgba(255, 255, 255, 0.08);
--border-hover: rgba(255, 255, 255, 0.15);
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.1);
--shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
--shadow-md: 0 20px 40px rgba(0, 0, 0, 0.35);
--shadow-lg: 0 25px 45px rgba(0, 0, 0, 0.45);
--shadow-xl: 0 40px 70px rgba(0, 0, 0, 0.55);
--radius-sm: 0.5rem;
--radius: 0.75rem;
--radius-md: 1rem;
--radius-lg: 1.25rem;
--transition: all 0.25s ease;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Space Grotesk', 'Inter', 'Segoe UI', system-ui, sans-serif;
background: radial-gradient(circle at 20% 20%, rgba(22, 163, 148, 0.15), transparent 30%),
radial-gradient(circle at 80% 0%, rgba(249, 115, 22, 0.12), transparent 30%),
linear-gradient(135deg, #0b1220 0%, #0b182b 40%, #0c1c32 100%);
min-height: 100vh;
color: var(--text-primary);
line-height: 1.6;
padding: 2.5rem 1.25rem 3rem;
position: relative;
overflow-x: hidden;
}
body::before {
content: '';
position: fixed;
inset: 0;
background: radial-gradient(1200px circle at 15% 15%, rgba(22, 163, 148, 0.08), transparent 35%),
radial-gradient(900px circle at 90% 10%, rgba(249, 115, 22, 0.06), transparent 30%);
pointer-events: none;
z-index: 0;
}
.container {
max-width: 1200px;
margin: 0 auto;
position: relative;
z-index: 1;
}
/* Header */
.header {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.03));
border-radius: var(--radius-lg);
padding: 1.5rem 2rem;
margin-bottom: 2rem;
box-shadow: var(--shadow);
display: flex;
justify-content: space-between;
align-items: center;
backdrop-filter: blur(12px);
border: 1px solid var(--border);
animation: slideDown 0.5s ease;
}
.logo {
display: flex;
align-items: center;
gap: 1rem;
}
.logo-icon {
width: 2.5rem;
height: 2.5rem;
color: var(--secondary);
stroke-width: 2;
}
.logo h1 {
font-size: 1.75rem;
font-weight: 700;
letter-spacing: -0.02em;
background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.status-badge {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: rgba(255, 255, 255, 0.06);
border-radius: var(--radius);
font-size: 0.875rem;
font-weight: 500;
border: 1px solid var(--border);
}
.status-dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background: var(--text-muted);
animation: pulse 2s ease-in-out infinite;
}
.status-badge.healthy .status-dot {
background: var(--success);
}
.status-badge.unhealthy .status-dot {
background: var(--danger);
}
/* Main Content */
.main-content {
display: grid;
gap: 2rem;
animation: fadeIn 0.6s ease 0.2s both;
}
/* Cards */
.card {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.02));
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
overflow: hidden;
transition: var(--transition);
border: 1px solid var(--border);
backdrop-filter: blur(10px);
}
.card:hover {
box-shadow: var(--shadow-md);
transform: translateY(-4px);
border-color: var(--border-hover);
}
.card-header {
padding: 1.5rem 2rem;
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
}
.card-header h2 {
font-size: 1.25rem;
font-weight: 600;
color: var(--text-primary);
letter-spacing: -0.01em;
}
.card-header p {
color: var(--text-secondary);
font-size: 0.875rem;
margin-top: 0.25rem;
}
.card-body {
padding: 2rem;
}
/* Upload Zone */
.upload-zone {
border: 1.5px dashed var(--border);
border-radius: var(--radius-lg);
padding: 3rem 2rem;
text-align: center;
cursor: pointer;
transition: var(--transition);
background: rgba(255, 255, 255, 0.04);
}
.upload-zone:hover {
border-color: var(--primary-light);
background: rgba(15, 118, 110, 0.08);
}
.upload-zone.drag-over {
border-color: var(--primary-light);
background: linear-gradient(135deg, rgba(15, 118, 110, 0.12) 0%, rgba(249, 115, 22, 0.12) 100%);
}
.upload-icon {
width: 3rem;
height: 3rem;
color: var(--primary-light);
margin-bottom: 1rem;
stroke-width: 2;
}
.upload-zone h3 {
font-size: 1.125rem;
font-weight: 600;
margin-bottom: 0.5rem;
}
.upload-zone p {
color: var(--text-secondary);
font-size: 0.875rem;
}
.browse-link {
color: var(--secondary);
font-weight: 500;
cursor: pointer;
}
.browse-link:hover {
text-decoration: underline;
}
.file-formats {
display: block;
margin-top: 0.75rem;
font-size: 0.75rem;
color: var(--text-muted);
}
/* File Preview */
.file-preview {
margin-top: 1.5rem;
padding: 1rem;
background: rgba(255, 255, 255, 0.04);
border-radius: var(--radius);
animation: slideIn 0.3s ease;
border: 1px solid var(--border);
}
.file-info {
display: flex;
align-items: center;
gap: 1rem;
}
.file-icon {
width: 2.5rem;
height: 2.5rem;
color: var(--primary-light);
stroke-width: 2;
flex-shrink: 0;
}
.file-details {
flex: 1;
display: flex;
flex-direction: column;
}
.file-name {
font-weight: 500;
color: var(--text-primary);
}
.file-size {
font-size: 0.875rem;
color: var(--text-secondary);
}
/* Form Elements */
.form-group {
margin-top: 1.5rem;
}
.form-group label {
display: block;
font-weight: 500;
margin-bottom: 0.5rem;
color: var(--text-primary);
}
.select-wrapper {
position: relative;
}
.select-wrapper::after {
content: '';
position: absolute;
right: 1rem;
top: 50%;
transform: translateY(-50%);
width: 0;
height: 0;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-top: 5px solid var(--text-secondary);
pointer-events: none;
}
select {
width: 100%;
padding: 0.75rem 1rem;
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: 1rem;
font-family: inherit;
background: rgba(255, 255, 255, 0.05);
color: var(--text-primary);
cursor: pointer;
transition: var(--transition);
appearance: none;
}
select:hover {
border-color: var(--border-hover);
}
select:focus {
outline: none;
border-color: var(--primary-light);
box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.18);
}
select:disabled {
background: rgba(255, 255, 255, 0.03);
cursor: not-allowed;
opacity: 0.6;
}
input[type="text"],
input[type="date"],
input[type="number"] {
width: 100%;
padding: 0.75rem 1rem;
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: 1rem;
font-family: inherit;
background: rgba(255, 255, 255, 0.05);
color: var(--text-primary);
transition: var(--transition);
}
input:focus {
outline: none;
border-color: var(--primary-light);
box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.18);
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
border: 1px solid transparent;
border-radius: var(--radius);
font-size: 1rem;
font-weight: 500;
font-family: inherit;
cursor: pointer;
transition: var(--transition);
text-decoration: none;
}
.btn-primary {
background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 120%);
color: white;
box-shadow: var(--shadow-md);
margin-top: 1.5rem;
width: 100%;
}
.btn-primary:hover:not(:disabled) {
box-shadow: var(--shadow-lg);
transform: translateY(-2px);
}
.btn-primary:active:not(:disabled) {
transform: translateY(0);
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.btn-secondary {
background: rgba(255, 255, 255, 0.05);
color: var(--text-primary);
border: 1px solid var(--border);
box-shadow: var(--shadow);
}
.btn-secondary:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.08);
}
.btn-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
border: none;
border-radius: var(--radius);
background: rgba(255, 255, 255, 0.05);
color: var(--text-secondary);
cursor: pointer;
transition: var(--transition);
}
.btn-icon:hover {
background: rgba(255, 255, 255, 0.08);
color: var(--primary-light);
}
.btn-icon svg {
width: 1.25rem;
height: 1.25rem;
stroke-width: 2;
}
.btn-icon-right {
width: 1.25rem;
height: 1.25rem;
stroke-width: 2;
}
/* Projects List */
.projects-list {
display: grid;
gap: 1rem;
}
.project-item {
padding: 1rem;
background: rgba(255, 255, 255, 0.04);
border-radius: var(--radius);
display: flex;
justify-content: space-between;
align-items: center;
transition: var(--transition);
border: 1px solid var(--border);
}
.project-item:hover {
background: rgba(255, 255, 255, 0.06);
border-color: var(--border-hover);
}
.project-name {
font-weight: 500;
color: var(--text-primary);
}
.project-meta {
display: flex;
gap: 1rem;
font-size: 0.875rem;
color: var(--text-secondary);
}
/* Approvals */
.approvals-body {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 1rem;
}
.approval-actions {
display: flex;
gap: 1rem;
align-items: flex-end;
flex-wrap: wrap;
}
.events-list {
grid-column: 1 / -1;
display: grid;
gap: 0.75rem;
margin-top: 0.5rem;
}
.event-item {
padding: 1rem;
background: rgba(255, 255, 255, 0.04);
border-radius: var(--radius);
display: flex;
justify-content: space-between;
align-items: center;
border: 1px solid var(--border);
}
.event-meta {
display: flex;
gap: 1rem;
color: var(--text-secondary);
font-size: 0.875rem;
}
.event-status {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.35rem 0.6rem;
border-radius: var(--radius);
font-size: 0.875rem;
background: var(--bg-tertiary);
color: var(--text-primary);
}
.event-status.approved {
background: rgba(16, 185, 129, 0.1);
color: var(--success);
}
.event-status.pending {
background: rgba(15, 118, 110, 0.1);
color: var(--primary-light);
}
/* Results */
.result-success {
padding: 1.5rem;
background: linear-gradient(135deg, rgba(16, 163, 148, 0.18) 0%, rgba(16, 163, 148, 0.1) 100%);
border: 1px solid rgba(16, 163, 148, 0.35);
border-radius: var(--radius);
animation: slideIn 0.3s ease;
}
.result-success h3 {
color: var(--success);
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.result-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-top: 1rem;
}
.stat-item {
padding: 1rem;
background: rgba(255, 255, 255, 0.04);
border-radius: var(--radius);
border: 1px solid var(--border);
}
.stat-value {
font-size: 1.75rem;
font-weight: 700;
color: var(--primary);
}
.stat-label {
font-size: 0.875rem;
color: var(--text-secondary);
margin-top: 0.25rem;
}
/* Toast Notifications */
.toast-container {
position: fixed;
top: 2rem;
right: 2rem;
z-index: 1000;
display: flex;
flex-direction: column;
gap: 1rem;
}
.toast {
padding: 1rem 1.5rem;
background: var(--bg-secondary);
border-radius: var(--radius);
box-shadow: var(--shadow-lg);
display: flex;
align-items: center;
gap: 1rem;
min-width: 300px;
animation: slideInRight 0.3s ease;
}
.toast.success {
border-left: 4px solid var(--success);
}
.toast.error {
border-left: 4px solid var(--danger);
}
.toast.warning {
border-left: 4px solid var(--warning);
}
.toast.info {
border-left: 4px solid var(--info);
}
/* Loading */
.loading {
text-align: center;
padding: 2rem;
color: var(--text-secondary);
}
.loading-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 999;
backdrop-filter: blur(4px);
}
.loading-overlay p {
color: white;
margin-top: 1rem;
font-weight: 500;
}
.spinner {
width: 3rem;
height: 3rem;
border: 4px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
/* Footer */
.footer {
text-align: center;
padding: 2rem 0;
color: rgba(255, 255, 255, 0.7);
font-size: 0.875rem;
margin-top: 2rem;
}
/* Animations */
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideInRight {
from {
opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
/* Responsive */
@media (max-width: 768px) {
body {
padding: 1rem;
}
.header {
flex-direction: column;
gap: 1rem;
text-align: center;
}
.card-body {
padding: 1.5rem;
}
.toast-container {
right: 1rem;
left: 1rem;
}
.toast {
min-width: auto;
}
}
+159
View File
@@ -0,0 +1,159 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ZynkTime - Time Reporting</title>
<link rel="stylesheet" href="/static/css/style.css">
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet">
</head>
<body>
<div class="container">
<!-- Header -->
<header class="header">
<div class="logo">
<svg class="logo-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<circle cx="12" cy="12" r="10"></circle>
<polyline points="12 6 12 12 16 14"></polyline>
</svg>
<h1>ZynkTime</h1>
</div>
<div class="status-badge" id="statusBadge">
<span class="status-dot"></span>
<span class="status-text">Checking...</span>
</div>
</header>
<!-- Main Content -->
<main class="main-content">
<!-- Upload Section -->
<section class="card upload-section">
<div class="card-header">
<h2>Report Your Time</h2>
<p>Upload your CSV file and select a project to report time</p>
</div>
<div class="card-body">
<!-- File Upload -->
<div class="upload-zone" id="uploadZone">
<input type="file" id="fileInput" accept=".csv,.xlsx" hidden>
<svg class="upload-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="17 8 12 3 7 8"></polyline>
<line x1="12" y1="3" x2="12" y2="15"></line>
</svg>
<h3>Drop your CSV or XLSX file here</h3>
<p>or <span class="browse-link" id="browseLink">browse files</span></p>
<span class="file-formats">Supports: CSV (comma or semicolon separated) and XLSX (AGRESSO sheet)</span>
</div>
<div class="file-preview" id="filePreview" style="display: none;">
<div class="file-info">
<svg class="file-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"></path>
<polyline points="13 2 13 9 20 9"></polyline>
</svg>
<div class="file-details">
<span class="file-name" id="fileName"></span>
<span class="file-size" id="fileSize"></span>
</div>
<button class="btn-icon" id="removeFile" title="Remove file">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
</div>
<!-- Project Selection -->
<div class="form-group">
<label for="projectSelect">Select Project</label>
<div class="select-wrapper">
<select id="projectSelect" disabled>
<option value="">Loading projects...</option>
</select>
</div>
</div>
<!-- Submit Button -->
<button class="btn btn-primary" id="submitBtn" disabled>
<span class="btn-text">Report Time</span>
<svg class="btn-icon-right" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<line x1="5" y1="12" x2="19" y2="12"></line>
<polyline points="12 5 19 12 12 19"></polyline>
</svg>
</button>
</div>
</section>
<!-- Results Section -->
<section class="card results-section" id="resultsSection" style="display: none;">
<div class="card-header">
<h2>Results</h2>
</div>
<div class="card-body" id="resultsContent"></div>
</section>
<!-- Approvals Section -->
<section class="card projects-section">
<div class="card-header">
<h2>Approve Events</h2>
</div>
<div class="card-body approvals-body">
<div class="form-group">
<label for="startDate">Start Date</label>
<input type="date" id="startDate">
</div>
<div class="form-group">
<label for="endDate">End Date</label>
<input type="date" id="endDate">
</div>
<div class="approval-actions">
<button class="btn btn-secondary" id="loadEvents">Load Events</button>
<button class="btn btn-primary" id="approveEvents">Approve Events</button>
</div>
<div class="events-list" id="eventsList">
<div class="loading">No events loaded yet</div>
</div>
</div>
</section>
<!-- Projects Info Section -->
<section class="card projects-section">
<div class="card-header">
<h2>Available Projects</h2>
<button class="btn-icon" id="refreshProjects" title="Refresh projects">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
<polyline points="23 4 23 10 17 10"></polyline>
<polyline points="1 20 1 14 7 14"></polyline>
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path>
</svg>
</button>
</div>
<div class="card-body">
<div class="projects-list" id="projectsList">
<div class="loading">Loading projects...</div>
</div>
</div>
</section>
</main>
<!-- Footer -->
<footer class="footer">
<p>ZynkTime v1.0 - Powered by Kleer API</p>
</footer>
</div>
<!-- Toast Notifications -->
<div class="toast-container" id="toastContainer"></div>
<!-- Loading Overlay -->
<div class="loading-overlay" id="loadingOverlay" style="display: none;">
<div class="spinner"></div>
<p>Processing your request...</p>
</div>
<script src="/static/js/app.js"></script>
</body>
</html>
+443
View File
@@ -0,0 +1,443 @@
// State
let selectedFile = null;
let projects = {};
let lastEvents = [];
// DOM Elements
const uploadZone = document.getElementById('uploadZone');
const fileInput = document.getElementById('fileInput');
const browseLink = document.getElementById('browseLink');
const filePreview = document.getElementById('filePreview');
const fileName = document.getElementById('fileName');
const fileSize = document.getElementById('fileSize');
const removeFileBtn = document.getElementById('removeFile');
const projectSelect = document.getElementById('projectSelect');
const submitBtn = document.getElementById('submitBtn');
const statusBadge = document.getElementById('statusBadge');
const resultsSection = document.getElementById('resultsSection');
const resultsContent = document.getElementById('resultsContent');
const projectsList = document.getElementById('projectsList');
const refreshProjectsBtn = document.getElementById('refreshProjects');
const loadingOverlay = document.getElementById('loadingOverlay');
const toastContainer = document.getElementById('toastContainer');
const startDateInput = document.getElementById('startDate');
const endDateInput = document.getElementById('endDate');
const loadEventsBtn = document.getElementById('loadEvents');
const approveEventsBtn = document.getElementById('approveEvents');
const eventsList = document.getElementById('eventsList');
// Initialize
document.addEventListener('DOMContentLoaded', () => {
checkHealth();
loadProjects();
setDefaultDateRange();
setupEventListeners();
});
// Event Listeners
function setupEventListeners() {
// File upload
uploadZone.addEventListener('click', () => fileInput.click());
browseLink.addEventListener('click', (e) => {
e.stopPropagation();
fileInput.click();
});
fileInput.addEventListener('change', handleFileSelect);
removeFileBtn.addEventListener('click', (e) => {
e.stopPropagation();
removeFile();
});
// Drag and drop
uploadZone.addEventListener('dragover', handleDragOver);
uploadZone.addEventListener('dragleave', handleDragLeave);
uploadZone.addEventListener('drop', handleDrop);
// Submit
submitBtn.addEventListener('click', handleSubmit);
// Refresh projects
refreshProjectsBtn.addEventListener('click', () => {
refreshProjectsBtn.classList.add('spin');
loadProjects().finally(() => {
setTimeout(() => refreshProjectsBtn.classList.remove('spin'), 500);
});
});
// Approvals
loadEventsBtn.addEventListener('click', fetchEvents);
approveEventsBtn.addEventListener('click', approveEvents);
}
// API Functions
async function checkHealth() {
try {
const response = await fetch('/health');
const data = await response.json();
const statusText = statusBadge.querySelector('.status-text');
if (data.status === 'healthy') {
statusBadge.classList.add('healthy');
statusText.textContent = 'Connected';
} else {
statusBadge.classList.add('unhealthy');
statusText.textContent = 'No API Key';
showToast('Please configure KLEER_API_KEY environment variable', 'warning');
}
} catch (error) {
statusBadge.classList.add('unhealthy');
statusBadge.querySelector('.status-text').textContent = 'Offline';
showToast('Cannot connect to server', 'error');
}
}
async function loadProjects() {
try {
const response = await fetch('/projects');
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
projects = await response.json();
populateProjectSelect();
displayProjects();
} catch (error) {
console.error('Error loading projects:', error);
projectSelect.innerHTML = '<option value="">Failed to load projects</option>';
projectsList.innerHTML = `<div class="loading" style="color: var(--danger);">Failed to load projects: ${error.message}</div>`;
showToast('Failed to load projects', 'error');
}
}
function populateProjectSelect() {
projectSelect.innerHTML = '<option value="">Select a project...</option>';
Object.keys(projects).forEach(projectName => {
const option = document.createElement('option');
option.value = projectName;
option.textContent = projectName;
projectSelect.appendChild(option);
});
projectSelect.disabled = false;
updateSubmitButton();
}
function displayProjects() {
const projectNames = Object.keys(projects);
if (projectNames.length === 0) {
projectsList.innerHTML = '<div class="loading">No projects found</div>';
return;
}
projectsList.innerHTML = '';
projectNames.forEach(projectName => {
const project = projects[projectName];
const projectItem = document.createElement('div');
projectItem.className = 'project-item';
projectItem.innerHTML = `
<div class="project-name">${projectName}</div>
<div class="project-meta">
<span>ID: ${project.id}</span>
<span>Activity: ${project.activity.id || 'N/A'}</span>
</div>
`;
projectsList.appendChild(projectItem);
});
}
// File Handling
function handleFileSelect(e) {
const file = e.target.files[0];
if (file) {
if (isSupportedFile(file.name)) {
setFile(file);
} else {
showToast('Please select a CSV or XLSX file', 'warning');
fileInput.value = '';
}
}
}
function handleDragOver(e) {
e.preventDefault();
uploadZone.classList.add('drag-over');
}
function handleDragLeave(e) {
e.preventDefault();
uploadZone.classList.remove('drag-over');
}
function handleDrop(e) {
e.preventDefault();
uploadZone.classList.remove('drag-over');
const file = e.dataTransfer.files[0];
if (file) {
if (isSupportedFile(file.name)) {
setFile(file);
} else {
showToast('Please drop a CSV or XLSX file', 'warning');
}
}
}
function isSupportedFile(fileName) {
const lower = fileName.toLowerCase();
return lower.endsWith('.csv') || lower.endsWith('.xlsx');
}
function setFile(file) {
selectedFile = file;
// Show file preview
uploadZone.style.display = 'none';
filePreview.style.display = 'block';
fileName.textContent = file.name;
fileSize.textContent = formatFileSize(file.size);
updateSubmitButton();
}
function removeFile() {
selectedFile = null;
fileInput.value = '';
uploadZone.style.display = 'block';
filePreview.style.display = 'none';
updateSubmitButton();
}
function updateSubmitButton() {
submitBtn.disabled = !(selectedFile && projectSelect.value);
}
function setDefaultDateRange() {
const today = new Date();
const start = new Date(today);
start.setDate(today.getDate() - 7);
startDateInput.value = start.toISOString().slice(0, 10);
endDateInput.value = today.toISOString().slice(0, 10);
}
// Submit Handler
async function handleSubmit() {
if (!selectedFile || !projectSelect.value) {
showToast('Please select a file and project', 'warning');
return;
}
const formData = new FormData();
formData.append('file', selectedFile);
const projectName = projectSelect.value;
showLoading(true);
try {
const response = await fetch(`/report-time?project_name=${encodeURIComponent(projectName)}`, {
method: 'POST',
body: formData
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.detail || `HTTP ${response.status}`);
}
// Show success
displayResults(data);
showToast('Time reported successfully!', 'success');
// Reset form
removeFile();
projectSelect.value = '';
updateSubmitButton();
} catch (error) {
console.error('Error submitting:', error);
showToast(`Error: ${error.message}`, 'error');
} finally {
showLoading(false);
}
}
// Results Display
function displayResults(data) {
resultsContent.innerHTML = `
<div class="result-success">
<h3>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path>
<polyline points="22 4 12 14.01 9 11.01"></polyline>
</svg>
${data.message}
</h3>
<div class="result-stats">
<div class="stat-item">
<div class="stat-value">${data.reported_days}</div>
<div class="stat-label">Days Reported</div>
</div>
<div class="stat-item">
<div class="stat-value">${data.total_hours.toFixed(2)}</div>
<div class="stat-label">Total Hours</div>
</div>
<div class="stat-item">
<div class="stat-value">${data.project_name}</div>
<div class="stat-label">Project</div>
</div>
<div class="stat-item">
<div class="stat-value">${data.user_name}</div>
<div class="stat-label">User</div>
</div>
</div>
</div>
`;
resultsSection.style.display = 'block';
resultsSection.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
// Utility Functions
function formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
}
function showLoading(show) {
loadingOverlay.style.display = show ? 'flex' : 'none';
}
function showToast(message, type = 'info') {
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.innerHTML = `
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
${getToastIcon(type)}
</svg>
<span>${message}</span>
`;
toastContainer.appendChild(toast);
setTimeout(() => {
toast.style.animation = 'slideInRight 0.3s ease reverse';
setTimeout(() => toast.remove(), 300);
}, 3000);
}
function getToastIcon(type) {
const icons = {
success: '<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path><polyline points="22 4 12 14.01 9 11.01"></polyline>',
error: '<circle cx="12" cy="12" r="10"></circle><line x1="15" y1="9" x2="9" y2="15"></line><line x1="9" y1="9" x2="15" y2="15"></line>',
warning: '<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path><line x1="12" y1="9" x2="12" y2="13"></line><line x1="12" y1="17" x2="12.01" y2="17"></line>',
info: '<circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line>'
};
return icons[type] || icons.info;
}
// Project select change handler
projectSelect.addEventListener('change', updateSubmitButton);
// Approvals helpers
function renderEvents(events) {
if (!events || events.length === 0) {
eventsList.innerHTML = '<div class="loading">No events found for range</div>';
return;
}
eventsList.innerHTML = '';
events.forEach(event => {
const approved = event.approved;
const item = document.createElement('div');
item.className = 'event-item';
item.innerHTML = `
<div>
<div class="project-name">${event.date || 'Unknown date'}</div>
<div class="event-meta">
<span>Hours: ${event.hours ?? '-'}</span>
<span>Activity: ${event.activity?.id ?? '-'}</span>
<span>Project: ${event["client-project"]?.id ?? '-'}</span>
</div>
</div>
<div class="event-status ${approved ? 'approved' : 'pending'}">
${approved ? 'Approved' : 'Pending'}
</div>
`;
eventsList.appendChild(item);
});
}
function getDateRange() {
const startDate = startDateInput.value;
const endDate = endDateInput.value;
if (!startDate || !endDate) {
showToast('Please select start and end dates', 'warning');
return null;
}
if (new Date(startDate) > new Date(endDate)) {
showToast('Start date cannot be after end date', 'warning');
return null;
}
return { startDate, endDate };
}
async function fetchEvents() {
const range = getDateRange();
if (!range) return;
showLoading(true);
try {
const response = await fetch(`/events?start_date=${range.startDate}&end_date=${range.endDate}`);
const data = await response.json();
if (!response.ok) {
throw new Error(data.detail || `HTTP ${response.status}`);
}
lastEvents = data.events || [];
renderEvents(lastEvents);
showToast('Events loaded', 'success');
} catch (error) {
console.error('Error fetching events:', error);
showToast(`Failed to load events: ${error.message}`, 'error');
} finally {
showLoading(false);
}
}
async function approveEvents() {
const range = getDateRange();
if (!range) return;
showLoading(true);
try {
const response = await fetch(
`/approve-events?start_date=${range.startDate}&end_date=${range.endDate}`,
{ method: 'POST' }
);
const data = await response.json();
if (!response.ok) {
throw new Error(data.detail || `HTTP ${response.status}`);
}
showToast('Events approved', 'success');
await fetchEvents();
} catch (error) {
console.error('Error approving events:', error);
showToast(`Failed to approve events: ${error.message}`, 'error');
} finally {
showLoading(false);
}
}
+263
View File
@@ -0,0 +1,263 @@
"""
Tests for ZynkTime application.
"""
import io
import os
from datetime import datetime
from unittest.mock import Mock, patch
import pytest
from fastapi.testclient import TestClient
from openpyxl import Workbook
from config import Config
from main import app
from services import (
create_time_event,
extract_work_time,
get_user_by_name,
parse_csv,
parse_xlsx,
validate_date,
)
# Test client for FastAPI
client = TestClient(app)
class TestServices:
"""Tests for service functions."""
def test_validate_date_valid(self):
"""Test date validation with valid date."""
assert validate_date("2024-01-15") is True
def test_validate_date_invalid(self):
"""Test date validation with invalid date."""
assert validate_date("invalid-date") is False
assert validate_date("2024-13-01") is False
def test_parse_csv_semicolon(self):
"""Test CSV parsing with semicolon delimiter."""
csv_content = "col1;col2;col3\nval1;val2;val3"
result = parse_csv(csv_content)
assert len(result) == 2
assert result[0] == ["col1", "col2", "col3"]
assert result[1] == ["val1", "val2", "val3"]
def test_parse_csv_comma(self):
"""Test CSV parsing with comma delimiter."""
csv_content = "col1,col2,col3\nval1,val2,val3"
result = parse_csv(csv_content)
assert len(result) == 2
assert result[0] == ["col1", "col2", "col3"]
assert result[1] == ["val1", "val2", "val3"]
def test_extract_work_time(self):
"""Test work time extraction from CSV rows."""
rows = [
["h1", "h2", "h3", "h4", "h5", "01/15/2024", "h7", "h8", "h9", "h10", "8.5"],
["v1", "v2", "v3", "v4", "v5", "01/16/2024", "v7", "v8", "v9", "v10", "7,5"],
]
result = extract_work_time(rows)
assert "2024-01-15" in result
assert "2024-01-16" in result
assert result["2024-01-15"] == 8.5
assert result["2024-01-16"] == 7.5
def test_extract_work_time_with_header(self):
"""Test work time extraction with header row."""
rows = [
["reg_period", "xreg_period", "T", "Time code", "Time code (T)", "Vouch.date",
"Project Activity", "Project Activity (T)", "Work package", "Work package (T)",
"Hours", "Workflow status", "Text"],
["202544", "", "A", "0", "Standard Time", "10/27/2025", "", "", "", "", "2.00 ", "N", ""],
["202544", "", "A", "0", "Standard Time", "10/27/2025", "", "", "", "", "4.00 ", "N", ""],
]
result = extract_work_time(rows)
assert "2025-10-27" in result
assert result["2025-10-27"] == 6.0
def test_extract_work_time_below_threshold(self):
"""Test that hours below threshold are filtered out."""
rows = [
["h1", "h2", "h3", "h4", "h5", "01/15/2024", "h7", "h8", "h9", "h10", "0.1"],
]
result = extract_work_time(rows)
assert len(result) == 0
def test_parse_xlsx_agresso_sheet(self):
"""Test XLSX parsing using the AGRESSO worksheet."""
workbook = Workbook()
workbook.active.title = "Other"
sheet = workbook.create_sheet("AGRESSO")
sheet.append(["Date", "Hours"])
sheet.append(["01/15/2024", "8.5"])
buffer = io.BytesIO()
workbook.save(buffer)
rows = parse_xlsx(buffer.getvalue())
assert rows[0] == ["Date", "Hours"]
assert rows[1][0] == "01/15/2024"
assert rows[1][1] == "8.5"
def test_parse_xlsx_missing_agresso(self):
"""Test XLSX parsing failure when AGRESSO worksheet is missing."""
workbook = Workbook()
workbook.active.title = "Other"
buffer = io.BytesIO()
workbook.save(buffer)
with pytest.raises(ValueError, match="Worksheet 'AGRESSO' not found"):
parse_xlsx(buffer.getvalue())
def test_create_time_event(self):
"""Test time event creation."""
event = create_time_event(
user_id=123,
project_id=456,
activity={"id": 789},
date="2024-01-15",
hours=8.5
)
assert event["user"]["id"] == 123
assert event["client-project"] == 456
assert event["activity"]["id"] == 789
assert event["date"] == "2024-01-15"
assert event["hours"] == 8.5
def test_get_user_by_name_success(self):
"""Test getting user by name successfully."""
users_data = {
"users": [
{"id": 1, "name": "Alice"},
{"id": 2, "name": "Bob"}
]
}
user_id = get_user_by_name(users_data, "Bob")
assert user_id == 2
def test_get_user_by_name_not_found(self):
"""Test getting user by name when user not found."""
users_data = {
"users": [
{"id": 1, "name": "Alice"}
]
}
with pytest.raises(ValueError, match="User 'Bob' not found"):
get_user_by_name(users_data, "Bob")
def test_get_user_by_name_invalid_data(self):
"""Test getting user by name with invalid data."""
with pytest.raises(ValueError, match="Invalid user data"):
get_user_by_name(None, "Bob")
class TestAPI:
"""Tests for FastAPI endpoints."""
def test_root_endpoint(self):
"""Test root endpoint."""
response = client.get("/")
assert response.status_code == 200
assert "name" in response.json()
assert response.json()["name"] == "ZynkTime API"
def test_health_check_without_api_key(self):
"""Test health check without API key configured."""
with patch.dict(os.environ, {}, clear=True):
response = client.get("/health")
assert response.status_code == 200
data = response.json()
assert data["status"] == "unhealthy"
assert data["api_key_configured"] is False
def test_health_check_with_api_key(self):
"""Test health check with API key configured."""
with patch.dict(os.environ, {"KLEER_API_KEY": "test-key"}):
response = client.get("/health")
assert response.status_code == 200
data = response.json()
assert data["status"] == "healthy"
assert data["api_key_configured"] is True
@patch('main.KleerAPI')
def test_list_projects_success(self, mock_kleer_api):
"""Test listing projects successfully."""
# Set up mock
mock_instance = Mock()
mock_instance.get_user_info.return_value = {
"users": [{"id": 1, "name": "Test User"}]
}
mock_instance.get_projects.return_value = {
"Project A": {"id": 1, "activity": {"id": 10}}
}
mock_kleer_api.return_value = mock_instance
with patch.dict(os.environ, {"KLEER_API_KEY": "test-key", "KLEER_USERNAME": "Test User"}):
response = client.get("/projects")
assert response.status_code == 200
data = response.json()
assert "Project A" in data
@patch('main.KleerAPI')
def test_list_events_success(self, mock_kleer_api):
"""Test listing events within date range."""
mock_instance = Mock()
mock_instance.get_user_info.return_value = {"users": [{"id": 1, "name": "Test User"}]}
mock_instance.get_events.return_value = {"event-readables": [{"id": {"id": 1}, "date": "2020-07-20"}]}
mock_kleer_api.return_value = mock_instance
with patch.dict(os.environ, {"KLEER_API_KEY": "test-key", "KLEER_USERNAME": "Test User"}):
response = client.get("/events", params={"start_date": "2020-07-20", "end_date": "2020-07-21"})
assert response.status_code == 200
data = response.json()
assert data["user_id"] == 1
assert len(data["events"]) == 1
@patch('main.KleerAPI')
def test_approve_events_success(self, mock_kleer_api):
"""Test approving events."""
mock_instance = Mock()
mock_instance.get_user_info.return_value = {"users": [{"id": 1, "name": "Test User"}]}
mock_instance.approve_events.return_value = {"id": 3118}
mock_kleer_api.return_value = mock_instance
with patch.dict(os.environ, {"KLEER_API_KEY": "test-key", "KLEER_USERNAME": "Test User"}):
response = client.post("/approve-events", params={"start_date": "2020-07-20", "end_date": "2020-07-21"})
assert response.status_code == 200
data = response.json()
assert data["approval"] == {"id": 3118}
assert data["user_id"] == 1
class TestConfig:
"""Tests for configuration."""
def test_get_username_default(self):
"""Test getting default username."""
with patch.dict(os.environ, {}, clear=True):
username = Config.get_username()
assert username == "Christopher Juhlin"
def test_get_username_from_env(self):
"""Test getting username from environment."""
with patch.dict(os.environ, {"KLEER_USERNAME": "Custom User"}):
username = Config.get_username()
assert username == "Custom User"
def test_validate_config_no_api_key(self):
"""Test config validation without API key."""
with patch.dict(os.environ, {}, clear=True):
with pytest.raises(ValueError, match="KLEER_API_KEY"):
Config.validate_config()
def test_validate_config_with_api_key(self):
"""Test config validation with API key."""
with patch.dict(os.environ, {"KLEER_API_KEY": "test-key"}):
Config.validate_config() # Should not raise
if __name__ == "__main__":
pytest.main([__file__, "-v"])