11 Commits

Author SHA1 Message Date
William Valentin b7a22524d7 Feat: add export functionality with GUI for data and graphs
Build and Push Docker Image / build-and-push (push) Has been cancelled
- Implemented ExportWindow class for exporting data and graphs in various formats (JSON, XML, PDF).
- Integrated ExportManager to handle export logic.
- Added export option in the main application menu.
- Enhanced user interface with data summary and export options.
- Included error handling and success messages for export operations.
- Updated dependencies in the lock file to include reportlab and lxml for PDF generation.
2025-08-02 10:00:24 -07:00
William Valentin 156dcd1651 feat: Import LOG_CLEAR constant for logging clarity 2025-08-01 15:15:04 -07:00
William Valentin 1d310dd081 feat: Update version to 1.7.5 in Makefile, docker-build.sh, and pyproject.toml
Build and Push Docker Image / build-and-push (push) Has been cancelled
2025-08-01 14:45:58 -07:00
William Valentin abd1fa33cf refactor: Simplify UI creation methods by removing dynamic variants and consolidating functionality 2025-08-01 14:41:58 -07:00
William Valentin 03ef9e761a feat: Update version to 1.7.4 in Makefile, docker-build.sh, and pyproject.toml
Build and Push Docker Image / build-and-push (push) Has been cancelled
2025-08-01 14:12:06 -07:00
William Valentin ca1f8c976d fix: notes are saved again
feat: Add test scripts for note saving and updating functionality
2025-08-01 14:09:29 -07:00
William Valentin 7392709a27 feat: Uncomment .vscode directory in .gitignore to include IDE settings 2025-08-01 13:25:47 -07:00
William Valentin 623050478a feat: Update version to 1.7.3 in Makefile, docker-build.sh, and pyproject.toml 2025-08-01 13:21:48 -07:00
William Valentin 41d91d9c30 feat: Center main window on screen during initialization
Build and Push Docker Image / build-and-push (push) Has been cancelled
2025-08-01 13:05:24 -07:00
William Valentin 14d9943665 feat: Update medicine toggles to be unchecked by default for improved user experience
Build and Push Docker Image / build-and-push (push) Has been cancelled
2025-08-01 12:53:19 -07:00
William Valentin 13a4826415 feat: Enhance DataManager and GraphManager with performance optimizations and caching 2025-08-01 12:46:51 -07:00
17 changed files with 1769 additions and 597 deletions
+2 -1
View File
@@ -47,7 +47,7 @@ htmlcov/
.pylint.d/ .pylint.d/
# IDEs and editors # IDEs and editors
#.vscode/ .vscode/
!.vscode/tasks.json !.vscode/tasks.json
!.vscode/launch.json !.vscode/launch.json
.idea/ .idea/
@@ -81,3 +81,4 @@ Thumbs.db
.Trashes .Trashes
ehthumbs.db ehthumbs.db
Thumbs.db Thumbs.db
integration_test_exports/
+3 -18
View File
@@ -1,5 +1,5 @@
TARGET=thechart TARGET=thechart
VERSION=1.6.1 VERSION=1.8.5
ROOT=/home/will ROOT=/home/will
ICON=chart-671.png ICON=chart-671.png
SHELL=fish SHELL=fish
@@ -85,7 +85,7 @@ install: ## Set up the development environment
@echo "To run tests: make test" @echo "To run tests: make test"
build: ## Build the Docker image build: ## Build the Docker image
@echo "Building the Docker image..." @echo "Building the Docker image..."
docker buildx build --platform linux/amd64,linux/arm64 -t ${IMAGE} --push . docker buildx build --platform linux/amd64 -t ${IMAGE} --push .
deploy: ## Deploy the application as a standalone executable deploy: ## Deploy the application as a standalone executable
@echo "Deploying the application..." @echo "Deploying the application..."
pyinstaller --name ${TARGET} --optimize 2 --onefile --windowed --hidden-import='PIL._tkinter_finder' --icon='${ICON}' --add-data="./.env:." --add-data='./chart-671.png:.' --add-data='./thechart_data.csv:.' --log-level=DEBUG src/main.py pyinstaller --name ${TARGET} --optimize 2 --onefile --windowed --hidden-import='PIL._tkinter_finder' --icon='${ICON}' --add-data="./.env:." --add-data='./chart-671.png:.' --add-data='./thechart_data.csv:.' --log-level=DEBUG src/main.py
@@ -121,21 +121,6 @@ test-watch: ## Run tests in watch mode
test-debug: ## Run tests with debug output test-debug: ## Run tests with debug output
@echo "Running tests with debug output..." @echo "Running tests with debug output..."
.venv/bin/python -m pytest tests/ -v -s --tb=long --cov=src .venv/bin/python -m pytest tests/ -v -s --tb=long --cov=src
test-dose-tracking: ## Test the dose tracking functionality
@echo "Testing dose tracking functionality..."
.venv/bin/python scripts/test_dose_tracking.py
test-scrollable-input: ## Test the scrollable input frame UI
@echo "Testing scrollable input frame..."
.venv/bin/python scripts/test_scrollable_input.py
test-edit-functionality: ## Test the enhanced edit functionality
@echo "Testing edit functionality..."
.venv/bin/python scripts/test_edit_functionality.py
test-edit-window: $(VENV_ACTIVATE) ## Test edit window functionality (save and delete)
@echo "Running edit window functionality test..."
$(PYTHON) scripts/test_edit_window_functionality.py
test-dose-editing: $(VENV_ACTIVATE) ## Test dose editing functionality in edit window
@echo "Running dose editing functionality test..."
$(PYTHON) scripts/test_dose_editing_functionality.py
lint: ## Run the linter lint: ## Run the linter
@echo "Running the linter..." @echo "Running the linter..."
docker-compose exec ${TARGET} pipenv run pre-commit run --all-files docker-compose exec ${TARGET} pipenv run pre-commit run --all-files
@@ -157,4 +142,4 @@ commit-emergency: ## Emergency commit (bypasses pre-commit hooks) - USE SPARINGL
@read -p "Enter commit message: " msg; \ @read -p "Enter commit message: " msg; \
git add . && git commit --no-verify -m "$$msg" git add . && git commit --no-verify -m "$$msg"
@echo "✅ Emergency commit completed. Please run tests manually when possible." @echo "✅ Emergency commit completed. Please run tests manually when possible."
.PHONY: install clean reinstall check-env build attach deploy run start stop test lint format shell requirements commit-emergency test-dose-tracking test-scrollable-input test-edit-functionality test-edit-window test-dose-editing migrate-csv help .PHONY: install clean reinstall check-env build attach deploy run start stop test lint format shell requirements commit-emergency help
+8
View File
@@ -15,6 +15,7 @@ make test
## 📚 Documentation ## 📚 Documentation
- **[Features Guide](docs/FEATURES.md)** - Complete feature documentation - **[Features Guide](docs/FEATURES.md)** - Complete feature documentation
- **[Export System](docs/EXPORT_SYSTEM.md)** - Data export functionality and formats
- **[Development Guide](docs/DEVELOPMENT.md)** - Testing, development, and architecture - **[Development Guide](docs/DEVELOPMENT.md)** - Testing, development, and architecture
- **[Changelog](docs/CHANGELOG.md)** - Version history and feature evolution - **[Changelog](docs/CHANGELOG.md)** - Version history and feature evolution
- **[Quick Reference](#quick-reference)** - Common commands and shortcuts - **[Quick Reference](#quick-reference)** - Common commands and shortcuts
@@ -226,6 +227,13 @@ On first run, the application will:
- **Backward Compatibility**: Seamless upgrades without data loss - **Backward Compatibility**: Seamless upgrades without data loss
- **Dynamic Columns**: Adapts to new medicines and pathologies - **Dynamic Columns**: Adapts to new medicines and pathologies
### 📋 Data Export System
- **Multiple Formats**: Export to JSON, XML, and PDF formats
- **Comprehensive Reports**: PDF exports with optional graph visualization
- **Metadata Inclusion**: Export includes date ranges, pathologies, and medicines
- **User-Friendly Interface**: Easy access through File menu with format selection
- **Data Portability**: Structured exports for analysis or backup purposes
For complete feature documentation, see **[docs/FEATURES.md](docs/FEATURES.md)**. For complete feature documentation, see **[docs/FEATURES.md](docs/FEATURES.md)**.
## Development ## Development
+3 -3
View File
@@ -1,19 +1,19 @@
#!/usr/bin/bash #!/usr/bin/bash
CONTAINER_ENGINE="docker" # podman | docker CONTAINER_ENGINE="docker" # podman | docker
VERSION="v1.0.0" VERSION="v1.7.5"
REGISTRY="gitea-http.taildb3494.ts.net/will/thechart" REGISTRY="gitea-http.taildb3494.ts.net/will/thechart"
if [ "$CONTAINER_ENGINE" == "podman" ]; if [ "$CONTAINER_ENGINE" == "podman" ];
then then
buildah build \ buildah build \
-t $REGISTRY:$VERSION \ -t $REGISTRY:$VERSION \
--platform linux/amd64,linux/arm64/v8 \ --platform linux/amd64 \
--no-cache . --no-cache .
else else
DOCKER_BUILDKIT=1 \ DOCKER_BUILDKIT=1 \
docker buildx build \ docker buildx build \
--platform linux/amd64,linux/arm64/v8 \ --platform linux/amd64 \
-t $REGISTRY:$VERSION \ -t $REGISTRY:$VERSION \
--no-cache \ --no-cache \
--push . --push .
+215
View File
@@ -0,0 +1,215 @@
# TheChart Export System Documentation
## Overview
The TheChart application now includes a comprehensive data export system that allows users to export their medication tracking data and visualizations to multiple formats:
- **JSON** - Structured data format with metadata
- **XML** - Hierarchical data format
- **PDF** - Formatted report with optional graph visualization
## Features
### Export Formats
#### JSON Export
- Exports all CSV data to structured JSON format
- Includes metadata about the export (date, total entries, date range)
- Lists all pathologies and medicines being tracked
- Data is exported as an array of entry objects
#### XML Export
- Exports data to hierarchical XML format
- Includes comprehensive metadata section
- All entries are properly structured with XML tags
- Column names are sanitized for valid XML element names
#### PDF Export
- Creates a formatted report document
- Includes export metadata and summary information
- Optional graph visualization inclusion
- Data table with all entries
- Proper pagination and styling
- Notes are truncated for better table formatting
### User Interface
The export functionality is accessible through:
1. **File Menu** - "Export Data..." option in the main menu bar
2. **Export Window** - Modal dialog with export options
3. **Format Selection** - Radio buttons for JSON, XML, or PDF
4. **Graph Option** - Checkbox to include graph in PDF exports
5. **File Dialog** - Standard save dialog for choosing export location
### Export Manager Architecture
The export system consists of three main components:
#### ExportManager Class (`src/export_manager.py`)
- Core export functionality
- Handles data transformation and file generation
- Integrates with existing data and graph managers
- Supports all three export formats
#### ExportWindow Class (`src/export_window.py`)
- GUI interface for export operations
- Modal dialog with export options
- File save dialog integration
- Progress feedback and error handling
#### Integration in MedTrackerApp (`src/main.py`)
- Export manager initialization
- Menu integration
- Seamless integration with existing managers
## Technical Implementation
### Dependencies Added
- `reportlab` - PDF generation library
- `lxml` - XML processing (added for future enhancements)
- `charset-normalizer` - Character encoding support
### Data Flow
1. User selects export format and options
2. ExportManager loads data from DataManager
3. Data is transformed according to selected format
4. Graph image is optionally generated for PDF
5. Output file is created and saved
6. User receives success/failure feedback
### Error Handling
- Graceful handling of missing data
- File system error management
- User-friendly error messages
- Logging of export operations
## Usage Examples
### Basic Export Process
1. Open TheChart application
2. Go to File → Export Data...
3. Select desired format (JSON/XML/PDF)
4. For PDF: choose whether to include graph
5. Click "Export..." button
6. Choose save location and filename
7. Confirm successful export
### Export File Examples
#### JSON Structure
```json
{
"metadata": {
"export_date": "2025-08-02T09:03:22.580489",
"total_entries": 32,
"date_range": {
"start": "07/02/2025",
"end": "08/02/2025"
},
"pathologies": ["depression", "anxiety", "sleep", "appetite"],
"medicines": ["bupropion", "hydroxyzine", "gabapentin", "propranolol", "quetiapine"]
},
"entries": [
{
"date": "07/02/2025",
"depression": 8,
"anxiety": 5,
"sleep": 3,
"appetite": 1,
"bupropion": 0,
"bupropion_doses": "",
"note": "Starting medication tracking"
}
]
}
```
#### XML Structure
```xml
<?xml version="1.0" encoding="UTF-8"?>
<thechart_data>
<metadata>
<export_date>2025-08-02T09:03:22.613013</export_date>
<total_entries>32</total_entries>
<date_range>
<start>07/02/2025</start>
<end>08/02/2025</end>
</date_range>
</metadata>
<entries>
<entry>
<date>07/02/2025</date>
<depression>8</depression>
<anxiety>5</anxiety>
<note>Starting medication tracking</note>
</entry>
</entries>
</thechart_data>
```
## Testing
### Automated Tests
- Export functionality is tested through `simple_export_test.py`
- Creates sample exports in all three formats
- Validates file creation and basic content structure
### Manual Testing
- GUI testing available through `test_export_gui.py`
- Opens export window for interactive testing
- Allows testing of all user interface components
### Test Files Location
Exported test files are created in the `test_exports/` directory:
- `export.json` - JSON format export
- `export.xml` - XML format export
- `export.csv` - CSV format copy
- `test_export.pdf` - PDF format with graph
## File Locations
### Source Files
- `src/export_manager.py` - Core export functionality
- `src/export_window.py` - GUI export interface
### Test Files
- `simple_export_test.py` - Basic export functionality test
- `test_export_gui.py` - GUI testing interface
- `scripts/test_export_functionality.py` - Comprehensive export tests
### Dependencies
- Added to `requirements.txt` and managed by `uv`
- PDF generation requires `reportlab`
- XML processing enhanced with `lxml`
## Future Enhancements
Potential improvements for the export system:
1. **Additional Formats** - Excel, CSV with formatting
2. **Export Filtering** - Date range selection, specific pathologies/medicines
3. **Batch Exports** - Multiple formats at once
4. **Email Integration** - Direct email export
5. **Cloud Storage** - Export to cloud services
6. **Export Scheduling** - Automated periodic exports
7. **Advanced PDF Styling** - Charts, graphs, custom layouts
## Troubleshooting
### Common Issues
1. **No Data to Export** - Ensure CSV file has entries before exporting
2. **PDF Generation Fails** - Check ReportLab installation and permissions
3. **File Save Errors** - Verify write permissions to selected directory
4. **Large File Exports** - PDF exports may take longer for large datasets
### Debugging
- Check application logs for detailed error messages
- Export operations are logged with DEBUG level information
- File system errors are captured and reported to user
## Integration Notes
The export system integrates seamlessly with existing TheChart functionality:
- Uses same data validation and loading mechanisms
- Respects existing pathology and medicine configurations
- Maintains data integrity and formatting consistency
- Follows existing logging and error handling patterns
+3 -1
View File
@@ -1,14 +1,16 @@
[project] [project]
name = "thechart" name = "thechart"
version = "1.6.1" version = "1.8.5"
description = "Chart to monitor your medication intake over time." description = "Chart to monitor your medication intake over time."
readme = "README.md" readme = "README.md"
requires-python = ">=3.13" requires-python = ">=3.13"
dependencies = [ dependencies = [
"colorlog>=6.9.0", "colorlog>=6.9.0",
"dotenv>=0.9.9", "dotenv>=0.9.9",
"lxml>=6.0.0",
"matplotlib>=3.10.3", "matplotlib>=3.10.3",
"pandas>=2.3.1", "pandas>=2.3.1",
"reportlab>=4.4.3",
"tk>=0.1.0", "tk>=0.1.0",
] ]
+61
View File
@@ -0,0 +1,61 @@
# TheChart Scripts Directory
This directory contains testing and utility scripts for TheChart application.
## Scripts Overview
### Testing Scripts
#### `run_tests.py`
Main test runner for the application.
```bash
cd /home/will/Code/thechart
.venv/bin/python scripts/run_tests.py
```
#### `integration_test.py`
Comprehensive integration test for the export system.
- Tests all export formats (JSON, XML, PDF)
- Validates data integrity and file creation
- No GUI dependencies - safe for automated testing
```bash
cd /home/will/Code/thechart
.venv/bin/python scripts/integration_test.py
```
### Feature Testing Scripts
#### `test_note_saving.py`
Tests note saving and retrieval functionality.
- Validates note persistence in CSV files
- Tests special characters and formatting
#### `test_update_entry.py`
Tests entry update functionality.
- Validates data modification operations
- Tests date validation and duplicate handling
## Usage
All scripts should be run from the project root directory:
```bash
cd /home/will/Code/thechart
.venv/bin/python scripts/<script_name>.py
```
## Test Data
- Integration tests create temporary export files in `integration_test_exports/` (auto-cleaned)
- Test scripts use the main `thechart_data.csv` file unless specified otherwise
- No test data is committed to the repository
## Development
When adding new scripts:
1. Place them in this directory
2. Use the standard shebang: `#!/usr/bin/env python3`
3. Add proper docstrings and error handling
4. Update this README with script documentation
5. Follow the project's linting and formatting standards
+128
View File
@@ -0,0 +1,128 @@
#!/usr/bin/env python3
"""
Integration test for TheChart export system
Tests the complete export workflow without GUI dependencies
"""
import sys
from pathlib import Path
# Add src to path
sys.path.insert(0, "src")
from data_manager import DataManager
from export_manager import ExportManager
from init import logger
from medicine_manager import MedicineManager
from pathology_manager import PathologyManager
class MockGraphManager:
"""Mock graph manager for testing."""
def __init__(self):
self.fig = None
def test_integration():
"""Test complete export system integration."""
print("TheChart Export System Integration Test")
print("=" * 45)
# 1. Initialize all managers
print("\n1. Initializing managers...")
try:
medicine_manager = MedicineManager(logger=logger)
pathology_manager = PathologyManager(logger=logger)
data_manager = DataManager(
"thechart_data.csv", logger, medicine_manager, pathology_manager
)
# Mock graph manager (no GUI dependencies)
graph_manager = MockGraphManager()
export_manager = ExportManager(
data_manager, graph_manager, medicine_manager, pathology_manager, logger
)
print(" ✓ All managers initialized successfully")
except Exception as e:
print(f" ✗ Manager initialization failed: {e}")
return False
# 2. Check data availability
print("\n2. Checking data availability...")
try:
export_info = export_manager.get_export_info()
print(f" Total entries: {export_info['total_entries']}")
print(f" Has data: {export_info['has_data']}")
if not export_info["has_data"]:
print(" ✗ No data available for export")
return False
print(
f" Date range: {export_info['date_range']['start']} "
f"to {export_info['date_range']['end']}"
)
print(f" Pathologies: {len(export_info['pathologies'])}")
print(f" Medicines: {len(export_info['medicines'])}")
print(" ✓ Data is available for export")
except Exception as e:
print(f" ✗ Data check failed: {e}")
return False
# 3. Test all export formats
export_dir = Path("integration_test_exports")
export_dir.mkdir(exist_ok=True)
formats_to_test = [
("JSON", "integration_test.json", export_manager.export_data_to_json),
("XML", "integration_test.xml", export_manager.export_data_to_xml),
(
"PDF",
"integration_test.pdf",
lambda path: export_manager.export_to_pdf(path, include_graph=False),
),
]
results = []
for format_name, filename, export_func in formats_to_test:
print(f"\n3.{len(results) + 1}. Testing {format_name} export...")
try:
file_path = export_dir / filename
success = export_func(str(file_path))
if success and file_path.exists():
file_size = file_path.stat().st_size
print(
f"{format_name} export successful: {filename} "
f"({file_size} bytes)"
)
results.append(True)
else:
print(f"{format_name} export failed")
results.append(False)
except Exception as e:
print(f"{format_name} export error: {e}")
results.append(False)
# 4. Summary
print("\n4. Test Summary")
print(f" Total tests: {len(results)}")
print(f" Passed: {sum(results)}")
print(f" Failed: {len(results) - sum(results)}")
if all(results):
print(" ✓ All export formats working correctly!")
print(f" Check '{export_dir}' directory for exported files.")
return True
else:
print(" ✗ Some export formats failed")
return False
if __name__ == "__main__":
success = test_integration()
sys.exit(0 if success else 1)
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env python3
"""
Test script to verify note field saving functionality
"""
import logging
import os
import sys
import pandas as pd
# Add src directory to path to import modules
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
from data_manager import DataManager
from medicine_manager import MedicineManager
from pathology_manager import PathologyManager
def test_note_saving():
"""Test note saving functionality by checking current data"""
print("Testing note saving functionality...")
# Initialize logger
logger = logging.getLogger("test")
logger.setLevel(logging.INFO)
# Initialize managers
medicine_manager = MedicineManager("medicines.json")
pathology_manager = PathologyManager("pathologies.json")
data_manager = DataManager(
"thechart_data.csv", logger, medicine_manager, pathology_manager
)
# Load current data
df = data_manager.load_data()
if df.empty:
print("No data found in CSV file")
return
print(f"Found {len(df)} entries in the data file")
# Check if we have any entries with notes
entries_with_notes = df[df["note"].notna() & (df["note"] != "")].copy()
print(f"Entries with notes: {len(entries_with_notes)}")
if len(entries_with_notes) > 0:
print("\nEntries with notes:")
for _, row in entries_with_notes.iterrows():
note_preview = (
row["note"][:50] + "..." if len(str(row["note"])) > 50 else row["note"]
)
print(f" Date: {row['date']}, Note: {note_preview}")
# Show the most recent entry
if len(df) > 0:
latest_entry = df.iloc[-1]
print("\nMost recent entry:")
print(f" Date: {latest_entry['date']}")
print(f" Note: '{latest_entry['note']}'")
print(f" Note length: {len(str(latest_entry['note']))}")
is_empty = pd.isna(latest_entry["note"]) or latest_entry["note"] == ""
print(f" Note is empty/null: {is_empty}")
if __name__ == "__main__":
test_note_saving()
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env python3
"""
Test the update_entry functionality with notes
"""
import logging
import os
import sys
# Add src directory to path to import modules
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
from data_manager import DataManager
from medicine_manager import MedicineManager
from pathology_manager import PathologyManager
def test_update_entry_with_note():
"""Test updating an entry with a note"""
print("Testing update_entry functionality with notes...")
# Initialize logger
logger = logging.getLogger("test")
logger.setLevel(logging.DEBUG)
# Add console handler to see debug output
handler = logging.StreamHandler()
handler.setLevel(logging.DEBUG)
formatter = logging.Formatter("%(levelname)s - %(message)s")
handler.setFormatter(formatter)
logger.addHandler(handler)
# Initialize managers
medicine_manager = MedicineManager("medicines.json")
pathology_manager = PathologyManager("pathologies.json")
data_manager = DataManager(
"thechart_data.csv", logger, medicine_manager, pathology_manager
)
# Load current data
df = data_manager.load_data()
if df.empty:
print("No data found in CSV file")
return
print(f"Found {len(df)} entries in the data file")
# Find the most recent entry to test with
latest_entry = df.iloc[-1].copy()
original_date = latest_entry["date"]
print(f"Testing with entry: {original_date}")
print(f"Current note: '{latest_entry['note']}'")
# Create test values - keep everything the same but change the note
test_note = "This is a test note to verify saving functionality!"
# Build values list (same format as the UI would send)
values = [original_date] # date
# Add pathology values
pathology_keys = pathology_manager.get_pathology_keys()
for key in pathology_keys:
values.append(latest_entry.get(key, 0))
# Add medicine values and doses
medicine_keys = medicine_manager.get_medicine_keys()
for key in medicine_keys:
values.append(latest_entry.get(key, 0)) # medicine checkbox
values.append(latest_entry.get(f"{key}_doses", "")) # medicine doses
# Add the test note
values.append(test_note)
print(f"Values to save: {values}")
print(f"Note in values: '{values[-1]}'")
# Test the update
success = data_manager.update_entry(original_date, values)
if success:
print("Update successful!")
# Reload and verify
df_after = data_manager.load_data()
updated_entry = df_after[df_after["date"] == original_date].iloc[0]
print(f"Note after update: '{updated_entry['note']}'")
print(f"Note correctly saved: {updated_entry['note'] == test_note}")
# Reset the note back to original
values[-1] = latest_entry["note"]
data_manager.update_entry(original_date, values)
print("Reverted note back to original")
else:
print("Update failed!")
if __name__ == "__main__":
test_update_entry_with_note()
+161 -55
View File
@@ -9,7 +9,7 @@ from pathology_manager import PathologyManager
class DataManager: class DataManager:
"""Handle all data operations for the application.""" """Handle all data operations for the application with performance optimizations."""
def __init__( def __init__(
self, self,
@@ -22,10 +22,21 @@ class DataManager:
self.logger: logging.Logger = logger self.logger: logging.Logger = logger
self.medicine_manager = medicine_manager self.medicine_manager = medicine_manager
self.pathology_manager = pathology_manager self.pathology_manager = pathology_manager
# Cache for loaded data to avoid repeated file I/O
self._data_cache: pd.DataFrame | None = None
self._cache_timestamp: float = 0
self._headers_cache: tuple[str, ...] | None = None
self._dtype_cache: dict[str, type] | None = None
self._initialize_csv_file() self._initialize_csv_file()
def _get_csv_headers(self) -> list[str]: def _get_csv_headers(self) -> tuple[str, ...]:
"""Get CSV headers based on current pathology and medicine configuration.""" """Get CSV headers based on current pathology and medicine configuration.
Cached to avoid repeated computation."""
if self._headers_cache is not None:
return self._headers_cache
# Start with date # Start with date
headers = ["date"] headers = ["date"]
@@ -37,7 +48,9 @@ class DataManager:
for medicine_key in self.medicine_manager.get_medicine_keys(): for medicine_key in self.medicine_manager.get_medicine_keys():
headers.extend([medicine_key, f"{medicine_key}_doses"]) headers.extend([medicine_key, f"{medicine_key}_doses"])
return headers + ["note"] result = tuple(headers + ["note"])
self._headers_cache = result
return result
def _initialize_csv_file(self) -> None: def _initialize_csv_file(self) -> None:
"""Create CSV file with headers if it doesn't exist or is empty.""" """Create CSV file with headers if it doesn't exist or is empty."""
@@ -46,27 +59,74 @@ class DataManager:
writer = csv.writer(file) writer = csv.writer(file)
writer.writerow(self._get_csv_headers()) writer.writerow(self._get_csv_headers())
def _invalidate_cache(self) -> None:
"""Invalidate the data cache when data changes."""
self._data_cache = None
self._cache_timestamp = 0
def _should_reload_data(self) -> bool:
"""Check if data should be reloaded based on file modification time."""
if self._data_cache is None:
return True
try:
file_mtime = os.path.getmtime(self.filename)
return file_mtime > self._cache_timestamp
except OSError:
return True
def _get_dtype_dict(self) -> dict[str, type]:
"""Get pandas dtype dictionary for efficient reading.
Cached to avoid recreation."""
if self._dtype_cache is not None:
return self._dtype_cache
dtype_dict = {"date": str, "note": str}
# Add pathology types
for pathology_key in self.pathology_manager.get_pathology_keys():
dtype_dict[pathology_key] = int
# Add medicine types
for medicine_key in self.medicine_manager.get_medicine_keys():
dtype_dict[medicine_key] = int
dtype_dict[f"{medicine_key}_doses"] = str
self._dtype_cache = dtype_dict
return dtype_dict
def load_data(self) -> pd.DataFrame: def load_data(self) -> pd.DataFrame:
"""Load data from CSV file.""" """Load data from CSV file with caching for better performance."""
if not os.path.exists(self.filename) or os.path.getsize(self.filename) == 0: if not os.path.exists(self.filename) or os.path.getsize(self.filename) == 0:
self.logger.warning("CSV file is empty or doesn't exist. No data to load.") self.logger.warning("CSV file is empty or doesn't exist. No data to load.")
return pd.DataFrame() return pd.DataFrame()
# Use cached data if available and file hasn't changed
if not self._should_reload_data():
return self._data_cache.copy()
try: try:
# Build dtype dictionary dynamically # Use pre-built dtype dictionary for faster parsing
dtype_dict = {"date": str, "note": str} dtype_dict = self._get_dtype_dict()
# Add pathology types # Read with optimized settings
for pathology_key in self.pathology_manager.get_pathology_keys(): df: pd.DataFrame = pd.read_csv(
dtype_dict[pathology_key] = int self.filename,
dtype=dtype_dict,
na_filter=False, # Don't convert to NaN, keep as empty strings
engine="c", # Use faster C engine
)
# Add medicine types # Sort only if needed (check if already sorted)
for medicine_key in self.medicine_manager.get_medicine_keys(): if len(df) > 1 and not df["date"].is_monotonic_increasing:
dtype_dict[medicine_key] = int df = df.sort_values(by="date").reset_index(drop=True)
dtype_dict[f"{medicine_key}_doses"] = str
# Cache the data and timestamp
self._data_cache = df.copy()
self._cache_timestamp = os.path.getmtime(self.filename)
return df.copy()
df: pd.DataFrame = pd.read_csv(self.filename, dtype=dtype_dict).fillna("")
return df.sort_values(by="date").reset_index(drop=True)
except pd.errors.EmptyDataError: except pd.errors.EmptyDataError:
self.logger.warning("CSV file is empty. No data to load.") self.logger.warning("CSV file is empty. No data to load.")
return pd.DataFrame() return pd.DataFrame()
@@ -75,69 +135,104 @@ class DataManager:
return pd.DataFrame() return pd.DataFrame()
def add_entry(self, entry_data: list[str | int]) -> bool: def add_entry(self, entry_data: list[str | int]) -> bool:
"""Add a new entry to the CSV file.""" """Add a new entry to the CSV file with optimized duplicate checking."""
try: try:
# Check if date already exists # Quick duplicate check using cached data if available
df: pd.DataFrame = self.load_data()
date_to_add: str = str(entry_data[0]) date_to_add: str = str(entry_data[0])
if not df.empty and date_to_add in df["date"].values: if self._data_cache is not None:
self.logger.warning(f"Entry with date {date_to_add} already exists.") # Use cached data for duplicate check
return False if date_to_add in self._data_cache["date"].values:
self.logger.warning(
f"Entry with date {date_to_add} already exists."
)
return False
else:
# Fallback to loading data if no cache
df: pd.DataFrame = self.load_data()
if not df.empty and date_to_add in df["date"].values:
self.logger.warning(
f"Entry with date {date_to_add} already exists."
)
return False
# Write to file
with open(self.filename, mode="a", newline="") as file: with open(self.filename, mode="a", newline="") as file:
writer = csv.writer(file) writer = csv.writer(file)
writer.writerow(entry_data) writer.writerow(entry_data)
# Invalidate cache since data changed
self._invalidate_cache()
return True return True
except Exception as e: except Exception as e:
self.logger.error(f"Error adding entry: {str(e)}") self.logger.error(f"Error adding entry: {str(e)}")
return False return False
def update_entry(self, original_date: str, values: list[str | int]) -> bool: def update_entry(self, original_date: str, values: list[str | int]) -> bool:
"""Update an existing entry identified by original_date.""" """Update an existing entry identified by original_date
with optimized processing."""
try: try:
df: pd.DataFrame = self.load_data() df: pd.DataFrame = self.load_data()
new_date: str = str(values[0]) new_date: str = str(values[0])
# If the date is being changed, check if the new date already exists # Optimized duplicate check
if original_date != new_date and new_date in df["date"].values: if original_date != new_date:
date_exists = (df["date"] == new_date).any()
if date_exists:
self.logger.warning(
f"Cannot update: entry with date {new_date} already exists."
)
return False
# Get current CSV headers to match with values
headers = list(self._get_csv_headers())
# Ensure we have the right number of values with optimized padding
if len(values) < len(headers):
# Pad with defaults efficiently
padding_needed = len(headers) - len(values)
for i in range(padding_needed):
header_idx = len(values) + i
if header_idx < len(headers):
header = headers[header_idx]
if header == "note" or header.endswith("_doses"):
values.append("")
else:
values.append(0)
# Use vectorized update for better performance
mask = df["date"] == original_date
if mask.any():
df.loc[mask, headers] = values
# Write back to CSV with optimized method
df.to_csv(self.filename, index=False, mode="w")
self._invalidate_cache()
return True
else:
self.logger.warning( self.logger.warning(
f"Cannot update: entry with date {new_date} already exists." f"Entry with date {original_date} not found for update."
) )
return False return False
# Get current CSV headers to match with values
headers = self._get_csv_headers()
# Ensure we have the right number of values
if len(values) != len(headers):
self.logger.warning(
f"Value count mismatch: expected {len(headers)}, got {len(values)}"
)
# Pad with defaults if too few values
while len(values) < len(headers):
header = headers[len(values)]
if header == "note" or header.endswith("_doses"):
values.append("")
else:
values.append(0)
# Update the row using column names
df.loc[df["date"] == original_date, headers] = values
df.to_csv(self.filename, index=False)
return True
except Exception as e: except Exception as e:
self.logger.error(f"Error updating entry: {str(e)}") self.logger.error(f"Error updating entry: {str(e)}")
return False return False
def delete_entry(self, date: str) -> bool: def delete_entry(self, date: str) -> bool:
"""Delete an entry identified by date.""" """Delete an entry identified by date with optimized processing."""
try: try:
df: pd.DataFrame = self.load_data() df: pd.DataFrame = self.load_data()
# Remove the row with the matching date original_len = len(df)
# Use vectorized filtering for better performance
df = df[df["date"] != date] df = df[df["date"] != date]
# Write the updated dataframe back to the CSV
df.to_csv(self.filename, index=False) # Only write if something was actually deleted
if len(df) < original_len:
df.to_csv(self.filename, index=False, mode="w")
self._invalidate_cache()
return True return True
except Exception as e: except Exception as e:
self.logger.error(f"Error deleting entry: {str(e)}") self.logger.error(f"Error deleting entry: {str(e)}")
@@ -146,23 +241,34 @@ class DataManager:
def get_today_medicine_doses( def get_today_medicine_doses(
self, date: str, medicine_name: str self, date: str, medicine_name: str
) -> list[tuple[str, str]]: ) -> list[tuple[str, str]]:
"""Get list of (timestamp, dose) tuples for a medicine on a given date.""" """Get list of (timestamp, dose) tuples for a medicine on a given date
with caching."""
try: try:
df: pd.DataFrame = self.load_data() df: pd.DataFrame = self.load_data()
if df.empty or date not in df["date"].values: if df.empty:
return []
# Use vectorized filtering for better performance
date_mask = df["date"] == date
if not date_mask.any():
return [] return []
dose_column = f"{medicine_name}_doses" dose_column = f"{medicine_name}_doses"
doses_str = df.loc[df["date"] == date, dose_column].iloc[0] if dose_column not in df.columns:
return []
doses_str = df.loc[date_mask, dose_column].iloc[0]
if not doses_str: if not doses_str:
return [] return []
# Optimized dose parsing
doses = [] doses = []
for dose_entry in doses_str.split("|"): for dose_entry in doses_str.split("|"):
if ":" in dose_entry: if ":" in dose_entry:
timestamp, dose = dose_entry.split(":", 1) parts = dose_entry.split(":", 1)
doses.append((timestamp, dose)) if len(parts) == 2:
doses.append((parts[0], parts[1]))
return doses return doses
except Exception as e: except Exception as e:
+385
View File
@@ -0,0 +1,385 @@
"""
Export Manager for TheChart Application
Handles exporting data and graphs to various formats:
- CSV data to JSON, XML
- Graphs to PDF (with data tables)
"""
import contextlib
import json
import logging
import os
from datetime import datetime
from pathlib import Path
from typing import Any
from xml.dom import minidom
from xml.etree.ElementTree import Element, SubElement, tostring
import pandas as pd
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
from reportlab.lib.units import inch
from reportlab.platypus import (
Image,
Paragraph,
SimpleDocTemplate,
Spacer,
Table,
TableStyle,
)
from data_manager import DataManager
from graph_manager import GraphManager
from medicine_manager import MedicineManager
from pathology_manager import PathologyManager
class ExportManager:
"""Handle data and graph export operations."""
def __init__(
self,
data_manager: DataManager,
graph_manager: GraphManager,
medicine_manager: MedicineManager,
pathology_manager: PathologyManager,
logger: logging.Logger,
) -> None:
self.data_manager = data_manager
self.graph_manager = graph_manager
self.medicine_manager = medicine_manager
self.pathology_manager = pathology_manager
self.logger = logger
def export_data_to_json(self, export_path: str) -> bool:
"""Export CSV data to JSON format."""
try:
df = self.data_manager.load_data()
if df.empty:
self.logger.warning("No data to export")
return False
# Convert DataFrame to dictionary with better structure
export_data = {
"metadata": {
"export_date": datetime.now().isoformat(),
"total_entries": len(df),
"date_range": {
"start": df["date"].min() if not df.empty else None,
"end": df["date"].max() if not df.empty else None,
},
"pathologies": list(self.pathology_manager.get_pathology_keys()),
"medicines": list(self.medicine_manager.get_medicine_keys()),
},
"entries": df.to_dict(orient="records"),
}
with open(export_path, "w", encoding="utf-8") as f:
json.dump(export_data, f, indent=2, ensure_ascii=False)
self.logger.info(f"Data exported to JSON: {export_path}")
return True
except Exception as e:
self.logger.error(f"Error exporting to JSON: {str(e)}")
return False
def export_data_to_xml(self, export_path: str) -> bool:
"""Export CSV data to XML format."""
try:
df = self.data_manager.load_data()
if df.empty:
self.logger.warning("No data to export")
return False
# Create root element
root = Element("thechart_data")
# Add metadata
metadata = SubElement(root, "metadata")
SubElement(metadata, "export_date").text = datetime.now().isoformat()
SubElement(metadata, "total_entries").text = str(len(df))
# Date range
date_range = SubElement(metadata, "date_range")
SubElement(date_range, "start").text = (
df["date"].min() if not df.empty else ""
)
SubElement(date_range, "end").text = (
df["date"].max() if not df.empty else ""
)
# Pathologies
pathologies = SubElement(metadata, "pathologies")
for pathology in self.pathology_manager.get_pathology_keys():
SubElement(pathologies, "pathology").text = pathology
# Medicines
medicines = SubElement(metadata, "medicines")
for medicine in self.medicine_manager.get_medicine_keys():
SubElement(medicines, "medicine").text = medicine
# Add entries
entries = SubElement(root, "entries")
for _, row in df.iterrows():
entry = SubElement(entries, "entry")
for column, value in row.items():
elem = SubElement(entry, column.replace(" ", "_"))
elem.text = str(value) if pd.notna(value) else ""
# Pretty print XML
rough_string = tostring(root, "utf-8")
reparsed = minidom.parseString(rough_string)
pretty_xml = reparsed.toprettyxml(indent=" ")
with open(export_path, "w", encoding="utf-8") as f:
f.write(pretty_xml)
self.logger.info(f"Data exported to XML: {export_path}")
return True
except Exception as e:
self.logger.error(f"Error exporting to XML: {str(e)}")
return False
def _save_graph_as_image(self, temp_dir: Path) -> str | None:
"""Save current graph as temporary image for PDF inclusion."""
try:
# Check if graph manager exists
if self.graph_manager is None:
self.logger.warning("No graph manager available for export")
return None
# Check if graph manager and figure exist
if not hasattr(self.graph_manager, "fig") or self.graph_manager.fig is None:
self.logger.warning("No graph figure available for export")
return None
# Ensure graph is up to date with current data
df = self.data_manager.load_data()
if not df.empty:
self.graph_manager.update_graph(df)
else:
self.logger.warning("No data available to update graph for export")
return None
# Ensure temp directory exists
temp_dir.mkdir(parents=True, exist_ok=True)
temp_image_path = temp_dir / "graph.png"
# Save the current figure
self.graph_manager.fig.savefig(
str(temp_image_path),
dpi=150,
bbox_inches="tight",
facecolor="white",
edgecolor="none",
)
# Verify the file was actually created
if not temp_image_path.exists():
self.logger.error(
f"Graph image file was not created: {temp_image_path}"
)
return None
self.logger.info(f"Graph image saved successfully: {temp_image_path}")
return str(temp_image_path)
except Exception as e:
self.logger.error(f"Error saving graph image: {str(e)}")
return None
def export_to_pdf(self, export_path: str, include_graph: bool = True) -> bool:
"""Export data and optionally graph to PDF format."""
try:
df = self.data_manager.load_data()
# Create PDF document
doc = SimpleDocTemplate(
export_path,
pagesize=A4,
rightMargin=72,
leftMargin=72,
topMargin=72,
bottomMargin=18,
)
# Get styles
styles = getSampleStyleSheet()
title_style = ParagraphStyle(
"CustomTitle",
parent=styles["Heading1"],
fontSize=18,
spaceAfter=30,
textColor=colors.darkblue,
)
story = []
# Title
story.append(Paragraph("TheChart - Medication Tracker Export", title_style))
story.append(Spacer(1, 20))
# Export metadata
export_info = [
f"Export Date: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
f"Total Entries: {len(df) if not df.empty else 0}",
]
if not df.empty:
export_info.extend(
[
f"Date Range: {df['date'].min()} to {df['date'].max()}",
(
"Pathologies: "
+ ", ".join(self.pathology_manager.get_pathology_keys())
),
(
"Medicines: "
+ ", ".join(self.medicine_manager.get_medicine_keys())
),
]
)
for info in export_info:
story.append(Paragraph(info, styles["Normal"]))
story.append(Spacer(1, 20))
# Include graph if requested and available
if include_graph:
temp_dir = Path(export_path).parent / "temp_export"
try:
graph_path = self._save_graph_as_image(temp_dir)
if graph_path and os.path.exists(graph_path):
story.append(
Paragraph("Data Visualization", styles["Heading2"])
)
story.append(Spacer(1, 10))
# Add graph image
img = Image(graph_path, width=6 * inch, height=3.6 * inch)
story.append(img)
story.append(Spacer(1, 20))
# Clean up temp image
os.remove(graph_path)
else:
# Graph not available, add a note instead
story.append(
Paragraph("Data Visualization", styles["Heading2"])
)
story.append(Spacer(1, 10))
story.append(
Paragraph(
"Graph not available - no data to visualize or graph "
"not generated yet.",
styles["Normal"],
)
)
story.append(Spacer(1, 20))
except Exception as e:
self.logger.error(f"Error including graph in PDF: {str(e)}")
# Add error note instead of failing completely
story.append(Paragraph("Data Visualization", styles["Heading2"]))
story.append(Spacer(1, 10))
story.append(
Paragraph(
f"Graph could not be included: {str(e)}", styles["Normal"]
)
)
story.append(Spacer(1, 20))
finally:
# Clean up temp directory
if temp_dir.exists():
with contextlib.suppress(OSError):
temp_dir.rmdir()
# Add data table if we have data
if not df.empty:
story.append(Paragraph("Data Table", styles["Heading2"]))
story.append(Spacer(1, 10))
# Prepare table data - limit columns for better PDF formatting
display_columns = ["date"]
for pathology_key in self.pathology_manager.get_pathology_keys():
display_columns.append(pathology_key)
for medicine_key in self.medicine_manager.get_medicine_keys():
display_columns.append(medicine_key)
display_columns.append("note")
# Filter dataframe to display columns that exist
available_columns = [
col for col in display_columns if col in df.columns
]
display_df = df[available_columns].copy()
# Truncate long notes for better table formatting
if "note" in display_df.columns:
display_df["note"] = display_df["note"].apply(
lambda x: (str(x)[:50] + "...") if len(str(x)) > 50 else str(x)
)
# Convert to table data
table_data = [available_columns] # Headers
for _, row in display_df.iterrows():
table_data.append(
[str(val) if pd.notna(val) else "" for val in row]
)
# Create table with styling
table = Table(table_data, repeatRows=1)
table.setStyle(
TableStyle(
[
("BACKGROUND", (0, 0), (-1, 0), colors.grey),
("TEXTCOLOR", (0, 0), (-1, 0), colors.whitesmoke),
("ALIGN", (0, 0), (-1, -1), "CENTER"),
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
("FONTSIZE", (0, 0), (-1, 0), 10),
("BOTTOMPADDING", (0, 0), (-1, 0), 12),
("BACKGROUND", (0, 1), (-1, -1), colors.beige),
("FONTNAME", (0, 1), (-1, -1), "Helvetica"),
("FONTSIZE", (0, 1), (-1, -1), 8),
("GRID", (0, 0), (-1, -1), 1, colors.black),
("VALIGN", (0, 0), (-1, -1), "TOP"),
]
)
)
story.append(table)
else:
story.append(
Paragraph("No data available to export.", styles["Normal"])
)
# Build PDF
doc.build(story)
self.logger.info(f"Data exported to PDF: {export_path}")
return True
except Exception as e:
self.logger.error(f"Error exporting to PDF: {str(e)}")
return False
def get_export_info(self) -> dict[str, Any]:
"""Get information about available data for export."""
df = self.data_manager.load_data()
return {
"total_entries": len(df) if not df.empty else 0,
"date_range": {
"start": df["date"].min() if not df.empty else None,
"end": df["date"].max() if not df.empty else None,
},
"pathologies": list(self.pathology_manager.get_pathology_keys()),
"medicines": list(self.medicine_manager.get_medicine_keys()),
"has_data": not df.empty,
}
+247
View File
@@ -0,0 +1,247 @@
"""
Export Window for TheChart Application
Provides a GUI interface for exporting data and graphs to various formats.
"""
import tkinter as tk
from pathlib import Path
from tkinter import filedialog, messagebox, ttk
from export_manager import ExportManager
class ExportWindow:
"""Export window for data and graph export functionality."""
def __init__(self, parent: tk.Tk, export_manager: ExportManager) -> None:
self.parent = parent
self.export_manager = export_manager
# Create the export window
self.window = tk.Toplevel(parent)
self.window.title("Export Data")
self.window.geometry("500x450") # Made taller to ensure buttons are visible
self.window.resizable(False, False)
# Center the window
self._center_window()
# Make window modal
self.window.transient(parent)
self.window.grab_set()
# Setup the UI
self._setup_ui()
def _center_window(self) -> None:
"""Center the export window on the parent window."""
self.window.update_idletasks()
# Get window dimensions
width = self.window.winfo_width()
height = self.window.winfo_height()
# Get parent window position and size
parent_x = self.parent.winfo_rootx()
parent_y = self.parent.winfo_rooty()
parent_width = self.parent.winfo_width()
parent_height = self.parent.winfo_height()
# Calculate position to center on parent
x = parent_x + (parent_width // 2) - (width // 2)
y = parent_y + (parent_height // 2) - (height // 2)
self.window.geometry(f"{width}x{height}+{x}+{y}")
def _setup_ui(self) -> None:
"""Setup the export window UI."""
# Main frame
main_frame = ttk.Frame(self.window, padding="15")
main_frame.pack(fill=tk.BOTH, expand=True)
# Title
title_label = ttk.Label(
main_frame, text="Export Data & Graphs", font=("Arial", 14, "bold")
)
title_label.pack(pady=(0, 15))
# Create scrollable content area for the main content
content_frame = ttk.Frame(main_frame)
content_frame.pack(fill=tk.BOTH, expand=True)
# Export info section
self._create_info_section(content_frame)
# Export options section
self._create_options_section(content_frame)
# Buttons section - always at the bottom
self._create_buttons_section(main_frame)
def _create_info_section(self, parent: ttk.Frame) -> None:
"""Create the data information section."""
info_frame = ttk.LabelFrame(parent, text="Data Summary", padding="10")
info_frame.pack(fill=tk.X, pady=(0, 20))
# Get export info
export_info = self.export_manager.get_export_info()
# Display information
if export_info["has_data"]:
info_text = f"""Total Entries: {export_info["total_entries"]}
Date Range: {export_info["date_range"]["start"]} to {export_info["date_range"]["end"]}
Pathologies: {", ".join(export_info["pathologies"])}
Medicines: {", ".join(export_info["medicines"])}"""
else:
info_text = "No data available for export."
info_label = ttk.Label(info_frame, text=info_text, justify=tk.LEFT)
info_label.pack(anchor=tk.W)
def _create_options_section(self, parent: ttk.Frame) -> None:
"""Create the export options section."""
options_frame = ttk.LabelFrame(parent, text="Export Options", padding="10")
options_frame.pack(fill=tk.X, pady=(0, 20))
# Include graph option (for PDF export)
self.include_graph_var = tk.BooleanVar(value=True)
graph_check = ttk.Checkbutton(
options_frame,
text="Include graph in PDF export",
variable=self.include_graph_var,
)
graph_check.pack(anchor=tk.W, pady=(0, 10))
# Format selection
format_label = ttk.Label(options_frame, text="Export Format:")
format_label.pack(anchor=tk.W)
self.format_var = tk.StringVar(value="JSON")
formats = ["JSON", "XML", "PDF"]
for fmt in formats:
radio = ttk.Radiobutton(
options_frame, text=fmt, variable=self.format_var, value=fmt
)
radio.pack(anchor=tk.W, padx=(20, 0))
def _create_buttons_section(self, parent: ttk.Frame) -> None:
"""Create the buttons section."""
# Add a separator for visual clarity
separator = ttk.Separator(parent, orient="horizontal")
separator.pack(fill=tk.X, pady=(10, 10))
button_frame = ttk.Frame(parent)
button_frame.pack(fill=tk.X, pady=(0, 10))
# Export button with more prominent styling
export_btn = ttk.Button(
button_frame, text="Export...", command=self._handle_export
)
export_btn.pack(side=tk.LEFT, padx=(10, 10), pady=5)
# Cancel button
cancel_btn = ttk.Button(
button_frame, text="Cancel", command=self.window.destroy
)
cancel_btn.pack(side=tk.RIGHT, padx=(10, 10), pady=5)
def _handle_export(self) -> None:
"""Handle the export button click."""
# Check if we have data to export
export_info = self.export_manager.get_export_info()
if not export_info["has_data"]:
messagebox.showwarning(
"No Data", "There is no data available to export.", parent=self.window
)
return
# Get selected format
selected_format = self.format_var.get()
# Define file types for dialog
file_types = {
"JSON": [("JSON files", "*.json"), ("All files", "*.*")],
"XML": [("XML files", "*.xml"), ("All files", "*.*")],
"PDF": [("PDF files", "*.pdf"), ("All files", "*.*")],
}
# Default filename
default_name = f"thechart_export.{selected_format.lower()}"
# Show save dialog
filename = filedialog.asksaveasfilename(
parent=self.window,
title=f"Export as {selected_format}",
defaultextension=f".{selected_format.lower()}",
filetypes=file_types[selected_format],
initialfile=default_name,
)
if not filename:
return
# Perform export based on selected format
success = False
try:
if selected_format == "JSON":
success = self.export_manager.export_data_to_json(filename)
elif selected_format == "XML":
success = self.export_manager.export_data_to_xml(filename)
elif selected_format == "PDF":
include_graph = self.include_graph_var.get()
success = self.export_manager.export_to_pdf(
filename, include_graph=include_graph
)
if success:
messagebox.showinfo(
"Export Successful",
f"Data exported successfully to:\n{filename}",
parent=self.window,
)
# Ask if user wants to open the file location
if messagebox.askyesno(
"Open Location",
"Would you like to open the file location?",
parent=self.window,
):
self._open_file_location(filename)
self.window.destroy()
else:
messagebox.showerror(
"Export Failed",
f"Failed to export data as {selected_format}. "
"Please check the logs for more details.",
parent=self.window,
)
except Exception as e:
messagebox.showerror(
"Export Error",
f"An error occurred during export:\n{str(e)}",
parent=self.window,
)
def _open_file_location(self, filepath: str) -> None:
"""Open the file location in the system file manager."""
try:
file_path = Path(filepath)
directory = file_path.parent
# Use system-specific command to open file manager
import subprocess
import sys
if sys.platform == "win32":
subprocess.run(["explorer", str(directory)], check=False)
elif sys.platform == "darwin":
subprocess.run(["open", str(directory)], check=False)
else: # Linux and other Unix-like systems
subprocess.run(["xdg-open", str(directory)], check=False)
except Exception:
# If opening file location fails, just ignore silently
pass
+221 -169
View File
@@ -12,7 +12,8 @@ from pathology_manager import PathologyManager
class GraphManager: class GraphManager:
"""Handle all graph-related operations for the application.""" """Optimized version - Handle all graph-related operations for the
application with performance improvements."""
def __init__( def __init__(
self, self,
@@ -24,166 +25,206 @@ class GraphManager:
self.medicine_manager = medicine_manager self.medicine_manager = medicine_manager
self.pathology_manager = pathology_manager self.pathology_manager = pathology_manager
# Configure graph frame to expand # Initialize matplotlib with optimized settings
self.parent_frame.grid_rowconfigure(0, weight=1) self.fig: matplotlib.figure.Figure = plt.figure(figsize=(10, 6), dpi=80)
self.parent_frame.grid_columnconfigure(0, weight=1) self.ax: Axes = self.fig.add_subplot(111)
self._initialize_toggle_vars() # Cache for current data to avoid reprocessing
self.current_data: pd.DataFrame = pd.DataFrame()
self._last_plot_hash: str = ""
# Initialize UI components
self.toggle_vars: dict[str, tk.IntVar] = {}
self._setup_ui() self._setup_ui()
self._initialize_toggle_vars()
def _initialize_toggle_vars(self) -> None:
"""Initialize toggle variables for chart elements."""
self.toggle_vars: dict[str, tk.BooleanVar] = {}
# Initialize pathology toggles dynamically
for pathology_key in self.pathology_manager.get_pathology_keys():
pathology = self.pathology_manager.get_pathology(pathology_key)
default_value = pathology.default_enabled if pathology else True
self.toggle_vars[pathology_key] = tk.BooleanVar(value=default_value)
# Add medicine toggles dynamically
for medicine_key in self.medicine_manager.get_medicine_keys():
medicine = self.medicine_manager.get_medicine(medicine_key)
default_value = medicine.default_enabled if medicine else False
self.toggle_vars[medicine_key] = tk.BooleanVar(value=default_value)
def _setup_ui(self) -> None:
"""Set up the UI components."""
# Create control frame for toggles
self.control_frame: ttk.Frame = ttk.Frame(self.parent_frame)
self.control_frame.grid(row=0, column=0, sticky="ew", padx=5, pady=5)
# Create toggle checkboxes
self._create_chart_toggles() self._create_chart_toggles()
# Create graph frame def _initialize_toggle_vars(self) -> None:
self.graph_frame: ttk.Frame = ttk.Frame(self.parent_frame) """Initialize toggle variables for chart elements with optimization."""
self.graph_frame.grid(row=1, column=0, sticky="nsew", padx=5, pady=5) # Initialize pathology toggles
for pathology_key in self.pathology_manager.get_pathology_keys():
self.toggle_vars[pathology_key] = tk.IntVar(value=1)
# Reconfigure parent frame for new layout # Initialize medicine toggles (unchecked by default)
self.parent_frame.grid_rowconfigure(1, weight=1) for medicine_key in self.medicine_manager.get_medicine_keys():
self.parent_frame.grid_columnconfigure(0, weight=1) self.toggle_vars[medicine_key] = tk.IntVar(value=0)
# Initialize matplotlib figure and canvas def _setup_ui(self) -> None:
self.fig: matplotlib.figure.Figure """Set up the UI components with performance optimizations."""
self.ax: Axes # Create canvas with optimized settings
self.fig, self.ax = plt.subplots() self.canvas = FigureCanvasTkAgg(self.fig, master=self.parent_frame)
self.canvas: FigureCanvasTkAgg = FigureCanvasTkAgg( self.canvas.draw_idle() # Use draw_idle for better performance
figure=self.fig, master=self.graph_frame
)
self.canvas.get_tk_widget().pack(fill="both", expand=True)
# Store current data for replotting # Pack canvas
self.current_data: pd.DataFrame = pd.DataFrame() canvas_widget = self.canvas.get_tk_widget()
canvas_widget.pack(side=tk.TOP, fill=tk.BOTH, expand=True)
# Create control frame
self.control_frame = ttk.Frame(self.parent_frame)
self.control_frame.pack(side=tk.BOTTOM, fill=tk.X, padx=5, pady=2)
def _create_chart_toggles(self) -> None: def _create_chart_toggles(self) -> None:
"""Create toggle controls for chart elements.""" """Create toggle controls for chart elements with improved layout."""
ttk.Label(self.control_frame, text="Show/Hide Elements:").pack( # Pathology toggles
side="left", padx=5 pathology_frame = ttk.LabelFrame(
self.control_frame, text="Pathologies", padding="5"
) )
pathology_frame.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=2)
# Pathologies toggles - dynamic based on pathology manager # Use grid for better layout
pathologies_frame = ttk.LabelFrame(self.control_frame, text="Pathologies") row, col = 0, 0
pathologies_frame.pack(side="left", padx=5, pady=2)
for pathology_key in self.pathology_manager.get_pathology_keys(): for pathology_key in self.pathology_manager.get_pathology_keys():
pathology = self.pathology_manager.get_pathology(pathology_key) pathology = self.pathology_manager.get_pathology(pathology_key)
if pathology: if pathology:
checkbox = ttk.Checkbutton( display_name = pathology.display_name
pathologies_frame, text = (
text=pathology.display_name, display_name[:10] + "..."
if len(display_name) > 10
else display_name
)
cb = ttk.Checkbutton(
pathology_frame,
text=text,
variable=self.toggle_vars[pathology_key], variable=self.toggle_vars[pathology_key],
command=self._handle_toggle_changed, command=self._handle_toggle_changed,
) )
checkbox.pack(side="left", padx=3) cb.grid(row=row, column=col, sticky="w", padx=2)
col += 1
if col > 1: # 2 columns max
col = 0
row += 1
# Medicines toggles - dynamic based on medicine manager # Medicine toggles
medicines_frame = ttk.LabelFrame(self.control_frame, text="Medicines") medicine_frame = ttk.LabelFrame(
medicines_frame.pack(side="left", padx=5, pady=2) self.control_frame, text="Medicines", padding="5"
)
medicine_frame.pack(side=tk.RIGHT, fill=tk.X, expand=True, padx=2)
# Use grid for medicines too
row, col = 0, 0
for medicine_key in self.medicine_manager.get_medicine_keys(): for medicine_key in self.medicine_manager.get_medicine_keys():
medicine = self.medicine_manager.get_medicine(medicine_key) medicine = self.medicine_manager.get_medicine(medicine_key)
if medicine: if medicine:
checkbox = ttk.Checkbutton( med_name = medicine.display_name
medicines_frame, text = med_name[:10] + "..." if len(med_name) > 10 else med_name
text=medicine.display_name, cb = ttk.Checkbutton(
medicine_frame,
text=text,
variable=self.toggle_vars[medicine_key], variable=self.toggle_vars[medicine_key],
command=self._handle_toggle_changed, command=self._handle_toggle_changed,
) )
checkbox.pack(side="left", padx=3) cb.grid(row=row, column=col, sticky="w", padx=2)
col += 1
if col > 2: # 3 columns max for medicines
col = 0
row += 1
def _handle_toggle_changed(self) -> None: def _handle_toggle_changed(self) -> None:
"""Handle toggle changes by replotting the graph.""" """Handle toggle changes by replotting the graph with optimization."""
if not self.current_data.empty: if not self.current_data.empty:
self._plot_graph_data(self.current_data) self._plot_graph_data(self.current_data)
def update_graph(self, df: pd.DataFrame) -> None: def update_graph(self, df: pd.DataFrame) -> None:
"""Update the graph with new data.""" """Update the graph with new data using optimization checks."""
self.current_data = df.copy() if not df.empty else pd.DataFrame() # Create hash of data to avoid unnecessary redraws
self._plot_graph_data(df) data_hash = str(hash(str(df.values.tobytes()) if not df.empty else "empty"))
# Only update if data actually changed
if data_hash != self._last_plot_hash or self.current_data.empty:
self.current_data = df.copy() if not df.empty else pd.DataFrame()
self._last_plot_hash = data_hash
self._plot_graph_data(df)
def _plot_graph_data(self, df: pd.DataFrame) -> None: def _plot_graph_data(self, df: pd.DataFrame) -> None:
"""Plot the graph data with current toggle settings.""" """Plot the graph data with current toggle settings using optimizations."""
self.ax.clear() # Use batch updates to reduce redraws
if not df.empty: with plt.ioff(): # Turn off interactive mode for batch updates
# Convert dates and sort self.ax.clear()
df = df.copy() # Create a copy to avoid modifying the original
df["date"] = pd.to_datetime(df["date"])
df = df.sort_values(by="date")
df.set_index(keys="date", inplace=True)
# Track if any series are plotted if not df.empty:
has_plotted_series = False # Optimize data processing
df_processed = self._preprocess_data(df)
# Plot pathology data series based on toggle states # Track if any series are plotted
for pathology_key in self.pathology_manager.get_pathology_keys(): has_plotted_series = self._plot_pathology_data(df_processed)
if self.toggle_vars[pathology_key].get(): medicine_data = self._plot_medicine_data(df_processed)
pathology = self.pathology_manager.get_pathology(pathology_key)
if pathology and pathology_key in df.columns:
label = f"{pathology.display_name} ({pathology.scale_info})"
linestyle = (
"dashed"
if pathology.scale_orientation == "inverted"
else "-"
)
self._plot_series(df, pathology_key, label, "o", linestyle)
has_plotted_series = True
# Plot medicine dose data if has_plotted_series or medicine_data["has_plotted"]:
# Get medicine colors from medicine manager self._configure_graph_appearance(medicine_data)
medicine_colors = self.medicine_manager.get_graph_colors()
# Get medicines dynamically from medicine manager # Single draw call at the end
medicines = self.medicine_manager.get_medicine_keys() self.canvas.draw_idle()
# Track medicines with and without data for legend def _preprocess_data(self, df: pd.DataFrame) -> pd.DataFrame:
medicines_with_data = [] """Preprocess data for plotting with optimizations."""
medicines_without_data = [] df = df.copy()
# Batch convert dates and sort
df["date"] = pd.to_datetime(df["date"], cache=True)
df = df.sort_values(by="date")
df.set_index(keys="date", inplace=True)
return df
for medicine in medicines: def _plot_pathology_data(self, df: pd.DataFrame) -> bool:
dose_column = f"{medicine}_doses" """Plot pathology data series with optimizations."""
if self.toggle_vars[medicine].get() and dose_column in df.columns: has_plotted_series = False
# Calculate daily dose totals
daily_doses = []
for dose_str in df[dose_column]:
total_dose = self._calculate_daily_dose(dose_str)
daily_doses.append(total_dose)
# Only plot if there are non-zero doses # Batch plot pathology data
if any(dose > 0 for dose in daily_doses): pathology_keys = self.pathology_manager.get_pathology_keys()
medicines_with_data.append(medicine) active_pathologies = [
# Scale doses for better visibility key
# (divide by 10 to fit with 0-10 scale) for key in pathology_keys
scaled_doses = [dose / 10 for dose in daily_doses] if self.toggle_vars[key].get() and key in df.columns
]
# Calculate total dosage for this medicine across all days for pathology_key in active_pathologies:
total_medicine_dose = sum(daily_doses) pathology = self.pathology_manager.get_pathology(pathology_key)
non_zero_doses = [d for d in daily_doses if d > 0] if pathology:
avg_dose = total_medicine_dose / len(non_zero_doses) label = f"{pathology.display_name} ({pathology.scale_info})"
linestyle = (
"dashed" if pathology.scale_orientation == "inverted" else "-"
)
self._plot_series(df, pathology_key, label, "o", linestyle)
has_plotted_series = True
# Create more informative label return has_plotted_series
def _plot_medicine_data(self, df: pd.DataFrame) -> dict:
"""Plot medicine data with optimizations."""
result = {"has_plotted": False, "with_data": [], "without_data": []}
# Get medicine colors and keys in batch
medicine_colors = self.medicine_manager.get_graph_colors()
medicines = self.medicine_manager.get_medicine_keys()
# Pre-calculate daily doses for all medicines to avoid repeated computation
medicine_doses = {}
for medicine in medicines:
dose_column = f"{medicine}_doses"
if dose_column in df.columns:
daily_doses = [
self._calculate_daily_dose(dose_str) for dose_str in df[dose_column]
]
medicine_doses[medicine] = daily_doses
# Plot medicines with data
for medicine in medicines:
if self.toggle_vars[medicine].get() and medicine in medicine_doses:
daily_doses = medicine_doses[medicine]
# Check if there's any data to plot
if any(dose > 0 for dose in daily_doses):
result["with_data"].append(medicine)
# Optimize dose scaling and bar plotting
scaled_doses = [dose / 10 for dose in daily_doses]
# Calculate statistics more efficiently
non_zero_doses = [d for d in daily_doses if d > 0]
if non_zero_doses:
avg_dose = sum(daily_doses) / len(non_zero_doses)
label = f"{medicine.capitalize()} (avg: {avg_dose:.1f}mg)" label = f"{medicine.capitalize()} (avg: {avg_dose:.1f}mg)"
# Single bar plot call
self.ax.bar( self.ax.bar(
df.index, df.index,
scaled_doses, scaled_doses,
@@ -193,56 +234,59 @@ class GraphManager:
width=0.6, width=0.6,
bottom=-max(scaled_doses) * 1.1 if scaled_doses else -1, bottom=-max(scaled_doses) * 1.1 if scaled_doses else -1,
) )
has_plotted_series = True result["has_plotted"] = True
else: else:
# Medicine is toggled on but has no dose data # Medicine is toggled on but has no dose data
if self.toggle_vars[medicine].get(): if self.toggle_vars[medicine].get():
medicines_without_data.append(medicine) result["without_data"].append(medicine)
# Configure graph appearance return result
if has_plotted_series:
# Get current legend handles and labels
handles, labels = self.ax.get_legend_handles_labels()
# Add information about medicines without data if any are toggled on def _configure_graph_appearance(self, medicine_data: dict) -> None:
if medicines_without_data: """Configure graph appearance with optimizations."""
# Add a text note about medicines without dose data # Get legend data in batch
med_list = ", ".join(medicines_without_data) handles, labels = self.ax.get_legend_handles_labels()
info_text = f"Tracked (no doses): {med_list}"
labels.append(info_text)
# Create a dummy handle for the info text (invisible)
from matplotlib.patches import Rectangle
dummy_handle = Rectangle( # Add information about medicines without data if any are toggled on
(0, 0), 1, 1, fc="w", fill=False, edgecolor="none", linewidth=0 if medicine_data["without_data"]:
) med_list = ", ".join(medicine_data["without_data"])
handles.append(dummy_handle) info_text = f"Tracked (no doses): {med_list}"
labels.append(info_text)
# Create an expanded legend with better formatting # Create dummy handle more efficiently
self.ax.legend( from matplotlib.patches import Rectangle
handles,
labels,
loc="upper left",
bbox_to_anchor=(0, 1),
ncol=2, # Display in 2 columns for better space usage
fontsize="small",
frameon=True,
fancybox=True,
shadow=True,
framealpha=0.9,
)
self.ax.set_title("Medication Effects Over Time")
self.ax.set_xlabel("Date")
self.ax.set_ylabel("Rating (0-10) / Dose (mg)")
# Adjust y-axis to accommodate medicine bars at bottom dummy_handle = Rectangle(
current_ylim = self.ax.get_ylim() (0, 0), 1, 1, fc="w", fill=False, edgecolor="none", linewidth=0
self.ax.set_ylim(bottom=current_ylim[0], top=max(10, current_ylim[1])) )
handles.append(dummy_handle)
self.fig.autofmt_xdate() # Create legend with optimized settings
if handles and labels:
self.ax.legend(
handles,
labels,
loc="upper left",
bbox_to_anchor=(0, 1),
ncol=2,
fontsize="small",
frameon=True,
fancybox=True,
shadow=True,
framealpha=0.9,
)
# Redraw the canvas # Set titles and labels
self.canvas.draw() self.ax.set_title("Medication Effects Over Time")
self.ax.set_xlabel("Date")
self.ax.set_ylabel("Rating (0-10) / Dose (mg)")
# Optimize y-axis configuration
current_ylim = self.ax.get_ylim()
self.ax.set_ylim(bottom=current_ylim[0], top=max(10, current_ylim[1]))
# Optimize date formatting
self.fig.autofmt_xdate()
def _plot_series( def _plot_series(
self, self,
@@ -252,25 +296,28 @@ class GraphManager:
marker: str, marker: str,
linestyle: str, linestyle: str,
) -> None: ) -> None:
"""Helper method to plot a data series.""" """Helper method to plot a data series with optimizations."""
# Use more efficient plotting parameters
self.ax.plot( self.ax.plot(
df.index, df.index,
df[column], df[column],
marker=marker, marker=marker,
linestyle=linestyle, linestyle=linestyle,
label=label, label=label,
markersize=4, # Smaller markers for better performance
linewidth=1.5, # Optimized line width
) )
def _calculate_daily_dose(self, dose_str: str) -> float: def _calculate_daily_dose(self, dose_str: str) -> float:
"""Calculate total daily dose from dose string format.""" """Calculate total daily dose from dose string format with optimizations."""
if not dose_str or pd.isna(dose_str) or str(dose_str).lower() == "nan": if not dose_str or pd.isna(dose_str) or str(dose_str).lower() == "nan":
return 0.0 return 0.0
total_dose = 0.0 total_dose = 0.0
# Handle different separators and clean the string # Optimize string processing
dose_str = str(dose_str).replace("", "").strip() dose_str = str(dose_str).replace("", "").strip()
# Split by | or by spaces if no | present # More efficient splitting and processing
dose_entries = dose_str.split("|") if "|" in dose_str else [dose_str] dose_entries = dose_str.split("|") if "|" in dose_str else [dose_str]
for entry in dose_entries: for entry in dose_entries:
@@ -279,15 +326,15 @@ class GraphManager:
continue continue
try: try:
# Extract dose part after the last colon (timestamp:dose format) # More efficient dose extraction
dose_part = entry.split(":")[-1] if ":" in entry else entry dose_part = entry.split(":")[-1] if ":" in entry else entry
# Extract numeric part from dose (e.g., "150mg" -> 150) # Optimized numeric extraction
dose_value = "" dose_value = ""
for char in dose_part: for char in dose_part:
if char.isdigit() or char == ".": if char.isdigit() or char == ".":
dose_value += char dose_value += char
elif dose_value: # Stop at first non-digit after finding digits elif dose_value:
break break
if dose_value: if dose_value:
@@ -298,5 +345,10 @@ class GraphManager:
return total_dose return total_dose
def close(self) -> None: def close(self) -> None:
"""Clean up resources.""" """Clean up resources with proper optimization."""
plt.close(self.fig) try:
# Clear the plot before closing
self.ax.clear()
plt.close(self.fig)
except Exception:
pass # Ignore cleanup errors
+65 -5
View File
@@ -7,8 +7,10 @@ from typing import Any
import pandas as pd import pandas as pd
from constants import LOG_LEVEL, LOG_PATH from constants import LOG_CLEAR, LOG_LEVEL, LOG_PATH
from data_manager import DataManager from data_manager import DataManager
from export_manager import ExportManager
from export_window import ExportWindow
from graph_manager import GraphManager from graph_manager import GraphManager
from init import logger from init import logger
from medicine_management_window import MedicineManagementWindow from medicine_management_window import MedicineManagementWindow
@@ -40,9 +42,12 @@ class MedTrackerApp:
Using default file: {self.filename}" Using default file: {self.filename}"
) )
logger.info(f"Log level: {LOG_LEVEL}")
if LOG_LEVEL == "DEBUG": if LOG_LEVEL == "DEBUG":
logger.debug(f"Script name: {sys.argv[0]}") logger.debug(f"Script name: {sys.argv[0]}")
logger.debug(f"Logs path: {LOG_PATH}") logger.debug(f"Logs path: {LOG_PATH}")
logger.debug(f"Log clear: {LOG_CLEAR}")
logger.debug(f"First argument: {first_argument}") logger.debug(f"First argument: {first_argument}")
# Initialize managers # Initialize managers
@@ -67,6 +72,29 @@ class MedTrackerApp:
# Add menu bar # Add menu bar
self._setup_menu() self._setup_menu()
# Center the window on screen
self._center_window()
def _center_window(self) -> None:
"""Center the main window on the screen."""
# Update the window to get accurate dimensions
self.root.update_idletasks()
# Get window dimensions
window_width = self.root.winfo_reqwidth()
window_height = self.root.winfo_reqheight()
# Get screen dimensions
screen_width = self.root.winfo_screenwidth()
screen_height = self.root.winfo_screenheight()
# Calculate position to center the window
x = (screen_width // 2) - (window_width // 2)
y = (screen_height // 2) - (window_height // 2)
# Set the window geometry
self.root.geometry(f"{window_width}x{window_height}+{x}+{y}")
def _setup_main_ui(self) -> None: def _setup_main_ui(self) -> None:
"""Set up the main UI components.""" """Set up the main UI components."""
import tkinter.ttk as ttk import tkinter.ttk as ttk
@@ -91,6 +119,15 @@ class MedTrackerApp:
graph_frame, self.medicine_manager, self.pathology_manager graph_frame, self.medicine_manager, self.pathology_manager
) )
# Initialize export manager
self.export_manager: ExportManager = ExportManager(
self.data_manager,
self.graph_manager,
self.medicine_manager,
self.pathology_manager,
logger,
)
# --- Create Input Frame --- # --- Create Input Frame ---
input_ui: dict[str, Any] = self.ui_manager.create_input_frame(main_frame) input_ui: dict[str, Any] = self.ui_manager.create_input_frame(main_frame)
self.input_frame: ttk.Frame = input_ui["frame"] self.input_frame: ttk.Frame = input_ui["frame"]
@@ -126,6 +163,13 @@ class MedTrackerApp:
menubar = tk.Menu(self.root) menubar = tk.Menu(self.root)
self.root.config(menu=menubar) self.root.config(menu=menubar)
# File menu
file_menu = tk.Menu(menubar, tearoff=0)
menubar.add_cascade(label="File", menu=file_menu)
file_menu.add_command(label="Export Data...", command=self._open_export_window)
file_menu.add_separator()
file_menu.add_command(label="Exit", command=self.handle_window_closing)
# Tools menu # Tools menu
tools_menu = tk.Menu(menubar, tearoff=0) tools_menu = tk.Menu(menubar, tearoff=0)
menubar.add_cascade(label="Tools", menu=tools_menu) menubar.add_cascade(label="Tools", menu=tools_menu)
@@ -136,6 +180,10 @@ class MedTrackerApp:
label="Manage Medicines...", command=self._open_medicine_manager label="Manage Medicines...", command=self._open_medicine_manager
) )
def _open_export_window(self) -> None:
"""Open the export window."""
ExportWindow(self.root, self.export_manager)
def _open_pathology_manager(self) -> None: def _open_pathology_manager(self) -> None:
"""Open the pathology management window.""" """Open the pathology management window."""
PathologyManagementWindow( PathologyManagementWindow(
@@ -150,6 +198,12 @@ class MedTrackerApp:
def _refresh_ui_after_config_change(self) -> None: def _refresh_ui_after_config_change(self) -> None:
"""Refresh UI components after pathology or medicine configuration changes.""" """Refresh UI components after pathology or medicine configuration changes."""
# Clear caches in optimized data manager
if hasattr(self.data_manager, "_invalidate_cache"):
self.data_manager._invalidate_cache()
self.data_manager._headers_cache = None
self.data_manager._dtype_cache = None
# Recreate the input frame with new pathologies and medicines # Recreate the input frame with new pathologies and medicines
self.input_frame.destroy() self.input_frame.destroy()
input_ui: dict[str, Any] = self.ui_manager.create_input_frame( input_ui: dict[str, Any] = self.ui_manager.create_input_frame(
@@ -412,9 +466,10 @@ class MedTrackerApp:
"""Load data from the CSV file into the table and graph.""" """Load data from the CSV file into the table and graph."""
logger.debug("Loading data from CSV.") logger.debug("Loading data from CSV.")
# Clear existing data in the treeview # Clear existing data in the treeview efficiently
for i in self.tree.get_children(): children = self.tree.get_children()
self.tree.delete(i) if children:
self.tree.delete(*children)
# Load data from the CSV file # Load data from the CSV file
df: pd.DataFrame = self.data_manager.load_data() df: pd.DataFrame = self.data_manager.load_data()
@@ -422,7 +477,11 @@ class MedTrackerApp:
# Update the treeview with the data # Update the treeview with the data
if not df.empty: if not df.empty:
# Build display columns dynamically (exclude dose columns for table view) # Build display columns dynamically (exclude dose columns for table view)
display_columns = ["date", "depression", "anxiety", "sleep", "appetite"] display_columns = ["date"]
# Add pathology columns
for pathology_key in self.pathology_manager.get_pathology_keys():
display_columns.append(pathology_key)
# Add medicine columns (without dose columns) # Add medicine columns (without dose columns)
for medicine_key in self.medicine_manager.get_medicine_keys(): for medicine_key in self.medicine_manager.get_medicine_keys():
@@ -437,6 +496,7 @@ class MedTrackerApp:
# Fallback - just use all columns # Fallback - just use all columns
display_df = df display_df = df
# Batch insert for better performance
for _index, row in display_df.iterrows(): for _index, row in display_df.iterrows():
self.tree.insert(parent="", index="end", values=list(row)) self.tree.insert(parent="", index="end", values=list(row))
logger.debug(f"Loaded {len(display_df)} entries into treeview.") logger.debug(f"Loaded {len(display_df)} entries into treeview.")
+32 -344
View File
@@ -417,8 +417,8 @@ class UIManager:
# Extract note (should be the last value) # Extract note (should be the last value)
note = values_list[-1] if len(values_list) > 0 else "" note = values_list[-1] if len(values_list) > 0 else ""
# Create improved UI sections dynamically # Create improved UI sections
vars_dict = self._create_edit_ui_dynamic( vars_dict = self._create_edit_ui(
main_container, main_container,
date, date,
pathology_values, pathology_values,
@@ -443,7 +443,7 @@ class UIManager:
return edit_win return edit_win
def _create_edit_ui_dynamic( def _create_edit_ui(
self, self,
parent: ttk.Frame, parent: ttk.Frame,
date: str, date: str,
@@ -500,7 +500,7 @@ class UIManager:
meds_frame.grid_columnconfigure(0, weight=1) meds_frame.grid_columnconfigure(0, weight=1)
# Create medicine checkboxes dynamically # Create medicine checkboxes dynamically
med_vars = self._create_medicine_section_dynamic(meds_frame, medicine_values) med_vars = self._create_medicine_section(meds_frame, medicine_values)
vars_dict.update(med_vars) vars_dict.update(med_vars)
row += 1 row += 1
@@ -510,7 +510,7 @@ class UIManager:
dose_frame.grid(row=row, column=0, sticky="ew", pady=(0, 15)) dose_frame.grid(row=row, column=0, sticky="ew", pady=(0, 15))
dose_frame.grid_columnconfigure(0, weight=1) dose_frame.grid_columnconfigure(0, weight=1)
dose_vars = self._create_dose_tracking_dynamic(dose_frame, medicine_doses) dose_vars = self._create_dose_tracking(dose_frame, medicine_doses)
vars_dict.update(dose_vars) vars_dict.update(dose_vars)
row += 1 row += 1
@@ -532,6 +532,7 @@ class UIManager:
) )
note_text.grid(row=0, column=0, sticky="ew", padx=5, pady=5) note_text.grid(row=0, column=0, sticky="ew", padx=5, pady=5)
note_text.insert("1.0", str(note)) note_text.insert("1.0", str(note))
vars_dict["note_text"] = note_text # Store the widget for access during save
# Bind text widget to string var for easy access # Bind text widget to string var for easy access
def update_note(*args): def update_note(*args):
@@ -542,111 +543,6 @@ class UIManager:
return vars_dict return vars_dict
def _create_edit_ui(
self,
parent: ttk.Frame,
date: str,
dep: int,
anx: int,
slp: int,
app: int,
bup: int,
hydro: int,
gaba: int,
prop: int,
quet: int,
note: str,
dose_data: dict[str, str],
) -> dict[str, Any]:
"""Create UI layout for edit window with organized sections."""
vars_dict = {}
row = 0
# Header with entry date
header_frame = ttk.Frame(parent)
header_frame.grid(row=row, column=0, sticky="ew", pady=(0, 20))
header_frame.grid_columnconfigure(1, weight=1)
ttk.Label(
header_frame, text="Editing Entry for:", font=("TkDefaultFont", 12, "bold")
).grid(row=0, column=0, sticky="w")
vars_dict["date"] = tk.StringVar(value=str(date))
date_entry = ttk.Entry(
header_frame,
textvariable=vars_dict["date"],
font=("TkDefaultFont", 12),
width=15,
)
date_entry.grid(row=0, column=1, sticky="w", padx=(10, 0))
row += 1
# Symptoms section
symptoms_frame = ttk.LabelFrame(
parent, text="Daily Symptoms (0-10 scale)", padding="15"
)
symptoms_frame.grid(row=row, column=0, sticky="ew", pady=(0, 15))
symptoms_frame.grid_columnconfigure(1, weight=1)
# Create symptom scales with better layout
symptoms = [
("Depression", "depression", dep),
("Anxiety", "anxiety", anx),
("Sleep Quality", "sleep", slp),
("Appetite", "appetite", app),
]
for i, (label, key, value) in enumerate(symptoms):
self._create_symptom_scale(symptoms_frame, i, label, key, value, vars_dict)
row += 1
# Medications section
meds_frame = ttk.LabelFrame(parent, text="Medications Taken", padding="15")
meds_frame.grid(row=row, column=0, sticky="ew", pady=(0, 15))
meds_frame.grid_columnconfigure(0, weight=1)
# Create medicine checkboxes with better styling
med_vars = self._create_medicine_section(
meds_frame, bup, hydro, gaba, prop, quet
)
vars_dict.update(med_vars)
row += 1
# Dose tracking section
dose_frame = ttk.LabelFrame(parent, text="Dose Tracking", padding="15")
dose_frame.grid(row=row, column=0, sticky="ew", pady=(0, 15))
dose_frame.grid_columnconfigure(0, weight=1)
dose_vars = self._create_dose_tracking(dose_frame, dose_data)
vars_dict.update(dose_vars)
row += 1
# Notes section
notes_frame = ttk.LabelFrame(parent, text="Notes", padding="15")
notes_frame.grid(row=row, column=0, sticky="ew", pady=(0, 20))
notes_frame.grid_columnconfigure(0, weight=1)
vars_dict["note"] = tk.StringVar(value=str(note))
note_text = tk.Text(
notes_frame, height=4, wrap=tk.WORD, font=("TkDefaultFont", 10)
)
note_text.grid(row=0, column=0, sticky="ew")
note_text.insert(1.0, str(note))
vars_dict["note_text"] = note_text
# Add scrollbar for notes
note_scroll = ttk.Scrollbar(
notes_frame, orient="vertical", command=note_text.yview
)
note_scroll.grid(row=0, column=1, sticky="ns")
note_text.configure(yscrollcommand=note_scroll.set)
return vars_dict
def _create_symptom_scale( def _create_symptom_scale(
self, self,
parent: ttk.Frame, parent: ttk.Frame,
@@ -733,91 +629,6 @@ class UIManager:
scale.bind("<KeyRelease>", update_value_label) scale.bind("<KeyRelease>", update_value_label)
update_value_label() # Set initial color update_value_label() # Set initial color
def _create_enhanced_symptom_scale(
self,
parent: ttk.Frame,
row: int,
label: str,
key: str,
value: int,
vars_dict: dict[str, tk.IntVar],
) -> None:
"""Create enhanced symptom scale for new entry form (like edit window)."""
# Ensure value is properly converted
try:
value = int(float(value)) if value not in ["", None] else 0
except (ValueError, TypeError):
value = 0
# Label
label_widget = ttk.Label(
parent, text=f"{label} (0-10):", font=("TkDefaultFont", 10, "bold")
)
label_widget.grid(row=row, column=0, sticky="w", padx=5, pady=8)
# Scale container
scale_container = ttk.Frame(parent)
scale_container.grid(row=row, column=1, sticky="ew", padx=(20, 5), pady=8)
scale_container.grid_columnconfigure(0, weight=1)
# Scale with value labels
scale_frame = ttk.Frame(scale_container)
scale_frame.grid(row=0, column=0, sticky="ew")
scale_frame.grid_columnconfigure(1, weight=1)
# Current value display
value_label = ttk.Label(
scale_frame,
text=str(value),
font=("TkDefaultFont", 12, "bold"),
foreground="#2E86AB",
width=3,
)
value_label.grid(row=0, column=0, padx=(0, 10))
# Scale widget
scale = ttk.Scale(
scale_frame,
from_=0,
to=10,
variable=vars_dict[key],
orient=tk.HORIZONTAL,
length=250, # Slightly smaller than edit window to fit better
)
scale.grid(row=0, column=1, sticky="ew")
# Scale labels (0, 5, 10)
labels_frame = ttk.Frame(scale_container)
labels_frame.grid(row=1, column=0, sticky="ew", pady=(5, 0))
ttk.Label(labels_frame, text="0", font=("TkDefaultFont", 8)).grid(
row=0, column=0, sticky="w"
)
labels_frame.grid_columnconfigure(1, weight=1)
ttk.Label(labels_frame, text="5", font=("TkDefaultFont", 8)).grid(
row=0, column=1
)
ttk.Label(labels_frame, text="10", font=("TkDefaultFont", 8)).grid(
row=0, column=2, sticky="e"
)
# Update label when scale changes
def update_value_label(event=None):
current_val = vars_dict[key].get()
value_label.configure(text=str(current_val))
# Change color based on value
if current_val <= 3:
value_label.configure(foreground="#28A745") # Green for low/good
elif current_val <= 6:
value_label.configure(foreground="#FFC107") # Yellow for medium
else:
value_label.configure(foreground="#DC3545") # Red for high/bad
scale.bind("<Motion>", update_value_label)
scale.bind("<ButtonRelease-1>", update_value_label)
scale.bind("<KeyRelease>", update_value_label)
update_value_label() # Set initial color
def _create_enhanced_pathology_scale( def _create_enhanced_pathology_scale(
self, self,
parent: ttk.Frame, parent: ttk.Frame,
@@ -927,153 +738,6 @@ class UIManager:
update_value_label_pathology() # Set initial color update_value_label_pathology() # Set initial color
def _create_medicine_section( def _create_medicine_section(
self, parent: ttk.Frame, bup: int, hydro: int, gaba: int, prop: int, quet: int
) -> dict[str, tk.IntVar]:
"""Create medicine checkboxes with organized layout."""
vars_dict = {}
# Create a grid layout for medicines
medicines = [
("bupropion", bup, "Bupropion", "150/300 mg", "#E8F4FD"),
("hydroxyzine", hydro, "Hydroxyzine", "25 mg", "#FFF2E8"),
("gabapentin", gaba, "Gabapentin", "100 mg", "#F0F8E8"),
("propranolol", prop, "Propranolol", "10 mg", "#FCE8F3"),
("quetiapine", quet, "Quetiapine", "25 mg", "#E8F0FF"),
]
# Create medicine cards in a 2-column layout
for i, (key, value, name, dose, _bg_color) in enumerate(medicines):
row = i // 2
col = i % 2
# Medicine card frame
med_card = ttk.Frame(parent, relief="solid", borderwidth=1)
med_card.grid(row=row, column=col, sticky="ew", padx=5, pady=5)
parent.grid_columnconfigure(col, weight=1)
vars_dict[key] = tk.IntVar(value=int(value))
# Checkbox with medicine name
check_frame = ttk.Frame(med_card)
check_frame.pack(fill="x", padx=10, pady=8)
checkbox = ttk.Checkbutton(
check_frame,
text=f"{name} ({dose})",
variable=vars_dict[key],
style="Medicine.TCheckbutton",
)
checkbox.pack(anchor="w")
return vars_dict
def _create_dose_tracking(
self, parent: ttk.Frame, dose_data: dict[str, str]
) -> dict[str, Any]:
"""Create dose tracking interface."""
vars_dict = {}
# Create notebook for organized dose tracking
notebook = ttk.Notebook(parent)
notebook.pack(fill="both", expand=True)
medicines = [
("bupropion", "Bupropion"),
("hydroxyzine", "Hydroxyzine"),
("gabapentin", "Gabapentin"),
("propranolol", "Propranolol"),
("quetiapine", "Quetiapine"),
]
for med_key, med_name in medicines:
# Create tab for each medicine
tab_frame = ttk.Frame(notebook)
notebook.add(tab_frame, text=med_name)
# Configure tab layout
tab_frame.grid_columnconfigure(0, weight=1)
# Quick dose entry section
entry_frame = ttk.LabelFrame(tab_frame, text="Add New Dose", padding="10")
entry_frame.grid(row=0, column=0, sticky="ew", padx=10, pady=5)
entry_frame.grid_columnconfigure(1, weight=1)
ttk.Label(entry_frame, text="Dose amount:").grid(
row=0, column=0, sticky="w"
)
dose_entry_var = tk.StringVar()
vars_dict[f"{med_key}_entry_var"] = dose_entry_var
dose_entry = ttk.Entry(entry_frame, textvariable=dose_entry_var, width=15)
dose_entry.grid(row=0, column=1, sticky="w", padx=(10, 10))
# Quick dose buttons
quick_frame = ttk.Frame(entry_frame)
quick_frame.grid(row=0, column=2, sticky="w")
# Common dose amounts (customize per medicine)
quick_doses = self._get_quick_doses(med_key)
for i, dose in enumerate(quick_doses):
ttk.Button(
quick_frame,
text=dose,
width=8,
command=lambda d=dose, var=dose_entry_var: var.set(d),
).grid(row=0, column=i, padx=2)
# Take dose button
def create_take_dose_command(med_name, entry_var, med_key):
def take_dose():
self._take_dose(med_name, entry_var, med_key, vars_dict)
return take_dose
take_button = ttk.Button(
entry_frame,
text=f"Take {med_name}",
style="Accent.TButton",
command=create_take_dose_command(med_name, dose_entry_var, med_key),
)
take_button.grid(row=1, column=0, columnspan=3, pady=(10, 0), sticky="ew")
# Dose history section
history_frame = ttk.LabelFrame(
tab_frame, text="Today's Doses", padding="10"
)
history_frame.grid(row=1, column=0, sticky="ew", padx=10, pady=5)
history_frame.grid_columnconfigure(0, weight=1)
# Dose history display with fixed height to prevent excessive expansion
dose_text = tk.Text(
history_frame,
height=4, # Reduced height to fit better in scrollable window
wrap=tk.WORD,
font=("Consolas", 10),
state="normal", # Start enabled
)
dose_text.grid(row=0, column=0, sticky="ew")
# Store raw dose string in a variable
doses_str = dose_data.get(med_key, "")
dose_str_var = tk.StringVar(value=doses_str)
vars_dict[f"{med_key}_doses_str"] = dose_str_var
# Populate with existing doses
self._populate_dose_history(dose_text, dose_str_var.get())
vars_dict[f"{med_key}_doses_text"] = dose_text
# Scrollbar for dose history
dose_scroll = ttk.Scrollbar(
history_frame, orient="vertical", command=dose_text.yview
)
dose_scroll.grid(row=0, column=1, sticky="ns")
dose_text.configure(yscrollcommand=dose_scroll.set)
return vars_dict
def _create_medicine_section_dynamic(
self, parent: ttk.Frame, medicine_values: dict[str, int] self, parent: ttk.Frame, medicine_values: dict[str, int]
) -> dict[str, tk.IntVar]: ) -> dict[str, tk.IntVar]:
"""Create medicine checkboxes dynamically.""" """Create medicine checkboxes dynamically."""
@@ -1120,7 +784,7 @@ class UIManager:
return vars_dict return vars_dict
def _create_dose_tracking_dynamic( def _create_dose_tracking(
self, parent: ttk.Frame, medicine_doses: dict[str, str] self, parent: ttk.Frame, medicine_doses: dict[str, str]
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Create dose tracking interface dynamically.""" """Create dose tracking interface dynamically."""
@@ -1398,9 +1062,33 @@ class UIManager:
# Get note text from Text widget # Get note text from Text widget
note_text_widget = vars_dict.get("note_text") note_text_widget = vars_dict.get("note_text")
self.logger.debug(f"note_text_widget found: {note_text_widget is not None}")
self.logger.debug(f"vars_dict keys: {list(vars_dict.keys())}")
note_content = "" note_content = ""
if note_text_widget: if note_text_widget:
note_content = note_text_widget.get(1.0, tk.END).strip() try:
note_content = note_text_widget.get(1.0, tk.END).strip()
self.logger.debug(f"Note content from widget: '{note_content}'")
except Exception as e:
self.logger.error(f"Error getting note from text widget: {e}")
# Fallback to StringVar
note_var = vars_dict.get("note")
if note_var:
note_content = note_var.get()
self.logger.debug(
f"Note content from StringVar fallback: '{note_content}'"
)
else:
# Fallback to StringVar if note_text widget not found
note_var = vars_dict.get("note")
if note_var:
note_content = note_var.get()
self.logger.debug(f"Note content from StringVar: '{note_content}'")
else:
self.logger.error("No note widget or StringVar found!")
self.logger.debug(f"Final note_content: '{note_content}'")
# Extract dose data dynamically from all medicines # Extract dose data dynamically from all medicines
dose_data = {} dose_data = {}
Generated
+64 -1
View File
@@ -20,6 +20,28 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249, upload-time = "2023-08-12T20:38:16.269Z" }, { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249, upload-time = "2023-08-12T20:38:16.269Z" },
] ]
[[package]]
name = "charset-normalizer"
version = "3.4.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e4/33/89c2ced2b67d1c2a61c19c6751aa8902d46ce3dacb23600a283619f5a12d/charset_normalizer-3.4.2.tar.gz", hash = "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63", size = 126367, upload-time = "2025-05-02T08:34:42.01Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ea/12/a93df3366ed32db1d907d7593a94f1fe6293903e3e92967bebd6950ed12c/charset_normalizer-3.4.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0", size = 199622, upload-time = "2025-05-02T08:32:56.363Z" },
{ url = "https://files.pythonhosted.org/packages/04/93/bf204e6f344c39d9937d3c13c8cd5bbfc266472e51fc8c07cb7f64fcd2de/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf", size = 143435, upload-time = "2025-05-02T08:32:58.551Z" },
{ url = "https://files.pythonhosted.org/packages/22/2a/ea8a2095b0bafa6c5b5a55ffdc2f924455233ee7b91c69b7edfcc9e02284/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e", size = 153653, upload-time = "2025-05-02T08:33:00.342Z" },
{ url = "https://files.pythonhosted.org/packages/b6/57/1b090ff183d13cef485dfbe272e2fe57622a76694061353c59da52c9a659/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1", size = 146231, upload-time = "2025-05-02T08:33:02.081Z" },
{ url = "https://files.pythonhosted.org/packages/e2/28/ffc026b26f441fc67bd21ab7f03b313ab3fe46714a14b516f931abe1a2d8/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c", size = 148243, upload-time = "2025-05-02T08:33:04.063Z" },
{ url = "https://files.pythonhosted.org/packages/c0/0f/9abe9bd191629c33e69e47c6ef45ef99773320e9ad8e9cb08b8ab4a8d4cb/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691", size = 150442, upload-time = "2025-05-02T08:33:06.418Z" },
{ url = "https://files.pythonhosted.org/packages/67/7c/a123bbcedca91d5916c056407f89a7f5e8fdfce12ba825d7d6b9954a1a3c/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0", size = 145147, upload-time = "2025-05-02T08:33:08.183Z" },
{ url = "https://files.pythonhosted.org/packages/ec/fe/1ac556fa4899d967b83e9893788e86b6af4d83e4726511eaaad035e36595/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b", size = 153057, upload-time = "2025-05-02T08:33:09.986Z" },
{ url = "https://files.pythonhosted.org/packages/2b/ff/acfc0b0a70b19e3e54febdd5301a98b72fa07635e56f24f60502e954c461/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff", size = 156454, upload-time = "2025-05-02T08:33:11.814Z" },
{ url = "https://files.pythonhosted.org/packages/92/08/95b458ce9c740d0645feb0e96cea1f5ec946ea9c580a94adfe0b617f3573/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b", size = 154174, upload-time = "2025-05-02T08:33:13.707Z" },
{ url = "https://files.pythonhosted.org/packages/78/be/8392efc43487ac051eee6c36d5fbd63032d78f7728cb37aebcc98191f1ff/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148", size = 149166, upload-time = "2025-05-02T08:33:15.458Z" },
{ url = "https://files.pythonhosted.org/packages/44/96/392abd49b094d30b91d9fbda6a69519e95802250b777841cf3bda8fe136c/charset_normalizer-3.4.2-cp313-cp313-win32.whl", hash = "sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7", size = 98064, upload-time = "2025-05-02T08:33:17.06Z" },
{ url = "https://files.pythonhosted.org/packages/e9/b0/0200da600134e001d91851ddc797809e2fe0ea72de90e09bec5a2fbdaccb/charset_normalizer-3.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980", size = 105641, upload-time = "2025-05-02T08:33:18.753Z" },
{ url = "https://files.pythonhosted.org/packages/20/94/c5790835a017658cbfabd07f3bfb549140c3ac458cfc196323996b10095a/charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0", size = 52626, upload-time = "2025-05-02T08:34:40.053Z" },
]
[[package]] [[package]]
name = "colorama" name = "colorama"
version = "0.4.6" version = "0.4.6"
@@ -258,6 +280,30 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/4c/fa/be89a49c640930180657482a74970cdcf6f7072c8d2471e1babe17a222dc/kiwisolver-1.4.8-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:be4816dc51c8a471749d664161b434912eee82f2ea66bd7628bd14583a833e85", size = 2349213, upload-time = "2024-12-24T18:30:40.019Z" }, { url = "https://files.pythonhosted.org/packages/4c/fa/be89a49c640930180657482a74970cdcf6f7072c8d2471e1babe17a222dc/kiwisolver-1.4.8-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:be4816dc51c8a471749d664161b434912eee82f2ea66bd7628bd14583a833e85", size = 2349213, upload-time = "2024-12-24T18:30:40.019Z" },
] ]
[[package]]
name = "lxml"
version = "6.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c5/ed/60eb6fa2923602fba988d9ca7c5cdbd7cf25faa795162ed538b527a35411/lxml-6.0.0.tar.gz", hash = "sha256:032e65120339d44cdc3efc326c9f660f5f7205f3a535c1fdbf898b29ea01fb72", size = 4096938, upload-time = "2025-06-26T16:28:19.373Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/79/21/6e7c060822a3c954ff085e5e1b94b4a25757c06529eac91e550f3f5cd8b8/lxml-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6da7cd4f405fd7db56e51e96bff0865b9853ae70df0e6720624049da76bde2da", size = 8414372, upload-time = "2025-06-26T16:26:39.079Z" },
{ url = "https://files.pythonhosted.org/packages/a4/f6/051b1607a459db670fc3a244fa4f06f101a8adf86cda263d1a56b3a4f9d5/lxml-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b34339898bb556a2351a1830f88f751679f343eabf9cf05841c95b165152c9e7", size = 4593940, upload-time = "2025-06-26T16:26:41.891Z" },
{ url = "https://files.pythonhosted.org/packages/8e/74/dd595d92a40bda3c687d70d4487b2c7eff93fd63b568acd64fedd2ba00fe/lxml-6.0.0-cp313-cp313-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:51a5e4c61a4541bd1cd3ba74766d0c9b6c12d6a1a4964ef60026832aac8e79b3", size = 5214329, upload-time = "2025-06-26T16:26:44.669Z" },
{ url = "https://files.pythonhosted.org/packages/52/46/3572761efc1bd45fcafb44a63b3b0feeb5b3f0066886821e94b0254f9253/lxml-6.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d18a25b19ca7307045581b18b3ec9ead2b1db5ccd8719c291f0cd0a5cec6cb81", size = 4947559, upload-time = "2025-06-28T18:47:31.091Z" },
{ url = "https://files.pythonhosted.org/packages/94/8a/5e40de920e67c4f2eef9151097deb9b52d86c95762d8ee238134aff2125d/lxml-6.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d4f0c66df4386b75d2ab1e20a489f30dc7fd9a06a896d64980541506086be1f1", size = 5102143, upload-time = "2025-06-28T18:47:33.612Z" },
{ url = "https://files.pythonhosted.org/packages/7c/4b/20555bdd75d57945bdabfbc45fdb1a36a1a0ff9eae4653e951b2b79c9209/lxml-6.0.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f4b481b6cc3a897adb4279216695150bbe7a44c03daba3c894f49d2037e0a24", size = 5021931, upload-time = "2025-06-26T16:26:47.503Z" },
{ url = "https://files.pythonhosted.org/packages/b6/6e/cf03b412f3763d4ca23b25e70c96a74cfece64cec3addf1c4ec639586b13/lxml-6.0.0-cp313-cp313-manylinux_2_27_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8a78d6c9168f5bcb20971bf3329c2b83078611fbe1f807baadc64afc70523b3a", size = 5645469, upload-time = "2025-07-03T19:19:13.32Z" },
{ url = "https://files.pythonhosted.org/packages/d4/dd/39c8507c16db6031f8c1ddf70ed95dbb0a6d466a40002a3522c128aba472/lxml-6.0.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ae06fbab4f1bb7db4f7c8ca9897dc8db4447d1a2b9bee78474ad403437bcc29", size = 5247467, upload-time = "2025-06-26T16:26:49.998Z" },
{ url = "https://files.pythonhosted.org/packages/4d/56/732d49def0631ad633844cfb2664563c830173a98d5efd9b172e89a4800d/lxml-6.0.0-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:1fa377b827ca2023244a06554c6e7dc6828a10aaf74ca41965c5d8a4925aebb4", size = 4720601, upload-time = "2025-06-26T16:26:52.564Z" },
{ url = "https://files.pythonhosted.org/packages/8f/7f/6b956fab95fa73462bca25d1ea7fc8274ddf68fb8e60b78d56c03b65278e/lxml-6.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1676b56d48048a62ef77a250428d1f31f610763636e0784ba67a9740823988ca", size = 5060227, upload-time = "2025-06-26T16:26:55.054Z" },
{ url = "https://files.pythonhosted.org/packages/97/06/e851ac2924447e8b15a294855caf3d543424364a143c001014d22c8ca94c/lxml-6.0.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:0e32698462aacc5c1cf6bdfebc9c781821b7e74c79f13e5ffc8bfe27c42b1abf", size = 4790637, upload-time = "2025-06-26T16:26:57.384Z" },
{ url = "https://files.pythonhosted.org/packages/06/d4/fd216f3cd6625022c25b336c7570d11f4a43adbaf0a56106d3d496f727a7/lxml-6.0.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4d6036c3a296707357efb375cfc24bb64cd955b9ec731abf11ebb1e40063949f", size = 5662049, upload-time = "2025-07-03T19:19:16.409Z" },
{ url = "https://files.pythonhosted.org/packages/52/03/0e764ce00b95e008d76b99d432f1807f3574fb2945b496a17807a1645dbd/lxml-6.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7488a43033c958637b1a08cddc9188eb06d3ad36582cebc7d4815980b47e27ef", size = 5272430, upload-time = "2025-06-26T16:27:00.031Z" },
{ url = "https://files.pythonhosted.org/packages/5f/01/d48cc141bc47bc1644d20fe97bbd5e8afb30415ec94f146f2f76d0d9d098/lxml-6.0.0-cp313-cp313-win32.whl", hash = "sha256:5fcd7d3b1d8ecb91445bd71b9c88bdbeae528fefee4f379895becfc72298d181", size = 3612896, upload-time = "2025-06-26T16:27:04.251Z" },
{ url = "https://files.pythonhosted.org/packages/f4/87/6456b9541d186ee7d4cb53bf1b9a0d7f3b1068532676940fdd594ac90865/lxml-6.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:2f34687222b78fff795feeb799a7d44eca2477c3d9d3a46ce17d51a4f383e32e", size = 4013132, upload-time = "2025-06-26T16:27:06.415Z" },
{ url = "https://files.pythonhosted.org/packages/b7/42/85b3aa8f06ca0d24962f8100f001828e1f1f1a38c954c16e71154ed7d53a/lxml-6.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:21db1ec5525780fd07251636eb5f7acb84003e9382c72c18c542a87c416ade03", size = 3672642, upload-time = "2025-06-26T16:27:09.888Z" },
]
[[package]] [[package]]
name = "macholib" name = "macholib"
version = "1.16.3" version = "1.16.3"
@@ -653,6 +699,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" }, { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" },
] ]
[[package]]
name = "reportlab"
version = "4.4.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "charset-normalizer" },
{ name = "pillow" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2f/83/3d44b873fa71ddc7d323c577fe4cfb61e05b34d14e64b6a232f9cfbff89d/reportlab-4.4.3.tar.gz", hash = "sha256:073b0975dab69536acd3251858e6b0524ed3e087e71f1d0d1895acb50acf9c7b", size = 3887532, upload-time = "2025-07-23T11:18:23.799Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/52/c8/aaf4e08679e7b1dc896ad30de0d0527f0fd55582c2e6deee4f2cc899bf9f/reportlab-4.4.3-py3-none-any.whl", hash = "sha256:df905dc5ec5ddaae91fc9cb3371af863311271d555236410954961c5ee6ee1b5", size = 1953896, upload-time = "2025-07-23T11:18:20.572Z" },
]
[[package]] [[package]]
name = "ruff" name = "ruff"
version = "0.12.5" version = "0.12.5"
@@ -698,13 +757,15 @@ wheels = [
[[package]] [[package]]
name = "thechart" name = "thechart"
version = "1.6.1" version = "1.8.5"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "colorlog" }, { name = "colorlog" },
{ name = "dotenv" }, { name = "dotenv" },
{ name = "lxml" },
{ name = "matplotlib" }, { name = "matplotlib" },
{ name = "pandas" }, { name = "pandas" },
{ name = "reportlab" },
{ name = "tk" }, { name = "tk" },
] ]
@@ -723,8 +784,10 @@ dev = [
requires-dist = [ requires-dist = [
{ name = "colorlog", specifier = ">=6.9.0" }, { name = "colorlog", specifier = ">=6.9.0" },
{ name = "dotenv", specifier = ">=0.9.9" }, { name = "dotenv", specifier = ">=0.9.9" },
{ name = "lxml", specifier = ">=6.0.0" },
{ name = "matplotlib", specifier = ">=3.10.3" }, { name = "matplotlib", specifier = ">=3.10.3" },
{ name = "pandas", specifier = ">=2.3.1" }, { name = "pandas", specifier = ">=2.3.1" },
{ name = "reportlab", specifier = ">=4.4.3" },
{ name = "tk", specifier = ">=0.1.0" }, { name = "tk", specifier = ">=0.1.0" },
] ]