Merge pull request 'TimeLogix overhaul & enhancements' (#1) from enhancements into main
Reviewed-on: https://brew.bsd.cafe/blake/time_logix/pulls/1
This commit is contained in:
commit
fa3453f153
5 changed files with 375 additions and 424 deletions
170
.github/workflows/build.yml
vendored
170
.github/workflows/build.yml
vendored
|
|
@ -1,170 +0,0 @@
|
||||||
name: Build AppImage, Windows EXE and Publish Release
|
|
||||||
|
|
||||||
on:
|
|
||||||
# push:
|
|
||||||
# branches:
|
|
||||||
# - main
|
|
||||||
tags:
|
|
||||||
- 'v*.*.*'
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-linux:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
name: Build AppImage
|
|
||||||
outputs:
|
|
||||||
artifact_path: ${{ steps.upload_artifacts.outputs.artifact_path }}
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v3
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
ref: ${{ github.ref }}
|
|
||||||
|
|
||||||
- name: Set up Python
|
|
||||||
uses: actions/setup-python@v4
|
|
||||||
with:
|
|
||||||
python-version: "3.x"
|
|
||||||
|
|
||||||
- name: Install PyInstaller and dependencies
|
|
||||||
run: pip install pyinstaller
|
|
||||||
|
|
||||||
- name: Build Linux executable with PyInstaller
|
|
||||||
run: |
|
|
||||||
pyinstaller --onefile --windowed time_logix.py
|
|
||||||
|
|
||||||
- name: Create AppDir and Files
|
|
||||||
run: |
|
|
||||||
mkdir -p AppDir/usr/bin
|
|
||||||
cp dist/time_logix AppDir/usr/bin/time_logix
|
|
||||||
# Create AppRun file
|
|
||||||
cat <<'EOF' > AppDir/AppRun
|
|
||||||
#!/bin/bash
|
|
||||||
HERE="$(dirname "$(readlink -f "${0}")")"
|
|
||||||
exec "$HERE/usr/bin/time_logix" "$@"
|
|
||||||
EOF
|
|
||||||
chmod +x AppDir/AppRun
|
|
||||||
# Create desktop file with icon reference
|
|
||||||
cat <<'EOF' > AppDir/time_logix.desktop
|
|
||||||
[Desktop Entry]
|
|
||||||
Type=Application
|
|
||||||
Name=TimeLogix
|
|
||||||
Exec=time_logix
|
|
||||||
Icon=appicon
|
|
||||||
Comment=Time tracking app for contractors
|
|
||||||
Categories=Utility;
|
|
||||||
EOF
|
|
||||||
# Download a placeholder icon and save it as appicon.png in AppDir
|
|
||||||
wget -q -O AppDir/appicon.png https://placehold.co/256
|
|
||||||
|
|
||||||
- name: Install FUSE library
|
|
||||||
run: sudo apt-get update && sudo apt-get install -y libfuse2
|
|
||||||
|
|
||||||
- name: Download appimagetool
|
|
||||||
run: |
|
|
||||||
wget -q https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage
|
|
||||||
chmod +x appimagetool-x86_64.AppImage
|
|
||||||
|
|
||||||
- name: Build AppImage
|
|
||||||
run: ./appimagetool-x86_64.AppImage AppDir
|
|
||||||
|
|
||||||
- name: Upload Linux Artifact
|
|
||||||
id: upload_artifacts
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: TimeLogix-AppImage
|
|
||||||
path: TimeLogix*-x86_64.AppImage
|
|
||||||
|
|
||||||
build-windows:
|
|
||||||
runs-on: windows-latest
|
|
||||||
name: Build Windows Executable
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v3
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
ref: ${{ github.ref }}
|
|
||||||
|
|
||||||
- name: Set up Python
|
|
||||||
uses: actions/setup-python@v4
|
|
||||||
with:
|
|
||||||
python-version: "3.x"
|
|
||||||
|
|
||||||
- name: Install PyInstaller
|
|
||||||
run: pip install pyinstaller
|
|
||||||
|
|
||||||
- name: Build Windows executable with PyInstaller
|
|
||||||
run: pyinstaller --onefile --windowed time_logix.py
|
|
||||||
|
|
||||||
- name: Upload Windows Artifact
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: TimeLogix-Windows
|
|
||||||
path: dist/time_logix.exe
|
|
||||||
|
|
||||||
release:
|
|
||||||
name: Publish Release
|
|
||||||
needs:
|
|
||||||
- build-linux
|
|
||||||
- build-windows
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Retrieve Linux artifact
|
|
||||||
uses: actions/download-artifact@v4
|
|
||||||
with:
|
|
||||||
name: TimeLogix-AppImage
|
|
||||||
|
|
||||||
- name: Retrieve Windows artifact
|
|
||||||
uses: actions/download-artifact@v4
|
|
||||||
with:
|
|
||||||
name: TimeLogix-Windows
|
|
||||||
|
|
||||||
- name: Create GitHub Release
|
|
||||||
id: create_release
|
|
||||||
uses: softprops/action-gh-release@v2
|
|
||||||
with:
|
|
||||||
tag_name: ${{ github.ref_name }}
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Install/Update GitHub CLI and Update PATH
|
|
||||||
run: |
|
|
||||||
gh --version
|
|
||||||
# Import the GitHub CLI public key directly using gpg
|
|
||||||
curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | gpg --dearmor -o /tmp/githubcli-archive-keyring.gpg
|
|
||||||
sudo mv /tmp/githubcli-archive-keyring.gpg /usr/share/keyrings/githubcli-archive-keyring.gpg
|
|
||||||
|
|
||||||
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null
|
|
||||||
sudo apt update
|
|
||||||
sudo apt install gh --yes
|
|
||||||
gh --version
|
|
||||||
|
|
||||||
# Get the directory where gh is installed
|
|
||||||
GH_PATH=$(dirname $(which gh))
|
|
||||||
|
|
||||||
# Add the path to the environment file
|
|
||||||
echo "GH_PATH=$GH_PATH" >> $GITHUB_ENV
|
|
||||||
echo "PATH=$GH_PATH:$PATH" >> $GITHUB_PATH
|
|
||||||
|
|
||||||
- name: Upload Linux AppImage to Release
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
gh release upload ${{ github.ref_name }} ./$(ls | grep AppImage) \
|
|
||||||
--name TimeLogix-AppImage.AppImage \
|
|
||||||
--label "Linux AppImage"
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
GH_PATH: ${{ env.GH_PATH }}
|
|
||||||
|
|
||||||
|
|
||||||
- name: Upload Windows EXE to Release
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
gh release upload ${{ github.ref_name }} ./$(ls | grep .exe) \
|
|
||||||
--name TimeLogix-Windows.exe \
|
|
||||||
--label "Windows Executable"
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,3 +1,4 @@
|
||||||
venv
|
venv
|
||||||
*.csv
|
*.csv
|
||||||
*.pdf
|
*.pdf
|
||||||
|
projects.txt
|
||||||
|
|
|
||||||
95
README.md
95
README.md
|
|
@ -1,38 +1,53 @@
|
||||||
# TimeLogix
|
# TimeLogix
|
||||||
|
|
||||||
TimeLogix is a simple, intuitive Tkinter-based time tracking application designed to help contractors log and manage their working hours effectively. This tool was created so I could keep proper track of time worked under a contract for a company, ensuring accurate record-keeping and easy export of session data for billing and reporting purposes.
|
TimeLogix is a modern, intuitive time tracking application designed to help contractors log and manage their working hours effectively. This tool was created to keep proper track of time worked, ensuring accurate record-keeping and easy export of session data for billing, reporting, and invoice generation.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Start/Stop Tracking:** Easily log your work sessions with start and stop buttons.
|
- **Start/Stop Tracking:** Easily log your work sessions with start and stop buttons.
|
||||||
- **Session Logging:** View session start times, end times, and durations both in \(H:MM:SS\) and decimal hours.
|
- **Session Logging:** View session start times, end times, and durations in H:MM:SS format.
|
||||||
- **CSV Export:** Export your session data to a CSV file for further processing or billing.
|
- **Project Management:** Assign time entries to specific projects.
|
||||||
- **Simple UI:** A clean and minimal user interface built using Tkinter.
|
- **CSV Export:** Export session data to a CSV file for further processing or reporting.
|
||||||
|
- **PDF Invoice Generation:** Generate professional PDF invoices based on logged time entries.
|
||||||
|
- **Customizable Settings:** Configure company and client information, along with an hourly rate, for accurate invoice generation.
|
||||||
|
- **Modern UI:** Built using CustomTkinter for a sleek and user-friendly experience.
|
||||||
|
- **Scrollable Interface:** Ensures all elements are accessible, even on smaller screens.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- Python 3.x
|
- Python 3.x
|
||||||
- Tkinter (usually included with standard Python installations)
|
- CustomTkinter: `pip install customtkinter`
|
||||||
|
- reportlab: `pip install reportlab`
|
||||||
|
- csv (Included with Python)
|
||||||
|
- tkinter (Included with Python)
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
1. **Clone the Repository:**
|
1. **Clone the Repository:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/blakeridgway/time_logix.git
|
||||||
|
cd time_logix
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **(Optional) Create and Activate a Virtual Environment:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m venv venv
|
||||||
|
source venv/bin/activate # On Linux/macOS
|
||||||
|
venv\Scripts\activate # On Windows
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Install Dependencies:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/yourusername/timelogix.git
|
pip install -r requirements.txt
|
||||||
cd timelogix
|
|
||||||
```
|
```
|
||||||
|
or
|
||||||
|
|
||||||
2. **(Optional) Create and Activate a Virtual Environment:**
|
```bash
|
||||||
|
pip install customtkinter reportlab
|
||||||
```bash
|
```
|
||||||
python -m venv venv
|
|
||||||
source venv/bin/activate # On Windows use `venv\Scripts\activate`
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Install Dependencies:**
|
|
||||||
|
|
||||||
There are no additional dependencies beyond the Python Standard Library. However, ensure you have the latest version of Python installed.
|
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
|
|
@ -44,17 +59,39 @@ python time_logix.py
|
||||||
|
|
||||||
### How to Use
|
### How to Use
|
||||||
|
|
||||||
1. **Start Tracking:**
|
1. **Start Tracking:**
|
||||||
- Click the **Start Tracking** button to log the starting time of your work session.
|
|
||||||
2. **Stop Tracking:**
|
|
||||||
- Click the **Stop Tracking** button to end the session. The app will display and log the duration in both formats, e.g., `1:30:00` and `1.50 hours`.
|
|
||||||
3. **Export Sessions:**
|
|
||||||
- Once you’re finished working, click the **Export Sessions** button to save your session data into `working_sessions.csv`.
|
|
||||||
|
|
||||||
## Customizations
|
* Click the **Start** button to begin tracking your work session.
|
||||||
|
|
||||||
- **Decimal Hours:** The app calculates hours worked in decimal format using a helper function.
|
2. **Stop Tracking:**
|
||||||
- **CSV File Export:** Sessions are exported to a CSV file with headers: `Start Time`, `End Time`, `Duration (H:MM:SS)`, and `Decimal Hours`.
|
|
||||||
|
* Click the **Stop** button to end the session. The application will log the start and end times and calculate the duration.
|
||||||
|
|
||||||
|
3. **Enter Task Description and Project:**
|
||||||
|
|
||||||
|
* Provide a brief description of the work completed and select the appropriate project from the dropdown menu.
|
||||||
|
|
||||||
|
4. **Add New Project:**
|
||||||
|
|
||||||
|
* Enter a new project name in the "New Project" field and click "Add Project". The new project will be added to the project dropdown.
|
||||||
|
|
||||||
|
5. **Export Data:**
|
||||||
|
|
||||||
|
* Click the "Export to CSV" button to save your session data to a CSV file ("working\_sessions.csv").
|
||||||
|
* Click the "Export to PDF" button to generate an invoice as a PDF file (named "invoice\_\*.pdf", where \* is the incrementing invoice number).
|
||||||
|
|
||||||
|
6. **Update Settings:**
|
||||||
|
|
||||||
|
* Fill out the company name, company address, client name, client address, and hourly rate fields.
|
||||||
|
* Click "Update Settings" to save these values. These settings will be used when generating PDF invoices.
|
||||||
|
|
||||||
|
7. **Calculate Total Time:**
|
||||||
|
|
||||||
|
* Click "Calculate Total Time" to display the sum of all recorded session durations.
|
||||||
|
|
||||||
|
8. **Exit:**
|
||||||
|
|
||||||
|
* Click "Exit" to close the application.
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1 +1,2 @@
|
||||||
|
customtkinter
|
||||||
reportlab
|
reportlab
|
||||||
532
time_logix.py
532
time_logix.py
|
|
@ -1,271 +1,353 @@
|
||||||
|
import customtkinter as ctk
|
||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
from tkinter import messagebox
|
from tkinter import ttk
|
||||||
from datetime import datetime, timedelta
|
import time
|
||||||
|
import datetime
|
||||||
import csv
|
import csv
|
||||||
import os
|
|
||||||
from reportlab.lib.pagesizes import letter
|
from reportlab.lib.pagesizes import letter
|
||||||
from reportlab.platypus import SimpleDocTemplate, Table, TableStyle
|
from reportlab.pdfgen import canvas
|
||||||
from reportlab.lib import colors
|
from reportlab.lib import colors
|
||||||
|
from reportlab.platypus import Table, TableStyle, Paragraph
|
||||||
|
from reportlab.lib.styles import getSampleStyleSheet
|
||||||
from reportlab.lib.units import inch
|
from reportlab.lib.units import inch
|
||||||
|
import sys
|
||||||
|
|
||||||
class TimeLogix(tk.Tk):
|
|
||||||
|
class TimeLogix:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
self.root = ctk.CTk()
|
||||||
self.title("TimeLogix")
|
self.root.title("TimeLogix")
|
||||||
self.geometry("500x500")
|
|
||||||
|
|
||||||
|
# Theme Configuration
|
||||||
|
ctk.set_appearance_mode("Dark") # Or "Light", "System"
|
||||||
|
ctk.set_default_color_theme("blue")
|
||||||
|
|
||||||
|
# --- Styling ---
|
||||||
|
self.font_family = "Segoe UI"
|
||||||
|
self.font_size = 12
|
||||||
|
|
||||||
|
# --- App Data ---
|
||||||
|
self.project_file = "projects.txt"
|
||||||
|
self.projects = self.load_projects()
|
||||||
|
self.is_running = False
|
||||||
self.start_time = None
|
self.start_time = None
|
||||||
self.tracking = False
|
self.elapsed_time = 0
|
||||||
self.sessions = []
|
self.timer_id = None
|
||||||
self.total_hours = 0
|
self.log_entries = []
|
||||||
|
self.invoice_number = 1
|
||||||
|
self.company_name = "Your Company Name"
|
||||||
|
self.company_address = "123 Main St, Anytown, USA"
|
||||||
|
self.client_name = "Client Name"
|
||||||
|
self.client_address = "Client Address"
|
||||||
|
self.hourly_rate = 60.00
|
||||||
|
|
||||||
self.create_widgets()
|
# --- Scrollable Frame ---
|
||||||
self.load_sessions_from_csv()
|
self.scrollable_frame = ctk.CTkScrollableFrame(self.root, width=450, height=600) # Consider making height adaptable
|
||||||
self.update_total_hours()
|
self.scrollable_frame.pack(fill="both", expand=True, padx=10, pady=10)
|
||||||
|
|
||||||
def create_widgets(self):
|
# --- UI Elements ---
|
||||||
self.status_label = tk.Label(self, text="Status: Not Tracking", font=("Helvetica", 12))
|
self.task_label = ctk.CTkLabel(self.scrollable_frame, text="Task Description:", font=(self.font_family, self.font_size))
|
||||||
self.status_label.pack(pady=5)
|
self.task_label.pack(pady=(10, 2))
|
||||||
|
|
||||||
self.start_button = tk.Button(
|
self.task_entry = ctk.CTkEntry(self.scrollable_frame, width=250)
|
||||||
self, text="Start Tracking", width=20, command=self.start_tracking
|
self.task_entry.pack(pady=(2, 10))
|
||||||
)
|
|
||||||
self.start_button.pack(pady=2)
|
|
||||||
|
|
||||||
self.stop_button = tk.Button(
|
self.project_label = ctk.CTkLabel(self.scrollable_frame, text="Project:", font=(self.font_family, self.font_size))
|
||||||
self, text="Stop Tracking", width=20, command=self.stop_tracking, state=tk.DISABLED
|
self.project_label.pack(pady=(10, 2))
|
||||||
)
|
|
||||||
self.stop_button.pack(pady=2)
|
|
||||||
|
|
||||||
self.description_label = tk.Label(self, text="Work Description:")
|
self.project_combo = ctk.CTkComboBox(self.scrollable_frame, values=self.projects, width=250)
|
||||||
self.description_label.pack(pady=1)
|
self.project_combo.pack(pady=(2, 10))
|
||||||
self.description_entry = tk.Text(self, height=3, width=40)
|
if self.projects:
|
||||||
self.description_entry.pack(pady=1)
|
self.project_combo.set(self.projects[0])
|
||||||
|
|
||||||
self.export_csv_button = tk.Button(
|
self.time_label = ctk.CTkLabel(self.scrollable_frame, text="00:00:00", font=(self.font_family, 36))
|
||||||
self, text="Export to CSV", width=20, command=self.export_sessions_csv
|
self.time_label.pack(pady=(15, 20))
|
||||||
)
|
|
||||||
self.export_csv_button.pack(pady=2)
|
|
||||||
|
|
||||||
self.export_pdf_button = tk.Button(
|
self.start_stop_button = ctk.CTkButton(self.scrollable_frame, text="Start", command=self.toggle_timer, corner_radius=8)
|
||||||
self, text="Export to PDF", width=20, command=self.export_sessions_pdf
|
self.start_stop_button.pack(pady=(5, 20))
|
||||||
)
|
|
||||||
self.export_pdf_button.pack(pady=2)
|
|
||||||
|
|
||||||
self.total_hours_label = tk.Label(self, text="Total Hours Worked: 0.00", font=("Helvetica", 12))
|
self.log_label = ctk.CTkLabel(self.scrollable_frame, text="Log Entries:", font=(self.font_family, self.font_size))
|
||||||
self.total_hours_label.pack(pady=5)
|
self.log_label.pack(pady=(10, 2))
|
||||||
|
|
||||||
self.log_text = tk.Text(self, height=10, state=tk.DISABLED)
|
self.log_text = ctk.CTkTextbox(self.scrollable_frame, width=400, height=100, font=(self.font_family, self.font_size))
|
||||||
self.log_text.pack(pady=5, padx=10, fill='both', expand=True)
|
self.log_text.pack(pady=5, padx=10, fill='both', expand=True)
|
||||||
|
|
||||||
self.exit_button = tk.Button(
|
|
||||||
self, text="Exit", width=10, command=self.exit_app
|
|
||||||
)
|
|
||||||
self.exit_button.pack(pady=5)
|
|
||||||
|
|
||||||
def log_message(self, message):
|
self.new_project_label = ctk.CTkLabel(self.scrollable_frame, text="New Project:", font=(self.font_family, self.font_size))
|
||||||
if hasattr(self, 'log_text'):
|
self.new_project_label.pack(pady=(10, 2))
|
||||||
self.log_text.configure(state=tk.NORMAL)
|
|
||||||
self.log_text.insert(tk.END, f"{message}\n")
|
|
||||||
self.log_text.configure(state=tk.DISABLED)
|
|
||||||
self.log_text.see(tk.END)
|
|
||||||
|
|
||||||
def start_tracking(self):
|
self.new_project_entry = ctk.CTkEntry(self.scrollable_frame, width=250)
|
||||||
if self.tracking:
|
self.new_project_entry.pack(pady=(2, 10))
|
||||||
messagebox.showwarning("Warning", "Already tracking!")
|
|
||||||
return
|
|
||||||
|
|
||||||
self.start_time = datetime.now()
|
self.add_project_button = ctk.CTkButton(self.scrollable_frame, text="Add Project", command=self.add_project, corner_radius=8)
|
||||||
self.tracking = True
|
self.add_project_button.pack(pady=(5, 15))
|
||||||
self.status_label.config(
|
|
||||||
text=f"Status: Tracking started at {self.start_time.strftime('%H:%M:%S')}"
|
|
||||||
)
|
|
||||||
self.log_message(f"Started at: {self.start_time.strftime('%Y-%m-%d %H:%M:%S')}")
|
|
||||||
self.start_button.config(state=tk.DISABLED)
|
|
||||||
self.stop_button.config(state=tk.NORMAL)
|
|
||||||
|
|
||||||
def stop_tracking(self):
|
# --- Button Frame ---
|
||||||
if not self.tracking:
|
button_frame = ctk.CTkFrame(self.scrollable_frame)
|
||||||
messagebox.showwarning("Warning", "Not currently tracking!")
|
button_frame.pack(pady=(10, 15))
|
||||||
return
|
|
||||||
|
|
||||||
end_time = datetime.now()
|
self.export_csv_button = ctk.CTkButton(button_frame, text="Export to CSV", command=self.export_to_csv, corner_radius=8)
|
||||||
duration = end_time - self.start_time
|
self.export_csv_button.pack(side="left", padx=5, pady=5) # Using side="left" for horizontal layout
|
||||||
description = self.description_entry.get("1.0", tk.END).strip()
|
|
||||||
self.sessions.append((self.start_time, end_time, duration, description))
|
|
||||||
self.tracking = False
|
|
||||||
self.status_label.config(text="Status: Not Tracking")
|
|
||||||
decimal_hours = self.get_decimal_hours(duration)
|
|
||||||
self.log_message(
|
|
||||||
f"Stopped at: {end_time.strftime('%Y-%m-%d %H:%M:%S')}, Duration: {self.format_duration(duration)} "
|
|
||||||
f"({decimal_hours:.2f} hours), Description: {description}"
|
|
||||||
)
|
|
||||||
self.start_button.config(state=tk.NORMAL)
|
|
||||||
self.stop_button.config(state=tk.DISABLED)
|
|
||||||
self.description_entry.delete("1.0", tk.END)
|
|
||||||
|
|
||||||
self.update_total_hours()
|
self.export_pdf_button = ctk.CTkButton(button_frame, text="Export to PDF", command=self.export_to_pdf, corner_radius=8)
|
||||||
|
self.export_pdf_button.pack(side="left", padx=5, pady=5)
|
||||||
|
|
||||||
def format_duration(self, duration):
|
self.exit_button = ctk.CTkButton(button_frame, text="Exit", command=self.exit_app, corner_radius=8)
|
||||||
total_seconds = int(duration.total_seconds())
|
self.exit_button.pack(side="left", padx=5, pady=5)
|
||||||
hours = total_seconds // 3600
|
|
||||||
minutes = (total_seconds % 3600) // 60
|
|
||||||
seconds = total_seconds % 60
|
|
||||||
return f"{hours}:{minutes:02d}:{seconds:02d}"
|
|
||||||
|
|
||||||
def get_decimal_hours(self, duration):
|
self.total_time_button = ctk.CTkButton(self.scrollable_frame, text="Calculate Total Time", command=self.calculate_total_time, corner_radius=8)
|
||||||
total_hours = duration.total_seconds() / 3600
|
self.total_time_button.pack(pady=(5, 15))
|
||||||
return total_hours
|
|
||||||
|
|
||||||
def export_sessions_csv(self):
|
self.total_time_label = ctk.CTkLabel(self.scrollable_frame, text="Total Time: 00:00:00", font=(self.font_family, self.font_size))
|
||||||
if not self.sessions:
|
self.total_time_label.pack(pady=(5, 15))
|
||||||
messagebox.showinfo("Info", "No sessions to export.")
|
|
||||||
return
|
|
||||||
|
|
||||||
filename = "working_sessions.csv"
|
# --- Settings UI ---
|
||||||
|
self.company_name_label = ctk.CTkLabel(self.scrollable_frame, text="Company Name:", font=(self.font_family, self.font_size))
|
||||||
|
self.company_name_label.pack(pady=(10, 2))
|
||||||
|
|
||||||
|
self.company_name_entry = ctk.CTkEntry(self.scrollable_frame, width=250)
|
||||||
|
self.company_name_entry.pack(pady=(2, 10))
|
||||||
|
|
||||||
|
self.company_address_label = ctk.CTkLabel(self.scrollable_frame, text="Company Address:", font=(self.font_family, self.font_size))
|
||||||
|
self.company_address_label.pack(pady=(10, 2))
|
||||||
|
|
||||||
|
self.company_address_entry = ctk.CTkEntry(self.scrollable_frame, width=250)
|
||||||
|
self.company_address_entry.pack(pady=(2, 10))
|
||||||
|
|
||||||
|
self.client_name_label = ctk.CTkLabel(self.scrollable_frame, text="Client Name:", font=(self.font_family, self.font_size))
|
||||||
|
self.client_name_label.pack(pady=(10, 2))
|
||||||
|
|
||||||
|
self.client_name_entry = ctk.CTkEntry(self.scrollable_frame, width=250)
|
||||||
|
self.client_name_entry.pack(pady=(2, 10))
|
||||||
|
|
||||||
|
self.client_address_label = ctk.CTkLabel(self.scrollable_frame, text="Client Address:", font=(self.font_family, self.font_size))
|
||||||
|
self.client_address_label.pack(pady=(10, 2))
|
||||||
|
|
||||||
|
self.client_address_entry = ctk.CTkEntry(self.scrollable_frame, width=250)
|
||||||
|
self.client_address_entry.pack(pady=(2, 10))
|
||||||
|
|
||||||
|
self.hourly_rate_label = ctk.CTkLabel(self.scrollable_frame, text="Hourly Rate:", font=(self.font_family, self.font_size))
|
||||||
|
self.hourly_rate_label.pack(pady=(10, 2))
|
||||||
|
|
||||||
|
self.hourly_rate_entry = ctk.CTkEntry(self.scrollable_frame, width=100)
|
||||||
|
self.hourly_rate_entry.pack(pady=(2, 10))
|
||||||
|
|
||||||
|
self.update_settings_button = ctk.CTkButton(self.scrollable_frame, text="Update Settings", command=self.update_settings, corner_radius=8)
|
||||||
|
self.update_settings_button.pack(pady=(15, 20))
|
||||||
|
|
||||||
|
def load_projects(self):
|
||||||
try:
|
try:
|
||||||
with open(filename, mode="w", newline="") as csvfile:
|
with open(self.project_file, "r") as f:
|
||||||
writer = csv.writer(csvfile)
|
projects = [line.strip() for line in f]
|
||||||
writer.writerow([
|
return projects
|
||||||
"Start Time", "End Time",
|
except FileNotFoundError:
|
||||||
"Duration (H:MM:SS)", "Decimal Hours", "Description"
|
return []
|
||||||
])
|
|
||||||
for start, end, duration, description in self.sessions:
|
def save_projects(self):
|
||||||
writer.writerow([
|
try:
|
||||||
start.strftime("%Y-%m-%d %H:%M:%S"),
|
with open(self.project_file, "w") as f:
|
||||||
end.strftime("%Y-%m-%d %H:%M:%S"),
|
for project in self.projects:
|
||||||
self.format_duration(duration),
|
f.write(project + "\n")
|
||||||
f"{self.get_decimal_hours(duration):.2f}",
|
|
||||||
description
|
|
||||||
])
|
|
||||||
writer.writerow(["", "", "", "Total Hours", f"{self.total_hours:.2f}"])
|
|
||||||
messagebox.showinfo("Export Successful", f"Sessions exported to {filename}")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
messagebox.showerror("Error", f"Failed to export: {e}")
|
print(f"Error saving projects: {e}")
|
||||||
|
|
||||||
def export_sessions_pdf(self):
|
def add_project(self):
|
||||||
if not self.sessions:
|
new_project = self.new_project_entry.get().strip()
|
||||||
messagebox.showinfo("Info", "No sessions to export.")
|
if new_project and new_project not in self.projects:
|
||||||
return
|
self.projects.append(new_project)
|
||||||
|
self.project_combo.configure(values=self.projects)
|
||||||
|
self.project_combo.set(new_project)
|
||||||
|
self.save_projects()
|
||||||
|
self.new_project_entry.delete(0, tk.END)
|
||||||
|
elif new_project in self.projects:
|
||||||
|
print("Project already exists.")
|
||||||
|
else:
|
||||||
|
print("Project name cannot be empty.")
|
||||||
|
|
||||||
filename = "working_sessions.pdf"
|
def update_settings(self):
|
||||||
|
self.company_name = self.company_name_entry.get()
|
||||||
|
self.company_address = self.company_address_entry.get()
|
||||||
|
self.client_name = self.client_name_entry.get()
|
||||||
|
self.client_address = self.client_address_entry.get()
|
||||||
try:
|
try:
|
||||||
doc = SimpleDocTemplate(filename, pagesize=letter)
|
self.hourly_rate = float(self.hourly_rate_entry.get())
|
||||||
elements = []
|
except ValueError:
|
||||||
|
print("Invalid hourly rate. Using default.")
|
||||||
|
self.hourly_rate = 50.00
|
||||||
|
|
||||||
data = [
|
def toggle_timer(self):
|
||||||
["Start Time", "End Time", "Duration (H:MM:SS)", "Decimal Hours", "Description"]
|
if self.is_running:
|
||||||
]
|
self.stop_timer()
|
||||||
for start, end, duration, description in self.sessions:
|
else:
|
||||||
data.append([
|
self.start_timer()
|
||||||
start.strftime("%Y-%m-%d %H:%M:%S"),
|
|
||||||
end.strftime("%Y-%m-%d %H:%M:%S"),
|
|
||||||
self.format_duration(duration),
|
|
||||||
f"{self.get_decimal_hours(duration):.2f}",
|
|
||||||
description
|
|
||||||
])
|
|
||||||
|
|
||||||
data.append(["", "", "", "Total Hours", f"{self.total_hours:.2f}"])
|
def start_timer(self):
|
||||||
|
self.is_running = True
|
||||||
|
self.start_stop_button.configure(text="Stop")
|
||||||
|
self.start_time = time.time()
|
||||||
|
self.update_timer()
|
||||||
|
|
||||||
table = Table(data)
|
def stop_timer(self):
|
||||||
style = TableStyle([
|
self.is_running = False
|
||||||
('BACKGROUND', (0, 0), (-1, 0), colors.grey),
|
self.start_stop_button.configure(text="Start")
|
||||||
('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke),
|
self.root.after_cancel(self.timer_id)
|
||||||
('ALIGN', (0, 0), (-1, -1), 'CENTER'),
|
self.log_time_entry()
|
||||||
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
|
|
||||||
('BOTTOMPADDING', (0, 0), (-1, 0), 12),
|
def update_timer(self):
|
||||||
('BACKGROUND', (0, 1), (-1, -1), colors.beige),
|
if self.is_running:
|
||||||
('GRID', (0, 0), (-1, -1), 1, colors.black)
|
self.elapsed_time = time.time() - self.start_time
|
||||||
])
|
minutes, seconds = divmod(self.elapsed_time, 60)
|
||||||
|
hours, minutes = divmod(minutes, 60)
|
||||||
|
time_str = "{:02d}:{:02d}:{:02d}".format(
|
||||||
|
int(hours), int(minutes), int(seconds)
|
||||||
|
)
|
||||||
|
self.time_label.configure(text=time_str)
|
||||||
|
self.timer_id = self.root.after(100, self.update_timer)
|
||||||
|
|
||||||
|
def log_time_entry(self):
|
||||||
|
end_time = datetime.datetime.now()
|
||||||
|
task_description = self.task_entry.get()
|
||||||
|
project = self.project_combo.get()
|
||||||
|
duration = self.elapsed_time
|
||||||
|
start_time_str = end_time - datetime.timedelta(seconds=duration)
|
||||||
|
|
||||||
|
entry = {
|
||||||
|
"task": task_description,
|
||||||
|
"project": project,
|
||||||
|
"start_time": start_time_str.strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
"end_time": end_time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
"duration": round(duration, 2),
|
||||||
|
}
|
||||||
|
self.log_entries.append(entry)
|
||||||
|
self.update_log_display()
|
||||||
|
self.elapsed_time = 0
|
||||||
|
self.time_label.configure(text="00:00:00")
|
||||||
|
|
||||||
|
def update_log_display(self):
|
||||||
|
self.log_text.delete("1.0", tk.END)
|
||||||
|
for entry in self.log_entries:
|
||||||
|
self.log_text.insert(tk.END, f"Task: {entry['task']}\n")
|
||||||
|
self.log_text.insert(tk.END, f"Project: {entry['project']}\n")
|
||||||
|
self.log_text.insert(tk.END, f"Start: {entry['start_time']}\n")
|
||||||
|
self.log_text.insert(tk.END, f"End: {entry['end_time']}\n")
|
||||||
|
self.log_text.insert(tk.END, f"Duration: {entry['duration']} seconds\n")
|
||||||
|
self.log_text.insert(tk.END, "-" * 20 + "\n")
|
||||||
|
|
||||||
|
def export_to_csv(self):
|
||||||
|
try:
|
||||||
|
with open("working_sessions.csv", "w", newline="") as csvfile:
|
||||||
|
fieldnames = ["task", "project", "start_time", "end_time", "duration"]
|
||||||
|
writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
|
||||||
|
writer.writeheader()
|
||||||
|
for entry in self.log_entries:
|
||||||
|
writer.writerow(entry)
|
||||||
|
print("Exported to CSV successfully!")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error exporting to CSV: {e}")
|
||||||
|
|
||||||
|
def export_to_pdf(self):
|
||||||
|
try:
|
||||||
|
filename = f"invoice_{self.invoice_number}.pdf"
|
||||||
|
c = canvas.Canvas(filename, pagesize=letter)
|
||||||
|
styles = getSampleStyleSheet()
|
||||||
|
|
||||||
|
# --- Header ---
|
||||||
|
c.setFont("Helvetica-Bold", 16)
|
||||||
|
c.drawString(inch, 7.5 * inch, self.company_name)
|
||||||
|
c.setFont("Helvetica", 10)
|
||||||
|
c.drawString(inch, 7.3 * inch, self.company_address)
|
||||||
|
|
||||||
|
c.setFont("Helvetica-Bold", 12)
|
||||||
|
c.drawString(4.5 * inch, 7.5 * inch, "Invoice")
|
||||||
|
c.setFont("Helvetica", 10)
|
||||||
|
c.drawString(
|
||||||
|
4.5 * inch, 7.3 * inch, f"Invoice Number: {self.invoice_number}"
|
||||||
|
)
|
||||||
|
current_date = datetime.datetime.now().strftime("%Y-%m-%d")
|
||||||
|
c.drawString(4.5 * inch, 7.1 * inch, f"Date: {current_date}")
|
||||||
|
|
||||||
|
# --- Client Info ---
|
||||||
|
c.setFont("Helvetica-Bold", 12)
|
||||||
|
c.drawString(inch, 6.5 * inch, "Bill To:")
|
||||||
|
c.setFont("Helvetica", 10)
|
||||||
|
c.drawString(inch, 6.3 * inch, self.client_name)
|
||||||
|
c.drawString(inch, 6.1 * inch, self.client_address)
|
||||||
|
|
||||||
|
# --- Table ---
|
||||||
|
data = [["Task", "Project", "Hours", "Rate", "Total"]]
|
||||||
|
total_amount = 0
|
||||||
|
|
||||||
|
for entry in self.log_entries:
|
||||||
|
hours = entry["duration"] / 3600
|
||||||
|
line_total = hours * self.hourly_rate
|
||||||
|
total_amount += line_total
|
||||||
|
data.append(
|
||||||
|
[
|
||||||
|
entry["task"],
|
||||||
|
entry["project"],
|
||||||
|
f"{hours:.2f}",
|
||||||
|
f"${self.hourly_rate:.2f}",
|
||||||
|
f"${line_total:.2f}",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
table = Table(data, colWidths=[1.5 * inch, 1.5 * inch, inch, inch, inch])
|
||||||
|
style = 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"),
|
||||||
|
("BOTTOMPADDING", (0, 0), (-1, 0), 12),
|
||||||
|
("BACKGROUND", (0, 1), (-1, -1), colors.beige),
|
||||||
|
("GRID", (0, 0), (-1, -1), 1, colors.black),
|
||||||
|
]
|
||||||
|
)
|
||||||
table.setStyle(style)
|
table.setStyle(style)
|
||||||
elements.append(table)
|
table.wrapOn(c, letter[0] - 2 * inch, letter[1] - 2 * inch)
|
||||||
|
table.drawOn(c, inch, 4 * inch)
|
||||||
|
|
||||||
|
# --- Totals ---
|
||||||
|
c.setFont("Helvetica-Bold", 12)
|
||||||
|
c.drawString(4 * inch, 3.5 * inch, "Subtotal:")
|
||||||
|
c.setFont("Helvetica", 12)
|
||||||
|
c.drawRightString(5.5 * inch, 3.5 * inch, f"${total_amount:.2f}")
|
||||||
|
|
||||||
|
c.setFont("Helvetica-Bold", 12)
|
||||||
|
c.drawString(4 * inch, 3.3 * inch, "Tax (0%):")
|
||||||
|
c.setFont("Helvetica", 12)
|
||||||
|
c.drawRightString(5.5 * inch, 3.3 * inch, "$0.00")
|
||||||
|
|
||||||
|
c.setFont("Helvetica-Bold", 12)
|
||||||
|
c.drawString(4 * inch, 3.1 * inch, "Total:")
|
||||||
|
c.setFont("Helvetica", 12)
|
||||||
|
c.drawRightString(5.5 * inch, 3.1 * inch, f"${total_amount:.2f}")
|
||||||
|
|
||||||
|
# --- Notes ---
|
||||||
|
c.setFont("Helvetica", 10)
|
||||||
|
c.drawString(inch, 2 * inch, "Notes:")
|
||||||
|
c.drawString(inch, 1.8 * inch, "Thank you for your business!")
|
||||||
|
|
||||||
|
c.save()
|
||||||
|
print(f"Exported to PDF successfully as {filename}!")
|
||||||
|
self.invoice_number += 1
|
||||||
|
|
||||||
doc.build(elements)
|
|
||||||
messagebox.showinfo("Export Successful", f"Sessions exported to {filename}")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
messagebox.showerror("Error", f"Failed to export: {e}")
|
print(f"Error exporting to PDF: {e}")
|
||||||
|
|
||||||
def load_sessions_from_csv(self):
|
def calculate_total_time(self):
|
||||||
filename = "working_sessions.csv"
|
total_seconds = sum(entry["duration"] for entry in self.log_entries)
|
||||||
if os.path.exists(filename):
|
minutes, seconds = divmod(total_seconds, 60)
|
||||||
try:
|
hours, minutes = divmod(minutes, 60)
|
||||||
with open(filename, mode="r") as csvfile:
|
time_str = "{:02d}:{:02d}:{:02d}".format(
|
||||||
reader = csv.reader(csvfile)
|
int(hours), int(minutes), int(seconds)
|
||||||
header = next(reader, None)
|
)
|
||||||
row_number = 1
|
self.total_time_label.configure(text=f"Total Time: {time_str}")
|
||||||
total_hours_loaded = False
|
|
||||||
|
|
||||||
for row in reader:
|
|
||||||
if not row:
|
|
||||||
print(f"Skipping empty row: {row_number}")
|
|
||||||
row_number += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
if len(row) == 5:
|
|
||||||
start_time_str, end_time_str, duration_str, decimal_hours_str, description = row
|
|
||||||
elif len(row) == 4:
|
|
||||||
if row[0] == '' and row[1] == '' and row[2] == '' and row[3] == 'Total Hours':
|
|
||||||
try:
|
|
||||||
self.total_hours = float(row[4])
|
|
||||||
self.update_total_hours()
|
|
||||||
total_hours_loaded = True
|
|
||||||
print(f"Total hours loaded from row {row_number}: {self.total_hours}")
|
|
||||||
row_number += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
except ValueError:
|
|
||||||
print(f"Skipping total hours row {row_number} due to parsing error")
|
|
||||||
row_number += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
start_time_str, end_time_str, duration_str, decimal_hours_str = row
|
|
||||||
description = ""
|
|
||||||
else:
|
|
||||||
print(f"Skipping row with unexpected number of columns ({len(row)}): {row_number}")
|
|
||||||
row_number += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
if not start_time_str or not end_time_str:
|
|
||||||
print(f"Skipping row with missing time data: {row_number}")
|
|
||||||
row_number += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
start_time = datetime.strptime(start_time_str, "%Y-%m-%d %H:%M:%S")
|
|
||||||
end_time = datetime.strptime(end_time_str, "%Y-%m-%d %H:%M:%S")
|
|
||||||
duration = self.parse_duration(duration_str)
|
|
||||||
self.sessions.append((start_time, end_time, duration, description))
|
|
||||||
self.log_message(
|
|
||||||
f"Loaded: {start_time.strftime('%Y-%m-%d %H:%M:%S')} - {end_time.strftime('%Y-%m-%d %H:%M:%S')}, "
|
|
||||||
f"Duration: {duration_str}, Description: {description}"
|
|
||||||
)
|
|
||||||
except ValueError as ve:
|
|
||||||
print(f"Skipping row {row_number} due to parsing error: {ve}")
|
|
||||||
row_number += 1
|
|
||||||
|
|
||||||
if not total_hours_loaded:
|
|
||||||
self.update_total_hours()
|
|
||||||
print("Total hours row was not found so calculating from current rows!")
|
|
||||||
except FileNotFoundError:
|
|
||||||
messagebox.showinfo("Info", "No CSV file found. Starting with a new session.")
|
|
||||||
except Exception as e:
|
|
||||||
messagebox.showerror("Error", f"Failed to load sessions from CSV: {e}")
|
|
||||||
|
|
||||||
def parse_duration(self, duration_str):
|
|
||||||
hours, minutes, seconds = map(int, duration_str.split(':'))
|
|
||||||
return timedelta(hours=hours, minutes=minutes, seconds=seconds)
|
|
||||||
|
|
||||||
def update_total_hours(self):
|
|
||||||
total_duration = timedelta()
|
|
||||||
for start, end, duration, description in self.sessions:
|
|
||||||
total_duration += duration
|
|
||||||
|
|
||||||
self.total_hours = total_duration.total_seconds() / 3600
|
|
||||||
self.total_hours_label.config(text=f"Total Hours Worked: {self.total_hours:.2f}")
|
|
||||||
|
|
||||||
def exit_app(self):
|
def exit_app(self):
|
||||||
self.destroy()
|
self.root.destroy()
|
||||||
|
sys.exit()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
app = TimeLogix()
|
app = TimeLogix()
|
||||||
app.mainloop()
|
app.root.mainloop()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue