feat: TimeTracker polish and production readiness (plan implementation)

Quick wins (Phase A):
- A1: Quick timer actions — last timer context, Repeat last button, Quick start one-click form; pre-fill modal and tags from last entry
- A2: Unified empty states using empty_state macro on custom_view, time_entry_templates, saved_filters, issues; add loading_placeholder macro
- A3: Dashboard hierarchy — Activity and Support/Donate moved to secondary row below fold with reduced visual weight
- A4: Error/feedback consistency (flash-to-toast already in place)

Medium impact (Phase B):
- B5: Split API v1 — api_v1_common.py (shared helpers), api_v1_time_entries.py sub-blueprint for time-entries and timer/*; register api_v1_time_entries_bp
- B6: Start Timer UX — templates as prominent chips at top of modal; default last context and quick start from A1
- B7: Week in review — ReportingService.get_week_in_review(), route /reports/week-in-review, template and link from reports index
- B8: Tags discoverability — GET /api/tags, recent_tags in dashboard, tags input with datalist in Start Timer modal; last context includes tags
- B9: Frontend consolidation — document onboarding.js vs onboarding-enhanced.js in base.html
- B10: API validation — Marshmallow TimeEntryCreateSchema/TimeEntryUpdateSchema and handle_validation_error in api_v1_time_entries create/update

UX: Remove duplicate Timer actions — single Repeat last and Start Timer in header; body shows only Resume when recent entries exist (no duplicate Repeat last or Start new).
This commit is contained in:
Dries Peeters
2026-03-11 08:00:47 +01:00
parent 9f3048f6fb
commit 2ee8da33a0
17 changed files with 802 additions and 762 deletions
+2
View File
@@ -14,6 +14,7 @@ def register_all_blueprints(app, logger=None):
from app.routes.admin import admin_bp
from app.routes.api import api_bp
from app.routes.api_v1 import api_v1_bp
from app.routes.api_v1_time_entries import api_v1_time_entries_bp
from app.routes.api_docs import api_docs_bp, swaggerui_blueprint
from app.routes.analytics import analytics_bp
from app.routes.tasks import tasks_bp
@@ -67,6 +68,7 @@ def register_all_blueprints(app, logger=None):
app.register_blueprint(admin_bp)
app.register_blueprint(api_bp)
app.register_blueprint(api_v1_bp)
app.register_blueprint(api_v1_time_entries_bp)
app.register_blueprint(api_docs_bp)
app.register_blueprint(swaggerui_blueprint)
app.register_blueprint(analytics_bp)
+29
View File
@@ -58,6 +58,35 @@ def timer_status():
)
@api_bp.route("/api/tags")
@login_required
def get_recent_tags():
"""Return distinct tags from current user's time entries for autocomplete (e.g. Start Timer modal)."""
limit = min(request.args.get("limit", 30, type=int), 100)
entries = (
TimeEntry.query.filter(
TimeEntry.user_id == current_user.id,
TimeEntry.tags.isnot(None),
TimeEntry.tags != "",
)
.order_by(TimeEntry.updated_at.desc())
.limit(500)
.all()
)
tags_set = set()
for e in entries:
if e.tags:
for part in e.tags.split(","):
t = part.strip()
if t:
tags_set.add(t)
if len(tags_set) >= limit:
break
if len(tags_set) >= limit:
break
return jsonify({"tags": sorted(tags_set)})
@api_bp.route("/api/search")
@login_required
def search():
+9 -590
View File
@@ -64,97 +64,14 @@ from app.models.time_entry import local_now
api_v1_bp = Blueprint("api_v1", __name__, url_prefix="/api/v1")
# ==================== Helper Functions ====================
def paginate_query(query, page=None, per_page=None):
"""Paginate a SQLAlchemy query"""
page = page or int(request.args.get("page", 1))
per_page = per_page or int(request.args.get("per_page", 50))
per_page = min(per_page, 100) # Max 100 items per page
paginated = query.paginate(page=page, per_page=per_page, error_out=False)
return {
"items": paginated.items,
"pagination": {
"page": paginated.page,
"per_page": paginated.per_page,
"total": paginated.total,
"pages": paginated.pages,
"has_next": paginated.has_next,
"has_prev": paginated.has_prev,
"next_page": paginated.page + 1 if paginated.has_next else None,
"prev_page": paginated.page - 1 if paginated.has_prev else None,
},
}
def parse_datetime(dt_str):
"""Parse datetime string from API request"""
if not dt_str:
return None
try:
# Handle ISO format with timezone
ts = dt_str.strip()
if ts.endswith("Z"):
ts = ts[:-1] + "+00:00"
dt = datetime.fromisoformat(ts)
# Convert to local naive for storage
if dt.tzinfo is not None:
dt = utc_to_local(dt).replace(tzinfo=None)
return dt
except Exception:
return None
def _parse_date(dstr):
"""Parse a YYYY-MM-DD string to date."""
if not dstr:
return None
try:
from datetime import date as _date
return _date.fromisoformat(str(dstr))
except Exception:
return None
def _parse_date_range(start_date_str, end_date_str):
"""Parse start/end date params. Date-only end_date becomes end-of-day."""
start_dt = parse_datetime(start_date_str) if start_date_str else None
end_dt = parse_datetime(end_date_str) if end_date_str else None
# If end_date is date-only (YYYY-MM-DD), treat as end of that day
if end_date_str and end_dt and "T" not in end_date_str.strip() and " " not in end_date_str.strip():
end_dt = end_dt.replace(hour=23, minute=59, second=59, microsecond=999999)
return start_dt, end_dt
def _require_module_enabled_for_api(module_id: str):
"""Return a Flask response tuple if module is disabled for this API user; otherwise None."""
try:
from app.models import Settings
from app.utils.module_registry import ModuleRegistry
settings = Settings.get_settings()
user = getattr(g, "api_user", None)
if not ModuleRegistry.is_enabled(module_id, settings, user):
return (
jsonify(
{
"error": "module_disabled",
"message": f"{module_id} module is disabled by the administrator.",
}
),
403,
)
except Exception:
# If we can't check, default to allow (avoid breaking API during migrations/startup)
return None
return None
# Shared helpers for API v1 (used here and in api_v1_time_entries)
from app.routes.api_v1_common import (
paginate_query,
parse_datetime,
_parse_date,
_parse_date_range,
_require_module_enabled_for_api,
)
# ==================== API Info & Health ====================
@@ -568,505 +485,7 @@ def delete_project(project_id):
return jsonify({"message": "Project archived successfully"})
# ==================== Time Entries ====================
@api_v1_bp.route("/time-entries", methods=["GET"])
@require_api_token("read:time_entries")
def list_time_entries():
"""List time entries
---
tags:
- Time Entries
parameters:
- name: project_id
in: query
type: integer
- name: user_id
in: query
type: integer
- name: start_date
in: query
type: string
format: date
- name: end_date
in: query
type: string
format: date
- name: billable
in: query
type: boolean
- name: page
in: query
type: integer
- name: per_page
in: query
type: integer
security:
- Bearer: []
responses:
200:
description: List of time entries
"""
from app.services import TimeTrackingService
from sqlalchemy.orm import joinedload
# Filter by project
project_id = request.args.get("project_id", type=int)
# Filter by user (non-admin can only see their own)
user_id = request.args.get("user_id", type=int)
if user_id:
if not g.api_user.is_admin and user_id != g.api_user.id:
return jsonify({"error": "Access denied"}), 403
else:
# Default to current user's entries if not admin
if not g.api_user.is_admin:
user_id = g.api_user.id
# Filter by date range
start_date = request.args.get("start_date")
end_date = request.args.get("end_date")
start_dt, end_dt = _parse_date_range(start_date, end_date)
# Filter by billable
billable = request.args.get("billable")
billable_filter = None
if billable is not None:
billable_filter = billable.lower() == "true"
# Only completed entries by default
include_active = request.args.get("include_active") == "true"
page = request.args.get("page", 1, type=int)
per_page = request.args.get("per_page", 50, type=int)
# Use repository with eager loading to avoid N+1 queries
from app.repositories import TimeEntryRepository
time_entry_repo = TimeEntryRepository()
# Build query with eager loading (use model.query for base query)
from app.models import TimeEntry
query = TimeEntry.query.options(
joinedload(TimeEntry.project), joinedload(TimeEntry.user), joinedload(TimeEntry.task)
)
# Apply filters
if project_id:
query = query.filter(TimeEntry.project_id == project_id)
if user_id:
query = query.filter(TimeEntry.user_id == user_id)
if start_dt:
query = query.filter(TimeEntry.start_time >= start_dt)
if end_dt:
query = query.filter(TimeEntry.start_time <= end_dt)
if billable_filter is not None:
query = query.filter(TimeEntry.billable == billable_filter)
if not include_active:
query = query.filter(TimeEntry.end_time.isnot(None))
# Order and paginate
query = query.order_by(TimeEntry.start_time.desc())
result = paginate_query(query, page, per_page)
return jsonify({"time_entries": [e.to_dict() for e in result["items"]], "pagination": result["pagination"]})
@api_v1_bp.route("/time-entries/<int:entry_id>", methods=["GET"])
@require_api_token("read:time_entries")
def get_time_entry(entry_id):
"""Get a specific time entry
---
tags:
- Time Entries
parameters:
- name: entry_id
in: path
type: integer
required: true
security:
- Bearer: []
responses:
200:
description: Time entry details
404:
description: Time entry not found
"""
from sqlalchemy.orm import joinedload
from app.models import TimeEntry
entry = (
TimeEntry.query.options(joinedload(TimeEntry.project), joinedload(TimeEntry.user), joinedload(TimeEntry.task))
.filter_by(id=entry_id)
.first_or_404()
)
# Check permissions
if not g.api_user.is_admin and entry.user_id != g.api_user.id:
return jsonify({"error": "Access denied"}), 403
return jsonify({"time_entry": entry.to_dict()})
@api_v1_bp.route("/time-entries", methods=["POST"])
@require_api_token("write:time_entries")
def create_time_entry():
"""Create a new time entry
---
tags:
- Time Entries
parameters:
- name: body
in: body
required: true
schema:
type: object
required:
- project_id
- start_time
properties:
project_id:
type: integer
task_id:
type: integer
start_time:
type: string
format: date-time
end_time:
type: string
format: date-time
notes:
type: string
tags:
type: string
billable:
type: boolean
security:
- Bearer: []
responses:
201:
description: Time entry created
400:
description: Invalid input
"""
from app.services import TimeTrackingService
data = request.get_json() or {}
# Validate required fields
if not data.get("project_id") and not data.get("client_id"):
return jsonify({"error": "Either project_id or client_id is required"}), 400
if not data.get("start_time"):
return jsonify({"error": "start_time is required"}), 400
# Parse times
start_time = parse_datetime(data["start_time"])
if not start_time:
return jsonify({"error": "Invalid start_time format"}), 400
end_time = None
if data.get("end_time"):
end_time = parse_datetime(data["end_time"])
if end_time and end_time <= start_time:
return jsonify({"error": "end_time must be after start_time"}), 400
# Use service layer to create time entry
time_tracking_service = TimeTrackingService()
result = time_tracking_service.create_manual_entry(
user_id=g.api_user.id,
project_id=data.get("project_id"),
client_id=data.get("client_id"),
start_time=start_time,
end_time=end_time or start_time, # Service requires end_time
task_id=data.get("task_id"),
notes=data.get("notes"),
tags=data.get("tags"),
billable=data.get("billable", True),
paid=data.get("paid", False),
invoice_number=data.get("invoice_number"),
)
if not result.get("success"):
return jsonify({"error": result.get("message", "Could not create time entry")}), 400
entry = result.get("entry")
# Log activity
if entry:
from app.models import Activity
from app.utils.audit import get_request_info
entity_name = entry.project.name if entry.project else (entry.client.name if entry.client else "Unknown")
task_name = entry.task.name if entry.task else None
duration_formatted = entry.duration_formatted if hasattr(entry, 'duration_formatted') else "0:00"
ip_address, user_agent, _ = get_request_info()
Activity.log(
user_id=g.api_user.id,
action="created",
entity_type="time_entry",
entity_id=entry.id,
entity_name=f"{entity_name}" + (f" - {task_name}" if task_name else ""),
description=f'Created time entry for {entity_name}' + (f" - {task_name}" if task_name else "") + f' - {duration_formatted}',
extra_data={
"project_name": entry.project.name if entry.project else None,
"client_name": entry.client.name if entry.client else None,
"task_name": task_name,
"duration_formatted": duration_formatted,
"duration_hours": entry.duration_hours if hasattr(entry, 'duration_hours') else None,
},
ip_address=ip_address,
user_agent=user_agent,
)
return jsonify({"message": "Time entry created successfully", "time_entry": result["entry"].to_dict()}), 201
@api_v1_bp.route("/time-entries/<int:entry_id>", methods=["PUT", "PATCH"])
@require_api_token("write:time_entries")
def update_time_entry(entry_id):
"""Update a time entry
---
tags:
- Time Entries
parameters:
- name: entry_id
in: path
type: integer
required: true
- name: body
in: body
schema:
type: object
security:
- Bearer: []
responses:
200:
description: Time entry updated
404:
description: Time entry not found
"""
from app.services import TimeTrackingService
data = request.get_json() or {}
# Parse times
start_time = None
if "start_time" in data:
start_time = parse_datetime(data["start_time"])
end_time = None
if "end_time" in data:
if data["end_time"] is None:
end_time = None
else:
end_time = parse_datetime(data["end_time"])
# Use service layer to update time entry
time_tracking_service = TimeTrackingService()
result = time_tracking_service.update_entry(
entry_id=entry_id,
user_id=g.api_user.id,
is_admin=g.api_user.is_admin,
project_id=data.get("project_id"),
client_id=data.get("client_id"),
task_id=data.get("task_id"),
start_time=start_time,
end_time=end_time,
notes=data.get("notes"),
tags=data.get("tags"),
billable=data.get("billable"),
paid=data.get("paid"),
invoice_number=data.get("invoice_number"),
reason=data.get("reason"),
)
if not result.get("success"):
return jsonify({"error": result.get("message", "Could not update time entry")}), 400
entry = result.get("entry")
# Log activity
if entry:
from app.models import Activity
from app.utils.audit import get_request_info
entity_name = entry.project.name if entry.project else (entry.client.name if entry.client else "Unknown")
task_name = entry.task.name if entry.task else None
ip_address, user_agent, _ = get_request_info()
Activity.log(
user_id=g.api_user.id,
action="updated",
entity_type="time_entry",
entity_id=entry.id,
entity_name=f"{entity_name}" + (f" - {task_name}" if task_name else ""),
description=f'Updated time entry for {entity_name}' + (f" - {task_name}" if task_name else ""),
extra_data={
"project_name": entry.project.name if entry.project else None,
"client_name": entry.client.name if entry.client else None,
"task_name": task_name,
},
ip_address=ip_address,
user_agent=user_agent,
)
return jsonify({"message": "Time entry updated successfully", "time_entry": result["entry"].to_dict()})
@api_v1_bp.route("/time-entries/<int:entry_id>", methods=["DELETE"])
@require_api_token("write:time_entries")
def delete_time_entry(entry_id):
"""Delete a time entry
---
tags:
- Time Entries
parameters:
- name: entry_id
in: path
type: integer
required: true
security:
- Bearer: []
responses:
200:
description: Time entry deleted
404:
description: Time entry not found
"""
from app.services import TimeTrackingService
data = request.get_json() or {}
reason = data.get("reason") # Optional reason for deletion
time_tracking_service = TimeTrackingService()
result = time_tracking_service.delete_entry(
entry_id=entry_id,
user_id=g.api_user.id,
is_admin=g.api_user.is_admin,
reason=reason,
)
if not result.get("success"):
return jsonify({"error": result.get("message", "Could not delete time entry")}), 400
return jsonify({"message": "Time entry deleted successfully"})
# ==================== Timer Control ====================
@api_v1_bp.route("/timer/status", methods=["GET"])
@require_api_token("read:time_entries")
def timer_status():
"""Get current timer status for authenticated user
---
tags:
- Timer
security:
- Bearer: []
responses:
200:
description: Timer status
"""
active_timer = g.api_user.active_timer
if not active_timer:
return jsonify({"active": False, "timer": None})
return jsonify({"active": True, "timer": active_timer.to_dict()})
@api_v1_bp.route("/timer/start", methods=["POST"])
@require_api_token("write:time_entries")
def start_timer():
"""Start a new timer
---
tags:
- Timer
parameters:
- name: body
in: body
required: true
schema:
type: object
required:
- project_id
properties:
project_id:
type: integer
task_id:
type: integer
security:
- Bearer: []
responses:
201:
description: Timer started
400:
description: Invalid input or timer already running
"""
from app.services import TimeTrackingService
data = request.get_json() or {}
# Validate project_id
project_id = data.get("project_id")
if not project_id:
return jsonify({"error": "project_id is required"}), 400
# Use service layer to start timer
time_tracking_service = TimeTrackingService()
result = time_tracking_service.start_timer(
user_id=g.api_user.id,
project_id=project_id,
task_id=data.get("task_id"),
notes=data.get("notes"),
template_id=data.get("template_id"),
)
if not result.get("success"):
return jsonify({"error": result.get("message", "Could not start timer")}), 400
return jsonify({"message": "Timer started successfully", "timer": result["timer"].to_dict()}), 201
@api_v1_bp.route("/timer/stop", methods=["POST"])
@require_api_token("write:time_entries")
def stop_timer():
"""Stop the active timer
---
tags:
- Timer
security:
- Bearer: []
responses:
200:
description: Timer stopped
400:
description: No active timer
"""
from app.services import TimeTrackingService
# Use same active_timer lookup as timer/status so stop always sees the same state
active_timer = g.api_user.active_timer
if not active_timer:
return jsonify({
"error": "No active timer to stop",
"error_code": "no_active_timer",
}), 400
time_tracking_service = TimeTrackingService()
result = time_tracking_service.stop_timer(user_id=g.api_user.id, entry_id=active_timer.id)
if not result.get("success"):
return jsonify({
"error": result.get("message", "Could not stop timer"),
"error_code": result.get("error", "stop_failed"),
}), 400
return jsonify({"message": "Timer stopped successfully", "time_entry": result["entry"].to_dict()})
# Time entries and timer routes are in api_v1_time_entries.py (api_v1_time_entries_bp)
# ==================== Tasks ====================
+95
View File
@@ -0,0 +1,95 @@
"""
Shared helpers for API v1 routes.
Used by api_v1.py and by domain-specific sub-blueprints (e.g. api_v1_time_entries).
"""
from flask import request, jsonify, g
def paginate_query(query, page=None, per_page=None):
"""Paginate a SQLAlchemy query."""
page = page or int(request.args.get("page", 1))
per_page = per_page or int(request.args.get("per_page", 50))
per_page = min(per_page, 100)
paginated = query.paginate(page=page, per_page=per_page, error_out=False)
return {
"items": paginated.items,
"pagination": {
"page": paginated.page,
"per_page": paginated.per_page,
"total": paginated.total,
"pages": paginated.pages,
"has_next": paginated.has_next,
"has_prev": paginated.has_prev,
"next_page": paginated.page + 1 if paginated.has_next else None,
"prev_page": paginated.page - 1 if paginated.has_prev else None,
},
}
def parse_datetime(dt_str):
"""Parse datetime string from API request."""
if not dt_str:
return None
try:
from app.utils.timezone import utc_to_local
ts = dt_str.strip()
if ts.endswith("Z"):
ts = ts[:-1] + "+00:00"
from datetime import datetime
dt = datetime.fromisoformat(ts)
if dt.tzinfo is not None:
dt = utc_to_local(dt).replace(tzinfo=None)
return dt
except Exception:
return None
def _parse_date(dstr):
"""Parse a YYYY-MM-DD string to date."""
if not dstr:
return None
try:
from datetime import date as _date
return _date.fromisoformat(str(dstr))
except Exception:
return None
def _parse_date_range(start_date_str, end_date_str):
"""Parse start/end date params. Date-only end_date becomes end-of-day."""
start_dt = parse_datetime(start_date_str) if start_date_str else None
end_dt = parse_datetime(end_date_str) if end_date_str else None
if end_date_str and end_dt and "T" not in end_date_str.strip() and " " not in end_date_str.strip():
end_dt = end_dt.replace(hour=23, minute=59, second=59, microsecond=999999)
return start_dt, end_dt
def _require_module_enabled_for_api(module_id: str):
"""Return a Flask response tuple if module is disabled for this API user; otherwise None."""
try:
from app.models import Settings
from app.utils.module_registry import ModuleRegistry
settings = Settings.get_settings()
user = getattr(g, "api_user", None)
if not ModuleRegistry.is_enabled(module_id, settings, user):
return (
jsonify(
{
"error": "module_disabled",
"message": f"{module_id} module is disabled by the administrator.",
}
),
403,
)
except Exception:
pass
return None
+279
View File
@@ -0,0 +1,279 @@
"""
API v1 - Time Entries and Timer endpoints.
Sub-blueprint for /api/v1/time-entries and /api/v1/timer/*.
"""
from flask import Blueprint, jsonify, request, g
from marshmallow import ValidationError
from app.utils.api_auth import require_api_token
from app.utils.api_responses import validation_error_response, handle_validation_error
from app.routes.api_v1_common import paginate_query, parse_datetime, _parse_date_range
from app.schemas.time_entry_schema import TimeEntryCreateSchema, TimeEntryUpdateSchema
api_v1_time_entries_bp = Blueprint("api_v1_time_entries", __name__, url_prefix="/api/v1")
@api_v1_time_entries_bp.route("/time-entries", methods=["GET"])
@require_api_token("read:time_entries")
def list_time_entries():
"""List time entries with filters."""
from sqlalchemy.orm import joinedload
from app.models import TimeEntry
project_id = request.args.get("project_id", type=int)
user_id = request.args.get("user_id", type=int)
if user_id:
if not g.api_user.is_admin and user_id != g.api_user.id:
return jsonify({"error": "Access denied"}), 403
else:
if not g.api_user.is_admin:
user_id = g.api_user.id
start_date = request.args.get("start_date")
end_date = request.args.get("end_date")
start_dt, end_dt = _parse_date_range(start_date, end_date)
billable = request.args.get("billable")
billable_filter = None
if billable is not None:
billable_filter = billable.lower() == "true"
include_active = request.args.get("include_active") == "true"
page = request.args.get("page", 1, type=int)
per_page = request.args.get("per_page", 50, type=int)
query = TimeEntry.query.options(
joinedload(TimeEntry.project), joinedload(TimeEntry.user), joinedload(TimeEntry.task)
)
if project_id:
query = query.filter(TimeEntry.project_id == project_id)
if user_id:
query = query.filter(TimeEntry.user_id == user_id)
if start_dt:
query = query.filter(TimeEntry.start_time >= start_dt)
if end_dt:
query = query.filter(TimeEntry.start_time <= end_dt)
if billable_filter is not None:
query = query.filter(TimeEntry.billable == billable_filter)
if not include_active:
query = query.filter(TimeEntry.end_time.isnot(None))
query = query.order_by(TimeEntry.start_time.desc())
result = paginate_query(query, page, per_page)
return jsonify({"time_entries": [e.to_dict() for e in result["items"]], "pagination": result["pagination"]})
@api_v1_time_entries_bp.route("/time-entries/<int:entry_id>", methods=["GET"])
@require_api_token("read:time_entries")
def get_time_entry(entry_id):
"""Get a specific time entry."""
from sqlalchemy.orm import joinedload
from app.models import TimeEntry
entry = (
TimeEntry.query.options(joinedload(TimeEntry.project), joinedload(TimeEntry.user), joinedload(TimeEntry.task))
.filter_by(id=entry_id)
.first_or_404()
)
if not g.api_user.is_admin and entry.user_id != g.api_user.id:
return jsonify({"error": "Access denied"}), 403
return jsonify({"time_entry": entry.to_dict()})
@api_v1_time_entries_bp.route("/time-entries", methods=["POST"])
@require_api_token("write:time_entries")
def create_time_entry():
"""Create a new time entry."""
from app.services import TimeTrackingService
data = request.get_json() or {}
schema = TimeEntryCreateSchema()
try:
validated = schema.load(data)
except ValidationError as err:
return handle_validation_error(err)
start_time = validated["start_time"]
end_time = validated.get("end_time") or start_time
time_tracking_service = TimeTrackingService()
result = time_tracking_service.create_manual_entry(
user_id=g.api_user.id,
project_id=validated.get("project_id"),
client_id=validated.get("client_id"),
start_time=start_time,
end_time=end_time,
task_id=validated.get("task_id"),
notes=validated.get("notes"),
tags=validated.get("tags"),
billable=validated.get("billable", True),
paid=validated.get("paid", False),
invoice_number=validated.get("invoice_number"),
)
if not result.get("success"):
return jsonify({"error": result.get("message", "Could not create time entry")}), 400
entry = result.get("entry")
if entry:
from app.models import Activity
from app.utils.audit import get_request_info
entity_name = entry.project.name if entry.project else (entry.client.name if entry.client else "Unknown")
task_name = entry.task.name if entry.task else None
duration_formatted = entry.duration_formatted if hasattr(entry, "duration_formatted") else "0:00"
ip_address, user_agent, _ = get_request_info()
Activity.log(
user_id=g.api_user.id,
action="created",
entity_type="time_entry",
entity_id=entry.id,
entity_name=f"{entity_name}" + (f" - {task_name}" if task_name else ""),
description=f"Created time entry for {entity_name}"
+ (f" - {task_name}" if task_name else "")
+ f" - {duration_formatted}",
extra_data={
"project_name": entry.project.name if entry.project else None,
"client_name": entry.client.name if entry.client else None,
"task_name": task_name,
"duration_formatted": duration_formatted,
"duration_hours": entry.duration_hours if hasattr(entry, "duration_hours") else None,
},
ip_address=ip_address,
user_agent=user_agent,
)
return jsonify({"message": "Time entry created successfully", "time_entry": result["entry"].to_dict()}), 201
@api_v1_time_entries_bp.route("/time-entries/<int:entry_id>", methods=["PUT", "PATCH"])
@require_api_token("write:time_entries")
def update_time_entry(entry_id):
"""Update a time entry."""
from app.services import TimeTrackingService
data = request.get_json() or {}
schema = TimeEntryUpdateSchema()
try:
validated = schema.load(data, partial=True)
except ValidationError as err:
return handle_validation_error(err)
time_tracking_service = TimeTrackingService()
result = time_tracking_service.update_entry(
entry_id=entry_id,
user_id=g.api_user.id,
is_admin=g.api_user.is_admin,
project_id=validated.get("project_id"),
client_id=validated.get("client_id"),
task_id=validated.get("task_id"),
start_time=validated.get("start_time"),
end_time=validated.get("end_time"),
notes=validated.get("notes"),
tags=validated.get("tags"),
billable=validated.get("billable"),
paid=validated.get("paid"),
invoice_number=validated.get("invoice_number"),
reason=data.get("reason"),
)
if not result.get("success"):
return jsonify({"error": result.get("message", "Could not update time entry")}), 400
entry = result.get("entry")
if entry:
from app.models import Activity
from app.utils.audit import get_request_info
entity_name = entry.project.name if entry.project else (entry.client.name if entry.client else "Unknown")
task_name = entry.task.name if entry.task else None
ip_address, user_agent, _ = get_request_info()
Activity.log(
user_id=g.api_user.id,
action="updated",
entity_type="time_entry",
entity_id=entry.id,
entity_name=f"{entity_name}" + (f" - {task_name}" if task_name else ""),
description=f"Updated time entry for {entity_name}" + (f" - {task_name}" if task_name else ""),
extra_data={
"project_name": entry.project.name if entry.project else None,
"client_name": entry.client.name if entry.client else None,
"task_name": task_name,
},
ip_address=ip_address,
user_agent=user_agent,
)
return jsonify({"message": "Time entry updated successfully", "time_entry": result["entry"].to_dict()})
@api_v1_time_entries_bp.route("/time-entries/<int:entry_id>", methods=["DELETE"])
@require_api_token("write:time_entries")
def delete_time_entry(entry_id):
"""Delete a time entry."""
from app.services import TimeTrackingService
data = request.get_json() or {}
reason = data.get("reason")
time_tracking_service = TimeTrackingService()
result = time_tracking_service.delete_entry(
entry_id=entry_id,
user_id=g.api_user.id,
is_admin=g.api_user.is_admin,
reason=reason,
)
if not result.get("success"):
return jsonify({"error": result.get("message", "Could not delete time entry")}), 400
return jsonify({"message": "Time entry deleted successfully"})
@api_v1_time_entries_bp.route("/timer/status", methods=["GET"])
@require_api_token("read:time_entries")
def timer_status():
"""Get current timer status."""
active_timer = g.api_user.active_timer
if not active_timer:
return jsonify({"active": False, "timer": None})
return jsonify({"active": True, "timer": active_timer.to_dict()})
@api_v1_time_entries_bp.route("/timer/start", methods=["POST"])
@require_api_token("write:time_entries")
def start_timer():
"""Start a new timer."""
from app.services import TimeTrackingService
data = request.get_json() or {}
project_id = data.get("project_id")
if not project_id:
return jsonify({"error": "project_id is required"}), 400
time_tracking_service = TimeTrackingService()
result = time_tracking_service.start_timer(
user_id=g.api_user.id,
project_id=project_id,
task_id=data.get("task_id"),
notes=data.get("notes"),
template_id=data.get("template_id"),
)
if not result.get("success"):
return jsonify({"error": result.get("message", "Could not start timer")}), 400
return jsonify({"message": "Timer started successfully", "timer": result["timer"].to_dict()}), 201
@api_v1_time_entries_bp.route("/timer/stop", methods=["POST"])
@require_api_token("write:time_entries")
def stop_timer():
"""Stop the active timer."""
from app.services import TimeTrackingService
active_timer = g.api_user.active_timer
if not active_timer:
return jsonify({"error": "No active timer to stop", "error_code": "no_active_timer"}), 400
time_tracking_service = TimeTrackingService()
result = time_tracking_service.stop_timer(user_id=g.api_user.id, entry_id=active_timer.id)
if not result.get("success"):
return jsonify({
"error": result.get("message", "Could not stop timer"),
"error_code": result.get("error", "stop_failed"),
}), 400
return jsonify({"message": "Timer stopped successfully", "time_entry": result["entry"].to_dict()})
+50
View File
@@ -108,6 +108,54 @@ def dashboard():
# Get recent activities for activity feed widget
recent_activities = Activity.get_recent(user_id=None if current_user.is_admin else current_user.id, limit=10)
# Recent tags for Start Timer modal autocomplete (distinct from user's time entries)
recent_tags = []
tag_rows = (
db.session.query(TimeEntry.tags)
.filter(
TimeEntry.user_id == current_user.id,
TimeEntry.tags.isnot(None),
TimeEntry.tags != "",
)
.order_by(TimeEntry.updated_at.desc())
.limit(200)
.all()
)
tags_seen = set()
for (tags_str,) in tag_rows:
if tags_str:
for part in tags_str.split(","):
t = part.strip()
if t and t not in tags_seen:
tags_seen.add(t)
recent_tags.append(t)
if len(recent_tags) >= 30:
break
if len(recent_tags) >= 30:
break
# Last timer context: most recent completed time entry for "Repeat last" / quick start
last_entry = (
TimeEntry.query.filter(
TimeEntry.user_id == current_user.id,
TimeEntry.end_time.isnot(None),
)
.order_by(TimeEntry.end_time.desc())
.limit(1)
.first()
)
last_timer_context = None
if last_entry and (last_entry.project_id or last_entry.client_id):
last_timer_context = {
"project_id": last_entry.project_id,
"task_id": last_entry.task_id,
"client_id": last_entry.client_id,
"notes": (last_entry.notes or "").strip(),
"tags": (last_entry.tags or "").strip(),
"project_name": last_entry.project.name if last_entry.project else None,
"client_name": last_entry.client.name if last_entry.client else None,
}
# Get user stats for smart banner and donation widget
try:
from app.models import DonationInteraction
@@ -147,6 +195,8 @@ def dashboard():
"current_week_goal": current_week_goal,
"templates": templates,
"recent_activities": recent_activities,
"last_timer_context": last_timer_context,
"recent_tags": recent_tags,
"user_stats": user_stats, # For smart banner
"time_entries_count": time_entries_count, # For donation widget
"total_hours": total_hours, # For donation widget
+17
View File
@@ -61,6 +61,23 @@ def reports():
)
@reports_bp.route("/reports/week-in-review")
@login_required
@module_enabled("reports")
def week_in_review():
"""Week in review: this week's hours, top projects, billable vs non-billable."""
from app.services import ReportingService
reporting_service = ReportingService()
data = reporting_service.get_week_in_review(
user_id=current_user.id, is_admin=current_user.is_admin
)
if data.get("error"):
flash(data["error"], "error")
return redirect(url_for("reports.reports"))
return render_template("reports/week_in_review.html", **data)
@reports_bp.route("/reports/comparison")
@login_required
@module_enabled("reports")
+53
View File
@@ -296,3 +296,56 @@ class ReportingService:
"days": (end_date - start_date).days,
},
}
def get_week_in_review(
self, user_id: Optional[int] = None, is_admin: bool = False
) -> Dict[str, Any]:
"""
Get a summary of the current week: total hours, billable vs non-billable, top projects.
Uses the user's week_start_day for "this week" boundaries.
"""
from app.utils.overtime import get_week_start_for_date
from app.models import User
user = User.query.get(user_id) if user_id else None
if not user and user_id:
return {"error": "User not found"}
today = date.today() if hasattr(date, "today") else datetime.utcnow().date()
week_start = get_week_start_for_date(today, user or object())
week_end = week_start + timedelta(days=6)
start_dt = datetime.combine(week_start, datetime.min.time())
end_dt = datetime.combine(week_end, datetime.max.time().replace(microsecond=0))
time_summary = self.get_time_summary(
user_id=user_id, start_date=start_dt, end_date=end_dt, billable_only=False
)
entries = self.time_entry_repo.get_by_date_range(
start_date=start_dt, end_date=end_dt, user_id=user_id, include_relations=True
)
project_hours = {}
for entry in entries:
key = (entry.project_id, entry.project.name if entry.project else "No project")
if entry.project_id is None and entry.client_id:
key = (None, entry.client.name if entry.client else "Direct (client)")
elif entry.project_id is None:
key = (None, "No project")
name = key[1]
if name not in project_hours:
project_hours[name] = {"name": name, "hours": 0.0, "billable_hours": 0.0}
h = (entry.duration_seconds or 0) / 3600
project_hours[name]["hours"] += h
if entry.billable:
project_hours[name]["billable_hours"] += h
top_projects = sorted(project_hours.values(), key=lambda x: x["hours"], reverse=True)[:10]
return {
"total_hours": time_summary["total_hours"],
"billable_hours": time_summary["billable_hours"],
"non_billable_hours": time_summary.get("non_billable_hours", time_summary["total_hours"] - time_summary["billable_hours"]),
"entry_count": time_summary.get("total_entries", len(entries)),
"top_projects": top_projects,
"week_start": week_start.isoformat(),
"week_end": week_end.isoformat(),
}
+1
View File
@@ -1982,6 +1982,7 @@
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<script src="{{ url_for('static', filename='charts.js') }}"></script>
<script src="{{ url_for('static', filename='enhanced-ui.js') }}"></script>
<!-- Onboarding: onboarding.js = core tour runner + restartTour(); onboarding-enhanced.js = enhanced steps/tooltips. Use restartTour() or window.onboardingManager to restart tour. -->
<script src="{{ url_for('static', filename='onboarding.js') }}"></script>
<script src="{{ url_for('static', filename='onboarding-enhanced.js') }}"></script>
<script src="{{ url_for('static', filename='error-handling-enhanced.js') }}"></script>
+11
View File
@@ -202,6 +202,17 @@
</div>
{% endmacro %}
{# Generic loading placeholder for lists/tables: use kind="table" (default) or "spinner" #}
{% macro loading_placeholder(kind="table", rows=5, cols=4, text=None, show_checkbox=False) %}
{% if kind == "table" %}
{{ skeleton_table(rows=rows, cols=cols, show_checkbox=show_checkbox) }}
{% else %}
<div class="py-12">
{{ loading_spinner(size="lg", text=text or _('Loading...')) }}
</div>
{% endif %}
{% endmacro %}
{% macro skeleton_table(rows=5, cols=4, show_checkbox=False) %}
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow-sm overflow-hidden animate-pulse">
<!-- Table Header -->
+9 -2
View File
@@ -1,5 +1,5 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header, stat_card %}
{% from "components/ui.html" import page_header, stat_card, empty_state %}
{% from "components/client_select.html" import client_select %}
{% block content %}
@@ -163,7 +163,14 @@
</div>
{% endif %}
{% else %}
<p class="text-text-muted-light dark:text-text-muted-dark text-center py-8">{{ _('No issues found.') }}</p>
{% set create_issue_action %}{% if current_user.is_admin or has_permission('create_issues') %}<a href="{{ url_for('issues.new_issue') }}" class="btn btn-primary"><i class="fas fa-plus mr-2"></i>{{ _('Create Issue') }}</a>{% endif %}{% endset %}
{{ empty_state(
'fas fa-bug',
_('No issues found'),
_('No issues match your filters. Create an issue or adjust your filters.'),
create_issue_action,
type='no-results'
) }}
{% endif %}
</div>
{% endblock %}
+154 -127
View File
@@ -99,9 +99,27 @@
<h2 class="text-lg font-semibold text-text-light dark:text-text-dark">{{ _('Timer') }}</h2>
</div>
{% if not active_timer %}
<button type="button" class="btn btn-primary js-open-start-timer">
<i class="fas fa-play"></i>{{ _('Start Timer') }}
</button>
<div class="flex flex-wrap items-center gap-2">
{% if last_timer_context %}
<button type="button" class="btn btn-secondary js-repeat-last-timer" title="{{ _('Start timer with same project, task and notes as last entry') }}">
<i class="fas fa-redo"></i>{{ _('Repeat last') }}
</button>
<form method="POST" action="{{ url_for('timer.start_timer') }}" class="inline js-quick-start-last-form" data-confirm-message="{{ _('Start timer with last project and notes?') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="project_id" value="{{ last_timer_context.project_id or '' }}">
<input type="hidden" name="client_id" value="{{ last_timer_context.client_id or '' }}">
<input type="hidden" name="task_id" value="{{ last_timer_context.task_id or '' }}">
<input type="hidden" name="notes" value="{{ last_timer_context.notes or '' }}">
<input type="hidden" name="tags" value="{{ last_timer_context.tags or '' }}">
<button type="submit" class="btn btn-primary btn-sm">
<i class="fas fa-bolt"></i>{{ _('Quick start') }}{% if last_timer_context.project_name %} ({{ last_timer_context.project_name }}){% elif last_timer_context.client_name %} ({{ last_timer_context.client_name }}){% endif %}
</button>
</form>
{% endif %}
<button type="button" class="btn btn-primary js-open-start-timer">
<i class="fas fa-play"></i>{{ _('Start Timer') }}
</button>
</div>
{% endif %}
</div>
{% if active_timer %}
@@ -165,14 +183,11 @@
<div class="text-center py-4">
<p class="text-text-muted-light dark:text-text-muted-dark mb-3">{{ _('No active timer.') }}</p>
{% if recent_entries %}
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mb-3">{{ _('Resume your last session or start a new timer.') }}</p>
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mb-3">{{ _('Resume your last session or use the buttons above to repeat last / start a new timer.') }}</p>
<div class="flex flex-wrap justify-center gap-3">
<a href="{{ url_for('timer.resume_timer', timer_id=recent_entries[0].id) }}" class="inline-flex items-center bg-green-500 hover:bg-green-600 text-white px-5 py-2.5 rounded-lg font-semibold shadow-lg hover:shadow-xl transition-all duration-200 transform hover:-translate-y-0.5">
<i class="fas fa-play mr-2"></i>{{ _('Resume') }}{% if recent_entries[0].project %} ({{ recent_entries[0].project.name }}){% elif recent_entries[0].client %} ({{ recent_entries[0].client.name }}){% endif %}
</a>
<button type="button" class="js-open-start-timer bg-primary hover:bg-primary-dark text-white px-5 py-2.5 rounded-lg font-semibold shadow-lg hover:shadow-xl transition-all duration-200 transform hover:-translate-y-0.5">
<i class="fas fa-plus mr-2"></i>{{ _('Start new') }}
</button>
</div>
{% else %}
<p class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Click "Start Timer" above to begin tracking your time.') }}</p>
@@ -410,113 +425,92 @@
</div>
{% endif %}
</div>
</div>
</div>
<!-- Activity Timeline Widget -->
<div class="bg-card-light dark:bg-card-dark p-6 rounded-xl shadow-lg hover:shadow-xl transition-all duration-300 dashboard-widget">
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-3">
<div class="bg-indigo-500/10 dark:bg-indigo-400/10 p-3 rounded-lg">
<i class="fas fa-stream text-indigo-600 dark:text-indigo-400 text-xl"></i>
</div>
<h2 class="text-lg font-semibold text-text-light dark:text-text-dark">{{ _('Recent Activity') }}</h2>
<!-- Secondary row: Activity and Support (below main focus) -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mt-6">
<!-- Activity Timeline Widget - reduced visual weight -->
<div class="lg:col-span-2 bg-card-light dark:bg-card-dark p-5 rounded-xl border border-border-light dark:border-border-dark shadow-sm dashboard-widget">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-2">
<div class="bg-indigo-500/10 dark:bg-indigo-400/10 p-2 rounded-lg">
<i class="fas fa-stream text-indigo-600 dark:text-indigo-400"></i>
</div>
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400 animate-pulse">
<span class="w-2 h-2 bg-green-600 dark:bg-green-400 rounded-full mr-2"></span>
{{ _('Live') }}
</span>
<h2 class="text-base font-semibold text-text-light dark:text-text-dark">{{ _('Recent Activity') }}</h2>
</div>
<div id="activityTimeline" class="activity-timeline">
{% if recent_activities %}
{% for activity in recent_activities %}
<div class="activity-timeline-item group">
<div class="flex items-start gap-4">
<div class="flex-shrink-0 relative">
<div class="w-10 h-10 rounded-full bg-gradient-to-br from-indigo-500 to-purple-500 flex items-center justify-center shadow-md group-hover:shadow-lg transition-shadow">
<i class="fas fa-circle text-xs text-white"></i>
</div>
{% if not loop.last %}
<div class="absolute left-1/2 top-10 w-0.5 h-6 bg-gradient-to-b from-indigo-300 to-transparent dark:from-indigo-700 dark:to-transparent transform -translate-x-1/2"></div>
{% endif %}
</div>
<div class="flex-1 pb-6 group-last:pb-0">
<p class="text-sm font-medium text-text-light dark:text-text-dark mb-1 group-hover:text-primary dark:group-hover:text-primary transition-colors">{{ activity.description or 'Activity' }}</p>
<p class="text-xs text-text-muted-light dark:text-text-muted-dark flex items-center gap-1">
<i class="fas fa-clock"></i>
{{ activity.created_at|local_datetime_short }}
</p>
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400">
<span class="w-1.5 h-1.5 bg-green-600 dark:bg-green-400 rounded-full mr-1.5"></span>
{{ _('Live') }}
</span>
</div>
<div id="activityTimeline" class="activity-timeline">
{% if recent_activities %}
{% for activity in recent_activities %}
<div class="activity-timeline-item group">
<div class="flex items-start gap-3">
<div class="flex-shrink-0 relative">
<div class="w-8 h-8 rounded-full bg-indigo-500/20 dark:bg-indigo-400/20 flex items-center justify-center">
<i class="fas fa-circle text-xs text-indigo-500 dark:text-indigo-400"></i>
</div>
{% if not loop.last %}
<div class="absolute left-1/2 top-8 w-0.5 h-4 bg-border-light dark:bg-border-dark transform -translate-x-1/2"></div>
{% endif %}
</div>
<div class="flex-1 pb-4 group-last:pb-0">
<p class="text-sm font-medium text-text-light dark:text-text-dark mb-0.5">{{ activity.description or 'Activity' }}</p>
<p class="text-xs text-text-muted-light dark:text-text-muted-dark">
<i class="fas fa-clock mr-1"></i>{{ activity.created_at|local_datetime_short }}
</p>
</div>
</div>
{% endfor %}
{% else %}
<div class="text-center py-12">
<div class="bg-gray-100 dark:bg-gray-800 rounded-full p-6 w-20 h-20 mx-auto mb-4 flex items-center justify-center">
<i class="fas fa-inbox text-3xl text-gray-400"></i>
</div>
<p class="text-text-muted-light dark:text-text-muted-dark font-medium mb-1">{{ _('No recent activity') }}</p>
<p class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Activity will appear here as you use the system.') }}</p>
</div>
{% endif %}
</div>
{% endfor %}
{% else %}
<div class="text-center py-8">
<p class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('No recent activity') }}</p>
</div>
{% endif %}
</div>
</div>
{% if (not settings or not getattr(settings, 'donate_ui_hidden', false)) and current_user.ui_show_donate %}
<!-- Support TimeTracker Widget -->
<div class="bg-gradient-to-br from-amber-500 via-orange-500 to-amber-600 p-6 rounded-xl shadow-lg hover:shadow-xl transition-all duration-300 dashboard-widget animated-card text-white">
<div class="flex items-start justify-between mb-4">
<div class="flex items-center gap-3 mb-3">
<div class="bg-white/20 backdrop-blur-sm p-3 rounded-lg">
<i class="fas fa-mug-saucer text-xl"></i>
</div>
<h2 class="text-lg font-semibold">{{ _('Support TimeTracker') }}</h2>
</div>
{% if (not settings or not getattr(settings, 'donate_ui_hidden', false)) and current_user.ui_show_donate %}
<!-- Support TimeTracker Widget - compact, below fold -->
<div class="bg-card-light dark:bg-card-dark border border-amber-200 dark:border-amber-800 p-5 rounded-xl shadow-sm dashboard-widget">
<div class="flex items-center gap-2 mb-3">
<div class="bg-amber-500/10 dark:bg-amber-400/10 p-2 rounded-lg">
<i class="fas fa-mug-saucer text-amber-600 dark:text-amber-400"></i>
</div>
<div class="mb-4">
<p class="text-sm opacity-90 mb-4">
{{ _('Support updates and new features. One-time key removes prompts for this instance.') }}
</p>
{% if time_entries_count > 0 or total_hours > 0 %}
<div class="bg-white/10 backdrop-blur-sm rounded-lg p-3 border border-white/10 mb-4">
<div class="text-xs opacity-90 space-y-2">
{% if time_entries_count > 0 %}
<div class="flex items-center gap-2">
<i class="fas fa-check-circle"></i>
<span>{{ _('You\'ve tracked %(count)s time entries', count=time_entries_count) }}</span>
</div>
{% endif %}
{% if total_hours > 0 %}
<div class="flex items-center gap-2">
<i class="fas fa-check-circle"></i>
<span>{{ _('You\'ve logged %(hours)s hours', hours="%.1f"|format(total_hours)) }}</span>
</div>
{% endif %}
</div>
</div>
{% endif %}
</div>
<div class="flex gap-2">
<a href="{{ url_for('main.donate') }}"
class="inline-flex items-center justify-center flex-1 bg-white text-amber-600 px-4 py-3 rounded-lg font-semibold hover:bg-amber-50 transition-all duration-200 shadow-md hover:shadow-lg transform hover:-translate-y-0.5">
<i class="fas fa-heart mr-2"></i>
{{ _('Support / Get key') }}
</a>
<a href="https://buymeacoffee.com/DryTrix?utm_source=timetracker&utm_medium=dashboard&utm_campaign=support"
target="_blank"
rel="noopener noreferrer"
onclick="trackDonationClick('dashboard_widget')"
class="inline-flex items-center justify-center flex-1 bg-amber-700 hover:bg-amber-800 text-white px-4 py-3 rounded-lg font-semibold transition-all duration-200 shadow-md hover:shadow-lg transform hover:-translate-y-0.5">
<i class="fas fa-mug-saucer mr-2"></i>
{{ _('Donate') }}
<i class="fas fa-external-link-alt ml-2 text-xs"></i>
</a>
</div>
<p class="mt-3 text-xs opacity-90">
{{ _('Remove prompts with a one-time key.') }}
<a href="{{ support_purchase_url }}" target="_blank" rel="noopener noreferrer" onclick="trackDonationClick('dashboard_widget_key')" class="underline hover:no-underline font-medium">{{ _('Get key') }}</a>
</p>
<h2 class="text-base font-semibold text-text-light dark:text-text-dark">{{ _('Support TimeTracker') }}</h2>
</div>
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mb-3">
{{ _('Support updates and new features. One-time key removes prompts for this instance.') }}
</p>
{% if time_entries_count > 0 or total_hours > 0 %}
<div class="bg-background-light dark:bg-background-dark rounded-lg p-2 mb-3 text-xs text-text-muted-light dark:text-text-muted-dark space-y-1">
{% if time_entries_count > 0 %}
<div><i class="fas fa-check-circle text-green-500 mr-1"></i>{{ _('You\'ve tracked %(count)s time entries', count=time_entries_count) }}</div>
{% endif %}
{% if total_hours > 0 %}
<div><i class="fas fa-check-circle text-green-500 mr-1"></i>{{ _('You\'ve logged %(hours)s hours', hours="%.1f"|format(total_hours)) }}</div>
{% endif %}
</div>
{% endif %}
<div class="flex flex-col sm:flex-row gap-2">
<a href="{{ url_for('main.donate') }}" class="inline-flex items-center justify-center flex-1 bg-amber-500 hover:bg-amber-600 text-white px-3 py-2 rounded-lg font-medium text-sm transition-colors">
<i class="fas fa-heart mr-1.5"></i>{{ _('Support / Get key') }}
</a>
<a href="https://buymeacoffee.com/DryTrix?utm_source=timetracker&utm_medium=dashboard&utm_campaign=support" target="_blank" rel="noopener noreferrer" onclick="trackDonationClick('dashboard_widget')" class="inline-flex items-center justify-center flex-1 border border-amber-500 text-amber-600 dark:text-amber-400 px-3 py-2 rounded-lg font-medium text-sm hover:bg-amber-500/10 transition-colors">
<i class="fas fa-mug-saucer mr-1.5"></i>{{ _('Donate') }} <i class="fas fa-external-link-alt ml-1 text-xs"></i>
</a>
</div>
<p class="mt-2 text-xs text-text-muted-light dark:text-text-muted-dark">
{{ _('Remove prompts with a one-time key.') }}
<a href="{{ support_purchase_url }}" target="_blank" rel="noopener noreferrer" onclick="trackDonationClick('dashboard_widget_key')" class="underline hover:no-underline font-medium text-amber-600 dark:text-amber-400">{{ _('Get key') }}</a>
</p>
</div>
{% endif %}
</div>
<!-- Delete Entry Confirmation Dialogs -->
{% for entry in recent_entries %}
{% if current_user.is_admin or entry.user_id == current_user.id %}
@@ -535,6 +529,7 @@
<div id="startTimerModal"
class="fixed inset-0 z-50 hidden overflow-y-auto"
aria-hidden="true"
data-last-timer-context="{{ (last_timer_context|tojson)|e if last_timer_context else '{}' }}"
data-tasks-api-url="{{ url_for('api.get_project_tasks', project_id=0) }}"
data-create-task-url="{{ url_for('api.create_task_inline') }}"
data-no-task="{{ _('No task')|e }}"
@@ -566,6 +561,24 @@
<form method="POST" action="{{ url_for('timer.start_timer') }}" id="startTimerForm" class="p-4">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="space-y-4">
{% if templates %}
<div class="pb-3 border-b border-border-light dark:border-border-dark">
<label class="form-label">{{ _('Quick start with a template') }}</label>
<div class="flex flex-wrap gap-2 mt-2">
{% for template in templates %}
<button type="button"
onclick="applyTemplate({{ template.id }})"
class="inline-flex items-center gap-2 px-3 py-2 rounded-lg border border-primary/30 bg-primary/5 dark:bg-primary/10 hover:bg-primary/15 dark:hover:bg-primary/20 text-primary font-medium text-sm transition">
<i class="fas fa-bolt text-xs"></i>
{{ template.name }}{% if template.project %} · {{ template.project.name }}{% endif %}
</button>
{% endfor %}
<a href="{{ url_for('time_entry_templates.list_templates') }}" class="inline-flex items-center gap-1 px-3 py-2 text-sm text-text-muted-light dark:text-text-muted-dark hover:text-primary transition">
{{ _('All templates') }} <i class="fas fa-external-link-alt text-xs"></i>
</a>
</div>
</div>
{% endif %}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="startTimerProject" class="form-label">{{ _('Project') }}</label>
@@ -601,35 +614,17 @@
<div id="startTimerNotes_editor"></div>
</div>
</div>
{% if templates %}
<div>
<label class="form-label">{{ _('Or use a template') }}</label>
<div class="space-y-2">
{% for template in templates %}
<button type="button"
onclick="applyTemplate({{ template.id }})"
class="w-full text-left p-3 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition">
<div class="flex items-center justify-between">
<div class="flex-1">
<div class="font-medium text-sm">{{ template.name }}</div>
{% if template.project %}
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">
<i class="fas fa-folder"></i> {{ template.project.name }}
{% if template.task %} → {{ template.task.name }}{% endif %}
</div>
{% endif %}
</div>
<i class="fas fa-chevron-right text-gray-400"></i>
</div>
</button>
<label for="startTimerTags" class="form-label">{{ _('Tags (optional)') }}</label>
<input type="text" id="startTimerTags" name="tags" class="form-input w-full" placeholder="{{ _('e.g. meeting, dev, admin') }}"
list="startTimerTagsList" autocomplete="off">
<datalist id="startTimerTagsList">
{% for tag in recent_tags %}
<option value="{{ tag|e }}"></option>
{% endfor %}
<a href="{{ url_for('time_entry_templates.list_templates') }}"
class="block text-center text-sm text-blue-600 dark:text-blue-400 hover:underline pt-2">
{{ _('View all templates') }} →
</a>
</div>
</datalist>
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('Comma-separated; recent tags shown as suggestions.') }}</p>
</div>
{% endif %}
</div>
</form>
</div>
@@ -952,6 +947,38 @@
const onlyOneClient = (modal?.dataset?.onlyOneClient || 'false') === 'true';
const singleClientId = (modal?.dataset?.singleClientId || '') || null;
// Repeat last: pre-fill modal from last_timer_context and open
document.querySelectorAll('.js-repeat-last-timer').forEach(function(btn) {
btn.addEventListener('click', async function(e) {
e.preventDefault();
if (!modal) return;
var ctxRaw = modal.getAttribute('data-last-timer-context');
var ctx = {};
try { ctx = ctxRaw ? JSON.parse(ctxRaw) : {}; } catch (err) { ctx = {}; }
if (!ctx.project_id && !ctx.client_id) {
openStartTimerModal(e);
return;
}
if (projectSelect) projectSelect.value = ctx.project_id || '';
if (clientSelect) clientSelect.value = ctx.client_id || '';
if (ctx.project_id && typeof loadTasksForProject === 'function') {
await loadTasksForProject(ctx.project_id, ctx.task_id || null);
} else if (taskSelect) {
taskSelect.innerHTML = '<option value="">' + noTaskText + '</option>';
taskSelect.disabled = false;
}
var notesEl = document.getElementById('startTimerNotes');
if (notesEl) notesEl.value = ctx.notes || '';
if (window.dashboardNotesEditor && typeof window.dashboardNotesEditor.setMarkdown === 'function') {
try { window.dashboardNotesEditor.setMarkdown(ctx.notes || ''); } catch (err) {}
}
var tagsEl = document.getElementById('startTimerTags');
if (tagsEl && ctx.tags) tagsEl.value = ctx.tags;
modal.classList.remove('hidden');
modal.setAttribute('aria-hidden', 'false');
});
});
// Task loading: attach when project and task elements exist (independent of client)
if (projectSelect && taskSelect) {
projectSelect.addEventListener('change', () => {
+9 -13
View File
@@ -1,5 +1,5 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header %}
{% from "components/ui.html" import page_header, empty_state %}
{% block title %}{{ saved_view.name }} - {{ _('Custom Report') }} - {{ app_name }}{% endblock %}
@@ -46,18 +46,14 @@
</table>
</div>
{% else %}
<div class="text-center py-12">
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-gray-100 dark:bg-gray-800 mb-4">
<i class="fas fa-inbox text-3xl text-gray-400"></i>
</div>
<h3 class="text-xl font-medium text-gray-900 dark:text-white mb-2">{{ _('No data found') }}</h3>
<p class="text-gray-600 dark:text-gray-400 mb-6">
{{ _('This report has no data matching the current filters. Try adjusting your date range or filters.') }}
</p>
<a href="{{ url_for('custom_reports.report_builder') }}" class="btn btn-primary">
<i class="fas fa-edit mr-2"></i>{{ _('Edit Report') }}
</a>
</div>
{% set edit_report_action %}<a href="{{ url_for('custom_reports.report_builder') }}" class="btn btn-primary"><i class="fas fa-edit mr-2"></i>{{ _('Edit Report') }}</a>{% endset %}
{{ empty_state(
'fas fa-inbox',
_('No data found'),
_('This report has no data matching the current filters. Try adjusting your date range or filters.'),
edit_report_action,
type='no-results'
) }}
{% endif %}
</div>
{% elif component == 'summary' %}
+9
View File
@@ -94,6 +94,15 @@
<div>
<h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark mb-3">{{ _('Comparison View') }}</h3>
<div class="space-y-3">
<a href="{{ url_for('reports.week_in_review') }}" class="block w-full px-4 py-3 rounded-lg text-left bg-background-light dark:bg-background-dark hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors no-underline">
<div class="flex items-center justify-between">
<div>
<div class="font-medium text-text-light dark:text-text-dark">{{ _('Week in Review') }}</div>
<div class="text-xs text-text-muted-light dark:text-text-muted-dark">{{ _('This week\'s hours, top projects, billable vs non-billable') }}</div>
</div>
<i class="fas fa-chevron-right text-text-muted-light dark:text-text-muted-dark"></i>
</div>
</a>
<button type="button" onclick="showComparisonView('month')" class="w-full px-4 py-3 rounded-lg text-left bg-background-light dark:bg-background-dark hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
<div class="flex items-center justify-between">
<div>
+57
View File
@@ -0,0 +1,57 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header, breadcrumb_nav %}
{% block content %}
{% set breadcrumbs = [
{'text': _('Reports'), 'url': url_for('reports.reports')},
{'text': _('Week in Review')}
] %}
{{ page_header(
icon_class='fas fa-calendar-week',
title_text=_('Week in Review'),
subtitle_text=_('This week\'s time summary and top projects'),
breadcrumbs=breadcrumbs,
actions_html=None
) }}
<p class="mb-4"><a href="{{ url_for('reports.reports') }}" class="btn btn-secondary">{{ _('Back to Reports') }}</a></p>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-xl border border-border-light dark:border-border-dark shadow-sm mb-6">
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mb-4">
{{ _('Week') }} {{ week_start }} {{ week_end }}
</p>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6">
<div class="p-4 rounded-lg bg-primary/10 dark:bg-primary/20">
<div class="text-2xl font-bold text-primary">{{ "%.1f"|format(total_hours) }}h</div>
<div class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Total hours') }}</div>
</div>
<div class="p-4 rounded-lg bg-green-500/10 dark:bg-green-500/20">
<div class="text-2xl font-bold text-green-600 dark:text-green-400">{{ "%.1f"|format(billable_hours) }}h</div>
<div class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Billable') }}</div>
</div>
<div class="p-4 rounded-lg bg-gray-500/10 dark:bg-gray-500/20">
<div class="text-2xl font-bold text-gray-600 dark:text-gray-400">{{ "%.1f"|format(non_billable_hours) }}h</div>
<div class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Non-billable') }}</div>
</div>
</div>
<p class="text-sm text-text-muted-light dark:text-text-muted-dark">
{{ _('%(count)s time entries logged this week.', count=entry_count) }}
</p>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-xl border border-border-light dark:border-border-dark shadow-sm">
<h2 class="text-lg font-semibold text-text-light dark:text-text-dark mb-4">{{ _('Top projects this week') }}</h2>
{% if top_projects %}
<ul class="space-y-3">
{% for item in top_projects %}
<li class="flex items-center justify-between py-2 border-b border-border-light dark:border-border-dark last:border-0">
<span class="font-medium text-text-light dark:text-text-dark">{{ item.name }}</span>
<span class="text-primary font-semibold">{{ "%.1f"|format(item.hours) }}h</span>
</li>
{% endfor %}
</ul>
{% else %}
<p class="text-text-muted-light dark:text-text-muted-dark">{{ _('No time logged this week yet.') }}</p>
{% endif %}
</div>
{% endblock %}
+9 -15
View File
@@ -1,5 +1,5 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header, breadcrumb_nav, button, filter_badge %}
{% from "components/ui.html" import page_header, breadcrumb_nav, button, filter_badge, empty_state %}
{% block title %}Saved Filters - {{ config.APP_NAME }}{% endblock %}
@@ -90,20 +90,14 @@
</div>
{% endfor %}
{% else %}
<!-- Empty State -->
<div class="text-center py-12">
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-gray-100 dark:bg-gray-800 mb-4">
<i class="fas fa-filter text-3xl text-gray-400"></i>
</div>
<h3 class="text-xl font-medium text-gray-900 dark:text-white mb-2">No saved filters yet</h3>
<p class="text-gray-600 dark:text-gray-400 mb-6">
Save filters from Reports or Tasks pages for quick access
</p>
<a href="{{ url_for('reports.reports') }}"
class="btn btn-primary">
<i class="fas fa-chart-bar mr-2"></i> Go to Reports
</a>
</div>
{% set go_reports_action %}<a href="{{ url_for('reports.reports') }}" class="btn btn-primary"><i class="fas fa-chart-bar mr-2"></i>{{ _('Go to Reports') }}</a>{% endset %}
{{ empty_state(
'fas fa-filter',
_('No saved filters yet'),
_('Save filters from Reports or Tasks pages for quick access.'),
go_reports_action,
type='no-data'
) }}
{% endif %}
</div>
+9 -15
View File
@@ -1,5 +1,5 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header, breadcrumb_nav, button, filter_badge %}
{% from "components/ui.html" import page_header, breadcrumb_nav, button, filter_badge, empty_state %}
{% block title %}Time Entry Templates - {{ config.APP_NAME }}{% endblock %}
@@ -108,20 +108,14 @@
{% endfor %}
</div>
{% else %}
<!-- Empty State -->
<div class="text-center py-12">
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-gray-100 dark:bg-gray-800 mb-4">
<i class="fas fa-file-alt text-3xl text-gray-400"></i>
</div>
<h3 class="text-xl font-medium text-gray-900 dark:text-white mb-2">No templates yet</h3>
<p class="text-gray-600 dark:text-gray-400 mb-6">
Create your first time entry template to speed up your workflow
</p>
<a href="{{ url_for('time_entry_templates.create_template') }}"
class="btn btn-primary">
<i class="fas fa-plus mr-2"></i> Create Your First Template
</a>
</div>
{% set create_action %}<a href="{{ url_for('time_entry_templates.create_template') }}" class="btn btn-primary"><i class="fas fa-plus mr-2"></i>{{ _('Create Your First Template') }}</a>{% endset %}
{{ empty_state(
'fas fa-file-alt',
_('No templates yet'),
_('Create your first time entry template to speed up your workflow.'),
create_action,
type='no-data'
) }}
{% endif %}
</div>