mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-19 04:40:32 -05:00
@@ -8,11 +8,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
- **API integration test for project tasks** — `tests/test_api_comprehensive.py` now matches `GET /api/projects/<id>/tasks`, which returns **all** tasks (including done and cancelled) for the time-entry UI.
|
||||
- **Quote create returned HTTP 500 after save (#583)** — The quote was saved, but the redirect to the quote detail page crashed when **Valid until** was set: the template compared `valid_until` to `now()`, and `now` was never defined in the Jinja context. The expired badge now uses `Quote.is_expired` (same rule, app timezone). Regression coverage in `tests/test_routes/test_quotes_web.py` posts `valid_until` so the view path is exercised.
|
||||
- **Desktop app navigation guard** — `will-navigate` no longer mis-classifies `file:` loads (opaque `"null"` origin) as external navigation. Allowed in-app protocols include `file:`, `about:`, and `devtools:`; `http:` / `https:` are still blocked from the embedded window.
|
||||
- **Desktop offline UI (bundle)** — Shared helpers load before dependent modules; timesheet period and time-off request lists expose **Delete** where allowed (with `currentUserProfile.id` for ownership); approve/reject controls read approval state from `state.currentUserProfile`; API client includes `deleteTimesheetPeriod` and `deleteTimeOffRequest`.
|
||||
|
||||
### Added
|
||||
- **Smart in-app notifications** — Opt-in under **Settings → Notifications → In-app reminders**: nudge when no time is logged today (configurable hour window, user timezone), alert when an active timer exceeds a configurable duration, and end-of-day summary of hours logged. Server-driven via `GET /api/notifications` and `POST /api/notifications/dismiss`; per-day dismissals stored in `user_smart_notification_dismissals`. Environment defaults: `SMART_NOTIFY_MAX_PER_DAY`, `SMART_NOTIFY_NO_TRACKING_AFTER`, `SMART_NOTIFY_SUMMARY_AT`, `SMART_NOTIFY_LONG_TIMER_HOURS`, `SMART_NOTIFY_SCHEDULER_SLOT_MINUTES` (see `app/config.py` and [docs/features/SMART_NOTIFICATIONS.md](docs/features/SMART_NOTIFICATIONS.md)). Migration `150_add_smart_notifications`. The dashboard client polls the API and shows toasts (optional browser notifications when enabled and permission granted). `toastManager.show` supports an optional `onDismiss` callback.
|
||||
- **Value dashboard widget** — Dashboard productivity block backed by `StatsService` and `GET /api/stats/value-dashboard` (short-TTL Redis cache when available). Wired from `dashboard-enhancements.js` with the existing real-time dashboard refresh.
|
||||
- **Quote line item reorder (Issue #584)** — Non-null `quote_items.position` (migration `146_add_quote_item_position`); `Quote.items` is ordered by `position`, then `id`. Create, edit, duplicate, bulk duplicate, API item payloads, and quote-template apply assign positions from the submitted row order. **Create quote** and **edit quote** forms include per-row **Move up** / **Move down** controls on **Quote line items**, **Costs**, and **Extra goods** so rows can be reordered without deleting and re-entering data; PDFs and detail views follow the saved order. New translatable UI strings: **Order**, **Move up**, **Move down** (run `pybabel extract` / `update` per [docs/CONTRIBUTING_TRANSLATIONS.md](docs/CONTRIBUTING_TRANSLATIONS.md)).
|
||||
- **Offline queue replay** — Queued requests now store method, headers, and body in a replay-safe form (serializable for localStorage). POST/PUT requests replayed when back online send the same body and method. Legacy queue items (with `options` only) are still replayed via fallback.
|
||||
- **Inventory API scopes** — New scopes `read:inventory` and `write:inventory` for inventory-only API access. Existing `read:projects` and `write:projects` still grant the same inventory access for backward compatibility.
|
||||
@@ -21,6 +24,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- **Crowdin integration (maintainers)** — Root [`crowdin.yml`](crowdin.yml) maps `translations/en/LC_MESSAGES/messages.po` to per-locale `messages.po` paths (with `nb` → `no` for Norwegian). Manual [`.github/workflows/crowdin-sync.yml`](.github/workflows/crowdin-sync.yml) uploads sources and downloads translations when `CROWDIN_PROJECT_ID` and `CROWDIN_PERSONAL_TOKEN` are set. [docs/CONTRIBUTING_TRANSLATIONS.md](docs/CONTRIBUTING_TRANSLATIONS.md) includes a Crowdin setup section; [docs/TRANSLATION_SYSTEM.md](docs/TRANSLATION_SYSTEM.md) and contributor docs cross-link it.
|
||||
|
||||
### Changed
|
||||
- **Documentation (API)** — Documented session-auth `GET /api/stats/value-dashboard` (response fields, Redis TTL, rate resolution) in [`docs/api/REST_API.md`](docs/api/REST_API.md) and linked dashboard session JSON from [`docs/API.md`](docs/API.md).
|
||||
- **API v1 search scoping** — Project, task, and client branches of token search use shared `apply_project_scope` and `apply_client_scope` query helpers in [`app/utils/scope_filter.py`](app/utils/scope_filter.py) for consistent subcontractor restrictions.
|
||||
- **Documentation (translations)** — Added [docs/CONTRIBUTING_TRANSLATIONS.md](docs/CONTRIBUTING_TRANSLATIONS.md) for contributors without Git (issue template, optional spreadsheet or hosted platform, maintainer workflow). Root [CONTRIBUTING.md](CONTRIBUTING.md) links to it; [docs/TRANSLATION_SYSTEM.md](docs/TRANSLATION_SYSTEM.md) defers the enabled locale list to `app/config.py` (`LANGUAGES`) and points translators at the new guide.
|
||||
- **Factur-X / PDF/A-3 invoice PDFs (export and email)** — Download and email attachments use the same embed-and-normalize path. Embedded CII uses Associated File relationship **Data** and MIME **text/xml**. PDF/A-3 normalization embeds sRGB via `app/resources/icc/` (override with `INVOICE_SRGB_ICC_PATH`). Added `app/utils/invoice_pdf_postprocess.py` and tests; [PEPPOL e-Invoicing](docs/admin/configuration/PEPPOL_EINVOICING.md) updated (veraPDF note, pytest command).
|
||||
- **Documentation sync** — CODEBASE_AUDIT.md: marked gaps 2.3–2.7 and 2.9 as fixed; added “Implemented 2026-03-16” summary. CLIENT_FEATURES_IMPLEMENTATION_STATUS: report date range and CSV export noted as implemented. INCOMPLETE_IMPLEMENTATIONS_ANALYSIS: added “Verified 2026-03-16” for webhook verification, issues permissions, search API, offline queue.
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ Thank you for your interest in contributing to TimeTracker. This page gives you
|
||||
## How to Contribute
|
||||
|
||||
- **Report bugs** — Use the [GitHub issue tracker](https://github.com/drytrix/TimeTracker/issues). Include steps to reproduce, expected vs actual behavior, and your environment (OS, deployment method, version).
|
||||
- **Improve translations (no Git)** — Use the **Translation improvement** issue template, or read [docs/CONTRIBUTING_TRANSLATIONS.md](docs/CONTRIBUTING_TRANSLATIONS.md) for spreadsheet, maintainer workflow, and optional Crowdin setup ([`crowdin.yml`](crowdin.yml), **Actions → Crowdin sync**).
|
||||
- **Improve translations (no Git)** — Use the **Translation improvement** issue template, translate on **[Crowdin (Drytrix TimeTracker)](https://crowdin.com/project/drytrix-timetracker)**, or read [docs/CONTRIBUTING_TRANSLATIONS.md](docs/CONTRIBUTING_TRANSLATIONS.md) for spreadsheet, maintainer workflow, and Crowdin setup ([`crowdin.yml`](crowdin.yml), **Actions → Crowdin sync**).
|
||||
- **Suggest features** — Open a [feature request](https://github.com/drytrix/TimeTracker/issues/new?template=feature_request.md) with a clear description and use case.
|
||||
- **Submit code** — Fork the repo, create a branch, make your changes, add tests, and open a pull request. Follow the [full contributing guidelines](docs/development/CONTRIBUTING.md) for setup, coding standards, and PR process.
|
||||
|
||||
|
||||
@@ -801,11 +801,12 @@ TimeTracker includes **optional** analytics and monitoring features to help impr
|
||||
#### 4. **Product Analytics** (Optional - Grafana OTLP)
|
||||
- Tracks feature usage and user behavior patterns with advanced features:
|
||||
- **Person Properties**: Role, auth method, login history
|
||||
- **Feature Flags**: Gradual rollouts, A/B testing, kill switches
|
||||
- **Group Analytics**: Segment by version, platform, deployment
|
||||
- **Rich Context**: Browser, device, environment on every event
|
||||
- **Sink config:** Set `GRAFANA_OTLP_ENDPOINT` and `GRAFANA_OTLP_TOKEN`
|
||||
|
||||
**Rollouts and kill switches** in this application are not driven by remote PostHog feature flags. Use **environment variables** and [`app/config.py`](app/config.py) (for example `DEMO_MODE`, `ALLOW_SELF_REGISTER`, `ENABLE_TELEMETRY`, `SINGLE_ACTIVE_TIMER`). **Per-user UI visibility** preferences are stored on the user record in the database, not in PostHog.
|
||||
|
||||
#### 5. **Installation Telemetry** (Optional, Anonymous)
|
||||
- Sends anonymous installation data via Grafana OTLP with:
|
||||
- Anonymized fingerprint (SHA-256 hash, cannot be reversed)
|
||||
|
||||
@@ -551,6 +551,12 @@ def create_app(config=None):
|
||||
if request.path.startswith("/static/") or request.path.startswith("/_"):
|
||||
return
|
||||
|
||||
# API discovery and mobile login must stay JSON (not HTML redirect) during install
|
||||
if request.path.startswith("/api/v1/info") or request.path.startswith("/api/v1/health"):
|
||||
return
|
||||
if request.path == "/api/v1/auth/login" and request.method == "POST":
|
||||
return
|
||||
|
||||
# Check if setup is complete
|
||||
from app.utils.installation import get_installation_config
|
||||
|
||||
|
||||
@@ -4,8 +4,14 @@ Extracted from app/__init__.py to reduce bootstrap module size and clarify struc
|
||||
"""
|
||||
|
||||
|
||||
def _is_dev_fail_fast(app):
|
||||
"""Re-raise optional blueprint failures only in local development (not testing/production)."""
|
||||
return bool(app.debug or app.config.get("FLASK_ENV") == "development")
|
||||
|
||||
|
||||
def register_all_blueprints(app, logger=None):
|
||||
"""Import and register all route blueprints. Optional blueprints are wrapped in try/except."""
|
||||
"""Import and register all route blueprints. Optional blueprints log failures; dev may re-raise."""
|
||||
_log = logger or app.logger
|
||||
from app.routes.admin import admin_bp
|
||||
from app.routes.analytics import analytics_bp
|
||||
from app.routes.api import api_bp
|
||||
@@ -67,9 +73,19 @@ def register_all_blueprints(app, logger=None):
|
||||
from app.routes.audit_logs import audit_logs_bp
|
||||
|
||||
app.register_blueprint(audit_logs_bp)
|
||||
except Exception as e:
|
||||
if logger:
|
||||
logger.warning("Could not register audit_logs blueprint: %s", e)
|
||||
except ImportError:
|
||||
_log.warning(
|
||||
"Could not register audit_logs blueprint (optional module missing)",
|
||||
exc_info=True,
|
||||
extra={"event": "blueprint_register_skipped", "blueprint": "audit_logs"},
|
||||
)
|
||||
except (AttributeError, RuntimeError):
|
||||
_log.exception(
|
||||
"Could not register audit_logs blueprint",
|
||||
extra={"event": "blueprint_register_failed", "blueprint": "audit_logs"},
|
||||
)
|
||||
if _is_dev_fail_fast(app):
|
||||
raise
|
||||
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(main_bp)
|
||||
@@ -129,11 +145,12 @@ def register_all_blueprints(app, logger=None):
|
||||
app.register_blueprint(custom_reports_bp)
|
||||
app.register_blueprint(salesman_reports_bp)
|
||||
|
||||
_register_optional_blueprints(app, logger)
|
||||
_register_optional_blueprints(app, _log)
|
||||
|
||||
|
||||
def _register_optional_blueprints(app, logger=None):
|
||||
"""Register optional/feature blueprints that may be missing in minimal installs."""
|
||||
_log = logger or app.logger
|
||||
optional = [
|
||||
("app.routes.project_templates", "project_templates_bp"),
|
||||
("app.routes.invoice_approvals", "invoice_approvals_bp"),
|
||||
@@ -155,6 +172,14 @@ def _register_optional_blueprints(app, logger=None):
|
||||
mod = __import__(module_path, fromlist=[attr])
|
||||
bp = getattr(mod, attr)
|
||||
app.register_blueprint(bp)
|
||||
except Exception as e:
|
||||
if logger:
|
||||
logger.warning("Could not register %s blueprint: %s", module_path.split(".")[-1], e)
|
||||
except Exception:
|
||||
_log.exception(
|
||||
"Could not register optional blueprint",
|
||||
extra={
|
||||
"event": "optional_blueprint_register_failed",
|
||||
"blueprint_module": module_path,
|
||||
"blueprint_attr": attr,
|
||||
},
|
||||
)
|
||||
if _is_dev_fail_fast(app):
|
||||
raise
|
||||
|
||||
@@ -134,6 +134,9 @@ class Config:
|
||||
|
||||
# Support & Purchase Key page URL (for links to purchase a key to hide donate UI)
|
||||
SUPPORT_PURCHASE_URL = os.getenv("SUPPORT_PURCHASE_URL", "https://timetracker.drytrix.com/support.html").strip()
|
||||
SUPPORT_PORTAL_BASE = os.getenv("SUPPORT_PORTAL_BASE", "https://timetracker.drytrix.com").strip()
|
||||
# Optional one-line social proof for support modal (empty = omit block)
|
||||
SUPPORT_SOCIAL_PROOF_TEXT = os.getenv("SUPPORT_SOCIAL_PROOF_TEXT", "").strip()
|
||||
|
||||
# Backup settings
|
||||
BACKUP_RETENTION_DAYS = int(os.getenv("BACKUP_RETENTION_DAYS", 30))
|
||||
@@ -227,6 +230,22 @@ class Config:
|
||||
github_run_number = os.getenv("GITHUB_RUN_NUMBER")
|
||||
APP_VERSION = f"dev-{github_run_number}" if github_run_number else "3.1.0"
|
||||
|
||||
# GitHub release check (admin update notification). GITHUB_RELEASES_TOKEN is optional; never log it.
|
||||
VERSION_CHECK_GITHUB_REPO = os.getenv("VERSION_CHECK_GITHUB_REPO", "DRYTRIX/TimeTracker").strip()
|
||||
VERSION_CHECK_GITHUB_CACHE_TTL = int(os.getenv("VERSION_CHECK_GITHUB_CACHE_TTL", "43200")) # 12h
|
||||
VERSION_CHECK_GITHUB_STALE_TTL = int(os.getenv("VERSION_CHECK_GITHUB_STALE_TTL", "604800")) # 7d
|
||||
VERSION_CHECK_HTTP_TIMEOUT = int(os.getenv("VERSION_CHECK_HTTP_TIMEOUT", "10"))
|
||||
GITHUB_RELEASES_TOKEN = os.getenv("GITHUB_RELEASES_TOKEN", "").strip() or None
|
||||
ENABLE_PRE_RELEASE_NOTIFICATIONS = os.getenv("ENABLE_PRE_RELEASE_NOTIFICATIONS", "false").lower() == "true"
|
||||
|
||||
# Smart in-app notifications (GET /api/notifications); times are HH:MM 24h in user's timezone.
|
||||
SMART_NOTIFY_MAX_PER_DAY = max(1, min(10, int(os.getenv("SMART_NOTIFY_MAX_PER_DAY", "2"))))
|
||||
SMART_NOTIFY_NO_TRACKING_AFTER = os.getenv("SMART_NOTIFY_NO_TRACKING_AFTER", "16:00").strip()
|
||||
SMART_NOTIFY_SUMMARY_AT = os.getenv("SMART_NOTIFY_SUMMARY_AT", "18:00").strip()
|
||||
SMART_NOTIFY_LONG_TIMER_HOURS = float(os.getenv("SMART_NOTIFY_LONG_TIMER_HOURS", "4"))
|
||||
# Fire time-based kinds only during the first N minutes of the configured hour (same idea as email remind-to-log).
|
||||
SMART_NOTIFY_SCHEDULER_SLOT_MINUTES = max(1, min(59, int(os.getenv("SMART_NOTIFY_SCHEDULER_SLOT_MINUTES", "30"))))
|
||||
|
||||
|
||||
class DevelopmentConfig(Config):
|
||||
"""Development configuration"""
|
||||
|
||||
@@ -38,6 +38,8 @@ def get_version_from_setup():
|
||||
This function reads setup.py at runtime to get the current version.
|
||||
All other code should reference this function, not define versions themselves.
|
||||
|
||||
Override at runtime with TIMETRACKER_VERSION or APP_VERSION (e.g. CI/containers).
|
||||
|
||||
This function tries multiple paths to find setup.py to work correctly
|
||||
in both production and development modes.
|
||||
|
||||
@@ -47,6 +49,10 @@ def get_version_from_setup():
|
||||
import os
|
||||
import re
|
||||
|
||||
env_version = (os.environ.get("TIMETRACKER_VERSION") or os.environ.get("APP_VERSION") or "").strip()
|
||||
if env_version:
|
||||
return env_version
|
||||
|
||||
# Try multiple possible paths to setup.py
|
||||
possible_paths = []
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Non-translated support/checkout configuration (URLs, numeric defaults)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any, Dict
|
||||
|
||||
|
||||
def get_support_portal_base(config: Dict[str, Any] | Any) -> str:
|
||||
"""Marketing site base; defaults to drytrix TimeTracker domain."""
|
||||
if hasattr(config, "get"):
|
||||
raw = (config.get("SUPPORT_PORTAL_BASE") or "").strip()
|
||||
else:
|
||||
raw = ""
|
||||
if not raw:
|
||||
raw = os.getenv("SUPPORT_PORTAL_BASE", "https://timetracker.drytrix.com").strip()
|
||||
return raw.rstrip("/")
|
||||
|
||||
|
||||
def build_support_checkout_urls(config: Dict[str, Any] | Any) -> Dict[str, str]:
|
||||
"""
|
||||
Per-tier outbound URLs. Unset env vars fall back to SUPPORT_PURCHASE_URL so checkout stays one hop.
|
||||
"""
|
||||
if hasattr(config, "get"):
|
||||
purchase = (config.get("SUPPORT_PURCHASE_URL") or "").strip()
|
||||
else:
|
||||
purchase = ""
|
||||
if not purchase:
|
||||
purchase = os.getenv(
|
||||
"SUPPORT_PURCHASE_URL", "https://timetracker.drytrix.com/support.html"
|
||||
).strip()
|
||||
|
||||
def _tier(env_name: str) -> str:
|
||||
v = os.getenv(env_name, "").strip()
|
||||
return v or purchase
|
||||
|
||||
return {
|
||||
"eur5": _tier("SUPPORT_DONATE_EUR5_URL"),
|
||||
"eur10": _tier("SUPPORT_DONATE_EUR10_URL"),
|
||||
"eur25": _tier("SUPPORT_DONATE_EUR25_URL"),
|
||||
"license": purchase,
|
||||
}
|
||||
|
||||
|
||||
def get_long_session_minutes() -> int:
|
||||
try:
|
||||
return max(30, int(os.getenv("SUPPORT_LONG_SESSION_MINUTES", "120")))
|
||||
except ValueError:
|
||||
return 120
|
||||
|
||||
|
||||
def get_social_proof_text(config: Dict[str, Any] | Any) -> str:
|
||||
if hasattr(config, "get"):
|
||||
t = (config.get("SUPPORT_SOCIAL_PROOF_TEXT") or "").strip()
|
||||
else:
|
||||
t = ""
|
||||
if not t:
|
||||
t = os.getenv("SUPPORT_SOCIAL_PROOF_TEXT", "").strip()
|
||||
return t
|
||||
@@ -90,6 +90,7 @@ from .time_off import CompanyHoliday, LeaveType, TimeOffRequest, TimeOffRequestS
|
||||
from .timesheet_period import TimesheetPeriod, TimesheetPeriodStatus
|
||||
from .timesheet_policy import TimesheetPolicy
|
||||
from .user import User
|
||||
from .user_smart_notification_dismissal import UserSmartNotificationDismissal
|
||||
from .user_client import UserClient
|
||||
from .user_favorite_project import UserFavoriteProject
|
||||
from .warehouse import Warehouse
|
||||
@@ -100,6 +101,7 @@ from .workflow import WorkflowExecution, WorkflowRule
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
"UserSmartNotificationDismissal",
|
||||
"Project",
|
||||
"TimeEntry",
|
||||
"Task",
|
||||
|
||||
@@ -59,6 +59,7 @@ class Client(db.Model):
|
||||
company=None,
|
||||
prepaid_hours_monthly=None,
|
||||
prepaid_reset_day=1,
|
||||
custom_fields=None,
|
||||
):
|
||||
"""Create a Client.
|
||||
|
||||
@@ -80,6 +81,7 @@ class Client(db.Model):
|
||||
self.prepaid_reset_day = max(1, min(28, reset_day))
|
||||
except (TypeError, ValueError):
|
||||
self.prepaid_reset_day = 1
|
||||
self.custom_fields = custom_fields
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Client {self.name}>"
|
||||
|
||||
@@ -5,6 +5,8 @@ Canonical interaction_type values (funnel):
|
||||
- banner_impression: support banner was shown (measured client-side)
|
||||
- banner_dismissed: user dismissed the banner
|
||||
- link_clicked: user clicked a support CTA (donate or key; segment by source)
|
||||
- support_modal_opened, support_donation_clicked, support_license_clicked: support modal funnel
|
||||
- support_prompt_shown, support_prompt_dismissed: soft prompt funnel
|
||||
|
||||
Canonical source values for CTR per placement:
|
||||
- header, banner, banner_bmc, banner_paypal, banner_key
|
||||
@@ -76,17 +78,15 @@ class DonationInteraction(db.Model):
|
||||
|
||||
@staticmethod
|
||||
def has_recent_donation_click(user_id: int, days: int = 30) -> bool:
|
||||
"""Check if user clicked donation link in last N days"""
|
||||
"""Check if user clicked a donation/support outbound link in last N days."""
|
||||
cutoff = datetime.utcnow() - timedelta(days=days)
|
||||
interaction_types = ("link_clicked", "banner_clicked")
|
||||
return (
|
||||
DonationInteraction.query.filter_by(user_id=user_id, interaction_type="banner_clicked")
|
||||
.filter(DonationInteraction.created_at >= cutoff)
|
||||
.first()
|
||||
is not None
|
||||
) or (
|
||||
DonationInteraction.query.filter_by(user_id=user_id, interaction_type="link_clicked")
|
||||
.filter(DonationInteraction.created_at >= cutoff)
|
||||
.first()
|
||||
DonationInteraction.query.filter(
|
||||
DonationInteraction.user_id == user_id,
|
||||
DonationInteraction.interaction_type.in_(interaction_types),
|
||||
DonationInteraction.created_at >= cutoff,
|
||||
).first()
|
||||
is not None
|
||||
)
|
||||
|
||||
|
||||
+24
-7
@@ -23,6 +23,8 @@ class User(UserMixin, db.Model):
|
||||
is_active = db.Column(db.Boolean, default=True, nullable=False)
|
||||
theme_preference = db.Column(db.String(10), default=None, nullable=True) # 'light' | 'dark' | None=system
|
||||
preferred_language = db.Column(db.String(8), default=None, nullable=True) # e.g., 'en', 'de'
|
||||
# Admin update popup: normalized semver of last "don't show again" GitHub release
|
||||
dismissed_release_version = db.Column(db.String(64), nullable=True)
|
||||
oidc_sub = db.Column(db.String(255), nullable=True)
|
||||
oidc_issuer = db.Column(db.String(255), nullable=True)
|
||||
avatar_filename = db.Column(db.String(255), nullable=True)
|
||||
@@ -43,6 +45,14 @@ class User(UserMixin, db.Model):
|
||||
reminder_to_log_time = db.Column(
|
||||
db.String(5), nullable=True
|
||||
) # Time of day "HH:MM" (24h) for reminder, e.g. "17:00"
|
||||
# In-app smart notifications (separate from email remind-to-log)
|
||||
smart_notifications_enabled = db.Column(db.Boolean, default=False, nullable=False)
|
||||
smart_notify_no_tracking = db.Column(db.Boolean, default=True, nullable=False)
|
||||
smart_notify_long_timer = db.Column(db.Boolean, default=True, nullable=False)
|
||||
smart_notify_daily_summary = db.Column(db.Boolean, default=True, nullable=False)
|
||||
smart_notify_browser = db.Column(db.Boolean, default=False, nullable=False)
|
||||
smart_notify_no_tracking_after = db.Column(db.String(5), nullable=True) # HH:MM override; null = use app config
|
||||
smart_notify_summary_at = db.Column(db.String(5), nullable=True) # HH:MM override; null = use app config
|
||||
timezone = db.Column(db.String(50), nullable=True) # User-specific timezone override
|
||||
date_format = db.Column(db.String(20), default=None, nullable=True) # None = use system default
|
||||
time_format = db.Column(db.String(10), default=None, nullable=True) # None = use system default
|
||||
@@ -142,6 +152,9 @@ class User(UserMixin, db.Model):
|
||||
ui_show_kiosk = db.Column(db.Boolean, default=True, nullable=False) # Show/hide Kiosk Mode
|
||||
ui_show_donate = db.Column(db.Boolean, default=True, nullable=False) # Show/hide donate/support UI
|
||||
|
||||
# Support UX: count of report generations (exports + custom report views) for stats in support modal
|
||||
support_stats_reports_generated = db.Column(db.Integer, default=0, nullable=False)
|
||||
|
||||
# Relationships
|
||||
time_entries = db.relationship("TimeEntry", backref="user", lazy="dynamic", cascade="all, delete-orphan")
|
||||
project_costs = db.relationship("ProjectCost", backref="user", lazy="dynamic", cascade="all, delete-orphan")
|
||||
@@ -470,16 +483,25 @@ class User(UserMixin, db.Model):
|
||||
"""True if user is restricted to assigned clients (e.g. subcontractor role)."""
|
||||
return "subcontractor" in self.get_role_names()
|
||||
|
||||
@property
|
||||
def is_client_portal_user(self):
|
||||
"""Check if user has client portal access enabled"""
|
||||
return self.client_portal_enabled and self.client_id is not None
|
||||
|
||||
def get_allowed_client_ids(self):
|
||||
"""Return list of client IDs this user may access, or None for full access."""
|
||||
if self.is_admin or not self.is_scope_restricted:
|
||||
if self.is_admin:
|
||||
return None
|
||||
if self.is_client_portal_user:
|
||||
return [self.client_id] if self.client_id else []
|
||||
if not self.is_scope_restricted:
|
||||
return None
|
||||
ids = [c.id for c in self.assigned_clients.all()]
|
||||
return ids if ids else []
|
||||
|
||||
def get_allowed_project_ids(self):
|
||||
"""Return list of project IDs this user may access, or None for full access."""
|
||||
if self.is_admin or not self.is_scope_restricted:
|
||||
if self.is_admin:
|
||||
return None
|
||||
from .project import Project
|
||||
|
||||
@@ -492,11 +514,6 @@ class User(UserMixin, db.Model):
|
||||
return [r[0] for r in rows]
|
||||
|
||||
# Client portal helpers
|
||||
@property
|
||||
def is_client_portal_user(self):
|
||||
"""Check if user has client portal access enabled"""
|
||||
return self.client_portal_enabled and self.client_id is not None
|
||||
|
||||
def get_client_portal_data(self):
|
||||
"""Get data for client portal view (projects, invoices, time entries for assigned client)"""
|
||||
if not self.is_client_portal_user:
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Per-user dismissals for smart in-app notifications (by local calendar date and kind)."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from app import db
|
||||
|
||||
|
||||
class UserSmartNotificationDismissal(db.Model):
|
||||
__tablename__ = "user_smart_notification_dismissals"
|
||||
__table_args__ = (db.UniqueConstraint("user_id", "local_date", "kind", name="uq_user_smart_notif_dismissal"),)
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
local_date = db.Column(db.String(10), nullable=False) # YYYY-MM-DD in user's timezone
|
||||
kind = db.Column(db.String(32), nullable=False)
|
||||
dismissed_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
+169
-213
@@ -7,6 +7,7 @@ from flask import Blueprint, current_app, jsonify, make_response, request, send_
|
||||
from flask_babel import gettext as _
|
||||
from flask_login import current_user, login_required
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
from app import db, socketio
|
||||
@@ -23,20 +24,82 @@ from app.models import (
|
||||
User,
|
||||
)
|
||||
from app.models.time_entry import local_now
|
||||
from app.services.global_search_service import run_global_search
|
||||
from app.services.time_tracking_service import TimeTrackingService
|
||||
from app.utils.api_deprecation import deprecated_session_api
|
||||
from app.utils.db import safe_commit
|
||||
from app.utils.scope_filter import apply_client_scope, apply_project_scope, user_can_access_project
|
||||
from app.utils.timezone import convert_app_datetime_to_user, parse_local_datetime, utc_to_local
|
||||
|
||||
api_bp = Blueprint("api", __name__)
|
||||
|
||||
|
||||
@api_bp.route("/api/health")
|
||||
@deprecated_session_api("/api/v1/health")
|
||||
def health_check():
|
||||
"""Health check endpoint for monitoring and error handling"""
|
||||
return jsonify({"status": "ok", "timestamp": datetime.utcnow().isoformat()})
|
||||
|
||||
|
||||
def _effective_user_for_version_api():
|
||||
"""Session user, or API token user (Bearer / X-API-Key). Used for version check routes."""
|
||||
if getattr(current_user, "is_authenticated", False):
|
||||
return current_user
|
||||
from app.utils.api_auth import authenticate_token, extract_token_from_request
|
||||
|
||||
token = extract_token_from_request()
|
||||
if not token:
|
||||
return None
|
||||
user, _api_token, _err = authenticate_token(token, record_usage=False)
|
||||
return user
|
||||
|
||||
|
||||
@api_bp.route("/api/version/check")
|
||||
def api_version_check():
|
||||
"""Admin only: compare installed version to latest GitHub release (cached)."""
|
||||
user = _effective_user_for_version_api()
|
||||
if user is None:
|
||||
return jsonify({"error": "unauthorized", "message": "Authentication required"}), 401
|
||||
if not user.is_admin:
|
||||
return jsonify({"error": "forbidden", "message": "Admin only"}), 403
|
||||
from app.services.version_service import VersionService
|
||||
|
||||
return jsonify(VersionService.build_check_response(user))
|
||||
|
||||
|
||||
@api_bp.route("/api/version/dismiss", methods=["POST"])
|
||||
def api_version_dismiss():
|
||||
"""Admin only: remember not to show update popup for this normalized release version."""
|
||||
user = _effective_user_for_version_api()
|
||||
if user is None:
|
||||
return jsonify({"error": "unauthorized", "message": "Authentication required"}), 401
|
||||
if not user.is_admin:
|
||||
return jsonify({"error": "forbidden", "message": "Admin only"}), 403
|
||||
data = request.get_json(silent=True) or {}
|
||||
raw = data.get("latest_version")
|
||||
if not isinstance(raw, str) or not raw.strip():
|
||||
return jsonify({"error": "latest_version is required"}), 400
|
||||
|
||||
from app.utils.version_compare import normalize_version_tag
|
||||
|
||||
norm = normalize_version_tag(raw)
|
||||
if not norm:
|
||||
current_app.logger.warning(
|
||||
"Version dismiss: invalid latest_version from admin user_id=%s: %r",
|
||||
user.id,
|
||||
raw,
|
||||
)
|
||||
return jsonify({"error": "invalid latest_version"}), 400
|
||||
user.dismissed_release_version = norm
|
||||
db.session.add(user)
|
||||
if not safe_commit():
|
||||
return jsonify({"error": "save_failed"}), 500
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@api_bp.route("/api/timer/status")
|
||||
@login_required
|
||||
@deprecated_session_api("/api/v1/timer/status")
|
||||
def timer_status():
|
||||
"""Get current timer status"""
|
||||
active_timer = current_user.active_timer
|
||||
@@ -91,8 +154,9 @@ def get_recent_tags():
|
||||
|
||||
@api_bp.route("/api/search")
|
||||
@login_required
|
||||
@deprecated_session_api("/api/v1/search")
|
||||
def search():
|
||||
"""Global search endpoint for projects, tasks, clients, and time entries
|
||||
"""Global search endpoint for projects, tasks, clients, and time entries.
|
||||
|
||||
Query Parameters:
|
||||
q (str): Search query (minimum 2 characters)
|
||||
@@ -100,152 +164,32 @@ def search():
|
||||
types (str): Comma-separated list of types to search (project, task, client, entry)
|
||||
|
||||
Returns:
|
||||
JSON object with search results array
|
||||
JSON with ``results``, ``query``, ``count``, ``partial`` (true if any search domain failed),
|
||||
and ``errors`` (map of domain key to message: ``projects``, ``tasks``, ``clients``, ``entries``).
|
||||
Domains that hit a database error are omitted from ``results``; other domains still return hits.
|
||||
"""
|
||||
query = request.args.get("q", "").strip()
|
||||
limit = min(request.args.get("limit", 10, type=int), 50) # Cap at 50
|
||||
types_filter = request.args.get("types", "").strip().lower()
|
||||
|
||||
if not query or len(query) < 2:
|
||||
return jsonify({"results": [], "query": query})
|
||||
return jsonify({"results": [], "query": query, "count": 0, "partial": False, "errors": {}})
|
||||
|
||||
# Parse types filter
|
||||
allowed_types = {"project", "task", "client", "entry"}
|
||||
if types_filter:
|
||||
requested_types = {t.strip() for t in types_filter.split(",") if t.strip()}
|
||||
search_types = requested_types.intersection(allowed_types)
|
||||
else:
|
||||
search_types = allowed_types
|
||||
|
||||
results = []
|
||||
search_pattern = f"%{query}%"
|
||||
|
||||
# Search projects
|
||||
if "project" in search_types:
|
||||
try:
|
||||
projects = (
|
||||
Project.query.filter(
|
||||
Project.status == "active",
|
||||
or_(Project.name.ilike(search_pattern), Project.description.ilike(search_pattern)),
|
||||
)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
for project in projects:
|
||||
results.append(
|
||||
{
|
||||
"type": "project",
|
||||
"category": "project",
|
||||
"id": project.id,
|
||||
"title": project.name,
|
||||
"description": project.description or "",
|
||||
"url": f"/projects/{project.id}",
|
||||
"badge": "Project",
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error searching projects: {e}")
|
||||
|
||||
# Search tasks
|
||||
if "task" in search_types:
|
||||
try:
|
||||
tasks = (
|
||||
Task.query.join(Project)
|
||||
.filter(
|
||||
Project.status == "active",
|
||||
or_(Task.name.ilike(search_pattern), Task.description.ilike(search_pattern)),
|
||||
)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
for task in tasks:
|
||||
results.append(
|
||||
{
|
||||
"type": "task",
|
||||
"category": "task",
|
||||
"id": task.id,
|
||||
"title": task.name,
|
||||
"description": f"{task.project.name if task.project else 'No Project'}",
|
||||
"url": f"/tasks/{task.id}",
|
||||
"badge": task.status.replace("_", " ").title() if task.status else "Task",
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error searching tasks: {e}")
|
||||
|
||||
# Search clients
|
||||
if "client" in search_types:
|
||||
try:
|
||||
clients = (
|
||||
Client.query.filter(
|
||||
or_(
|
||||
Client.name.ilike(search_pattern),
|
||||
Client.email.ilike(search_pattern),
|
||||
Client.company.ilike(search_pattern),
|
||||
)
|
||||
)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
for client in clients:
|
||||
results.append(
|
||||
{
|
||||
"type": "client",
|
||||
"category": "client",
|
||||
"id": client.id,
|
||||
"title": client.name,
|
||||
"description": client.company or client.email or "",
|
||||
"url": f"/clients/{client.id}",
|
||||
"badge": "Client",
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error searching clients: {e}")
|
||||
|
||||
# Search time entries (notes and tags)
|
||||
if "entry" in search_types:
|
||||
try:
|
||||
entries = (
|
||||
TimeEntry.query.filter(
|
||||
TimeEntry.user_id == current_user.id,
|
||||
TimeEntry.end_time.isnot(None),
|
||||
or_(TimeEntry.notes.ilike(search_pattern), TimeEntry.tags.ilike(search_pattern)),
|
||||
)
|
||||
.order_by(TimeEntry.start_time.desc())
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
for entry in entries:
|
||||
title_parts = []
|
||||
if entry.project:
|
||||
title_parts.append(entry.project.name)
|
||||
if entry.task:
|
||||
title_parts.append(f"• {entry.task.name}")
|
||||
title = " ".join(title_parts) if title_parts else "Time Entry"
|
||||
|
||||
description = entry.notes[:100] if entry.notes else ""
|
||||
if entry.tags:
|
||||
description += f" [{entry.tags}]"
|
||||
|
||||
results.append(
|
||||
{
|
||||
"type": "entry",
|
||||
"category": "entry",
|
||||
"id": entry.id,
|
||||
"title": title,
|
||||
"description": description,
|
||||
"url": f"/timer/edit/{entry.id}",
|
||||
"badge": entry.duration_formatted,
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error searching time entries: {e}")
|
||||
|
||||
return jsonify({"results": results, "query": query, "count": len(results)})
|
||||
results, errors = run_global_search(
|
||||
current_user,
|
||||
query,
|
||||
limit=limit,
|
||||
types_filter=types_filter,
|
||||
)
|
||||
return jsonify(
|
||||
{
|
||||
"results": results,
|
||||
"query": query,
|
||||
"count": len(results),
|
||||
"partial": bool(errors),
|
||||
"errors": errors,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@api_bp.route("/api/deadlines/upcoming")
|
||||
@@ -290,6 +234,7 @@ def upcoming_deadlines():
|
||||
|
||||
@api_bp.route("/api/tasks")
|
||||
@login_required
|
||||
@deprecated_session_api("/api/v1/tasks")
|
||||
def list_tasks_for_project():
|
||||
"""List tasks for a given project (optionally filter by status)."""
|
||||
project_id = request.args.get("project_id", type=int)
|
||||
@@ -315,11 +260,9 @@ def list_tasks_for_project():
|
||||
|
||||
@api_bp.route("/api/timer/start", methods=["POST"])
|
||||
@login_required
|
||||
@deprecated_session_api("/api/v1/timer/start")
|
||||
def api_start_timer():
|
||||
"""Start timer via API"""
|
||||
from app.models import Settings
|
||||
from app.utils.time_entry_validation import validate_time_entry_requirements
|
||||
|
||||
data = request.get_json() or {}
|
||||
project_id = data.get("project_id")
|
||||
task_id = data.get("task_id")
|
||||
@@ -328,70 +271,48 @@ def api_start_timer():
|
||||
if not project_id:
|
||||
return jsonify({"error": "Project ID is required"}), 400
|
||||
|
||||
# Check if project exists and is active
|
||||
project = Project.query.filter_by(id=project_id, status="active").first()
|
||||
if not project:
|
||||
return jsonify({"error": "Invalid project"}), 400
|
||||
|
||||
from app.utils.scope_filter import user_can_access_project
|
||||
|
||||
if not user_can_access_project(current_user, project_id):
|
||||
return jsonify({"error": "You do not have access to this project"}), 403
|
||||
|
||||
# Validate task if provided
|
||||
task = None
|
||||
if task_id:
|
||||
task = Task.query.filter_by(id=task_id, project_id=project_id).first()
|
||||
if not task:
|
||||
return jsonify({"error": "Invalid task for selected project"}), 400
|
||||
|
||||
# Validate time entry requirements (task, description)
|
||||
settings = Settings.get_settings()
|
||||
err = validate_time_entry_requirements(
|
||||
settings, project_id=project_id, client_id=None, task_id=task_id, notes=notes
|
||||
)
|
||||
if err:
|
||||
return jsonify({"error": err["message"]}), 400
|
||||
|
||||
# Check if user already has an active timer
|
||||
active_timer = current_user.active_timer
|
||||
if active_timer:
|
||||
return jsonify({"error": "User already has an active timer"}), 400
|
||||
|
||||
# Create new timer
|
||||
from app.models.time_entry import local_now
|
||||
|
||||
new_timer = TimeEntry(
|
||||
service = TimeTrackingService()
|
||||
result = service.start_timer(
|
||||
user_id=current_user.id,
|
||||
project_id=project_id,
|
||||
task_id=task.id if task else None,
|
||||
start_time=local_now(),
|
||||
task_id=task_id,
|
||||
notes=notes,
|
||||
source="auto",
|
||||
template_id=None,
|
||||
)
|
||||
if not result.get("success"):
|
||||
return jsonify({"error": result.get("message", "Could not start timer")}), 400
|
||||
|
||||
db.session.add(new_timer)
|
||||
db.session.commit()
|
||||
new_timer = result["timer"]
|
||||
project = new_timer.project or Project.query.get(project_id)
|
||||
tid = new_timer.task_id
|
||||
|
||||
# Emit WebSocket event
|
||||
socketio.emit(
|
||||
"timer_started",
|
||||
{
|
||||
"user_id": current_user.id,
|
||||
"timer_id": new_timer.id,
|
||||
"project_name": project.name,
|
||||
"task_id": task.id if task else None,
|
||||
"project_name": project.name if project else "",
|
||||
"task_id": tid,
|
||||
"start_time": new_timer.start_time.isoformat(),
|
||||
},
|
||||
)
|
||||
|
||||
return jsonify(
|
||||
{"success": True, "timer_id": new_timer.id, "project_name": project.name, "task_id": task.id if task else None}
|
||||
{
|
||||
"success": True,
|
||||
"timer_id": new_timer.id,
|
||||
"project_name": project.name if project else "",
|
||||
"task_id": tid,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@api_bp.route("/api/timer/stop", methods=["POST"])
|
||||
@login_required
|
||||
@deprecated_session_api("/api/v1/timer/stop")
|
||||
def api_stop_timer():
|
||||
"""Stop timer via API"""
|
||||
active_timer = current_user.active_timer
|
||||
@@ -399,23 +320,27 @@ def api_stop_timer():
|
||||
if not active_timer:
|
||||
return jsonify({"error": "No active timer to stop"}), 400
|
||||
|
||||
# Stop the timer
|
||||
active_timer.stop_timer()
|
||||
service = TimeTrackingService()
|
||||
result = service.stop_timer(user_id=current_user.id, entry_id=active_timer.id)
|
||||
if not result.get("success"):
|
||||
return jsonify({"error": result.get("message", "Could not stop timer")}), 400
|
||||
|
||||
entry = result["entry"]
|
||||
|
||||
# Emit WebSocket event
|
||||
socketio.emit(
|
||||
"timer_stopped",
|
||||
{"user_id": current_user.id, "timer_id": active_timer.id, "duration": active_timer.duration_formatted},
|
||||
{"user_id": current_user.id, "timer_id": entry.id, "duration": entry.duration_formatted},
|
||||
)
|
||||
|
||||
return jsonify(
|
||||
{"success": True, "duration": active_timer.duration_formatted, "duration_hours": active_timer.duration_hours}
|
||||
{"success": True, "duration": entry.duration_formatted, "duration_hours": entry.duration_hours}
|
||||
)
|
||||
|
||||
|
||||
# --- Idle control: stop at specific time ---
|
||||
@api_bp.route("/api/timer/stop_at", methods=["POST"])
|
||||
@login_required
|
||||
@deprecated_session_api("/api/v1/timer/stop")
|
||||
def api_stop_timer_at():
|
||||
"""Stop the active timer at a specific timestamp (idle adjustment)."""
|
||||
active_timer = current_user.active_timer
|
||||
@@ -467,6 +392,7 @@ def api_stop_timer_at():
|
||||
# --- Resume last timer/project ---
|
||||
@api_bp.route("/api/timer/resume", methods=["POST"])
|
||||
@login_required
|
||||
@deprecated_session_api("/api/v1/timer/start")
|
||||
def api_resume_timer():
|
||||
"""Resume timer for last used project/task or provided project/task."""
|
||||
if current_user.active_timer:
|
||||
@@ -498,12 +424,18 @@ def api_resume_timer():
|
||||
if not task:
|
||||
return jsonify({"error": "Invalid task for selected project"}), 400
|
||||
|
||||
# Create new timer
|
||||
new_timer = TimeEntry(
|
||||
user_id=current_user.id, project_id=project_id, task_id=task_id, start_time=local_now(), source="auto"
|
||||
service = TimeTrackingService()
|
||||
result = service.start_timer(
|
||||
user_id=current_user.id,
|
||||
project_id=project_id,
|
||||
task_id=task_id,
|
||||
notes=None,
|
||||
template_id=None,
|
||||
)
|
||||
db.session.add(new_timer)
|
||||
db.session.commit()
|
||||
if not result.get("success"):
|
||||
return jsonify({"error": result.get("message", "Could not start timer")}), 400
|
||||
|
||||
new_timer = result["timer"]
|
||||
|
||||
socketio.emit(
|
||||
"timer_started",
|
||||
@@ -521,6 +453,7 @@ def api_resume_timer():
|
||||
|
||||
@api_bp.route("/api/entries")
|
||||
@login_required
|
||||
@deprecated_session_api("/api/v1/time-entries")
|
||||
def get_entries():
|
||||
"""Get time entries with pagination"""
|
||||
page = request.args.get("page", 1, type=int)
|
||||
@@ -843,10 +776,10 @@ def delete_saved_filter(filter_id):
|
||||
|
||||
@api_bp.route("/api/entries", methods=["POST"])
|
||||
@login_required
|
||||
@deprecated_session_api("/api/v1/time-entries")
|
||||
def create_entry():
|
||||
"""Create a finished time entry (used by calendar drag-create)."""
|
||||
from app.models import Client
|
||||
from app.services import TimeTrackingService
|
||||
|
||||
data = request.get_json() or {}
|
||||
project_id = data.get("project_id")
|
||||
@@ -945,6 +878,7 @@ def create_entry():
|
||||
|
||||
@api_bp.route("/api/entries/bulk", methods=["POST"])
|
||||
@login_required
|
||||
@deprecated_session_api("/api/v1/time-entries/bulk")
|
||||
def bulk_entries_action():
|
||||
"""Perform bulk actions on time entries: delete, set billable, set paid, add/remove tag."""
|
||||
from app.services.time_entry_bulk_service import apply_bulk_time_entry_actions
|
||||
@@ -1256,6 +1190,7 @@ def calendar_export():
|
||||
|
||||
@api_bp.route("/api/projects")
|
||||
@login_required
|
||||
@deprecated_session_api("/api/v1/projects")
|
||||
def get_projects():
|
||||
"""Get active projects"""
|
||||
projects = Project.query.filter_by(status="active").order_by(Project.name).all()
|
||||
@@ -1264,6 +1199,7 @@ def get_projects():
|
||||
|
||||
@api_bp.route("/api/projects/<int:project_id>/tasks")
|
||||
@login_required
|
||||
@deprecated_session_api("/api/v1/tasks")
|
||||
def get_project_tasks(project_id):
|
||||
"""Get tasks for a specific project"""
|
||||
# Check if project exists and is active
|
||||
@@ -1412,6 +1348,7 @@ def create_task_inline():
|
||||
# Fetch a single time entry (details for edit modal)
|
||||
@api_bp.route("/api/entry/<int:entry_id>", methods=["GET"])
|
||||
@login_required
|
||||
@deprecated_session_api("/api/v1/time-entries")
|
||||
def get_entry(entry_id):
|
||||
entry = TimeEntry.query.get_or_404(entry_id)
|
||||
if entry.user_id != current_user.id and not current_user.is_admin:
|
||||
@@ -1423,6 +1360,7 @@ def get_entry(entry_id):
|
||||
|
||||
@api_bp.route("/api/users")
|
||||
@login_required
|
||||
@deprecated_session_api("/api/v1/users")
|
||||
def get_users():
|
||||
"""Get active users (admin only). Uses a single aggregate query for total_hours to avoid N+1."""
|
||||
if not current_user.is_admin:
|
||||
@@ -1490,8 +1428,18 @@ def get_stats():
|
||||
)
|
||||
|
||||
|
||||
@api_bp.route("/api/stats/value-dashboard")
|
||||
@login_required
|
||||
def value_dashboard_stats():
|
||||
"""Productivity/value aggregates for the dashboard widget (short-TTL Redis cache)."""
|
||||
from app.services.stats_service import StatsService
|
||||
|
||||
return jsonify(StatsService.get_value_dashboard(current_user))
|
||||
|
||||
|
||||
@api_bp.route("/api/entry/<int:entry_id>", methods=["PUT"])
|
||||
@login_required
|
||||
@deprecated_session_api("/api/v1/time-entries")
|
||||
def update_entry(entry_id):
|
||||
"""Update a time entry"""
|
||||
entry = TimeEntry.query.get_or_404(entry_id)
|
||||
@@ -1525,8 +1473,6 @@ def update_entry(entry_id):
|
||||
return None
|
||||
|
||||
# Use service layer for update to get enhanced audit logging
|
||||
from app.services import TimeTrackingService
|
||||
|
||||
service = TimeTrackingService()
|
||||
|
||||
# Convert data to service parameters
|
||||
@@ -1598,8 +1544,6 @@ def delete_entry(entry_id):
|
||||
reason = data.get("reason") # Optional reason for deletion
|
||||
|
||||
# Use service layer for deletion to get enhanced audit logging
|
||||
from app.services import TimeTrackingService
|
||||
|
||||
service = TimeTrackingService()
|
||||
|
||||
result = service.delete_entry(
|
||||
@@ -1723,6 +1667,7 @@ def serve_editor_image(filename):
|
||||
|
||||
@api_bp.route("/api/activities")
|
||||
@login_required
|
||||
@deprecated_session_api("/api/v1/activities")
|
||||
def get_activities():
|
||||
"""Get recent activities with filtering"""
|
||||
from sqlalchemy import and_
|
||||
@@ -1880,28 +1825,39 @@ def dashboard_sparklines():
|
||||
@api_bp.route("/api/summary/today")
|
||||
@login_required
|
||||
def summary_today():
|
||||
"""Get today's time tracking summary for daily summary notification"""
|
||||
from datetime import datetime, timedelta
|
||||
"""Get today's time tracking summary (user's local calendar day, not UTC midnight)."""
|
||||
from app.services.notification_service import get_today_summary_for_user
|
||||
|
||||
from sqlalchemy import distinct, func
|
||||
payload = get_today_summary_for_user(current_user)
|
||||
return jsonify(payload)
|
||||
|
||||
from app.models import Project, TimeEntry
|
||||
|
||||
today = datetime.utcnow().date()
|
||||
@api_bp.route("/api/notifications")
|
||||
@login_required
|
||||
def api_smart_notifications():
|
||||
"""Smart in-app notification candidates (respects preferences, dismissals, caps)."""
|
||||
from app.services.notification_service import NotificationService
|
||||
|
||||
# Get today's time entries for current user
|
||||
entries = TimeEntry.query.filter(
|
||||
TimeEntry.user_id == current_user.id, func.date(TimeEntry.start_time) == today, TimeEntry.end_time.isnot(None)
|
||||
).all()
|
||||
return jsonify(NotificationService.build_for_user(current_user))
|
||||
|
||||
# Calculate total hours
|
||||
total_hours = sum((entry.duration_hours or 0) for entry in entries)
|
||||
|
||||
# Count unique projects
|
||||
project_ids = set(entry.project_id for entry in entries if entry.project_id)
|
||||
project_count = len(project_ids)
|
||||
@api_bp.route("/api/notifications/dismiss", methods=["POST"])
|
||||
@login_required
|
||||
def api_smart_notifications_dismiss():
|
||||
"""Record dismissal for a notification kind on a local calendar date."""
|
||||
from app.services.notification_service import NotificationService, user_local_today_bounds_utc
|
||||
|
||||
return jsonify({"hours": round(total_hours, 2), "projects": project_count})
|
||||
data = request.get_json(silent=True) or {}
|
||||
kind = data.get("kind")
|
||||
local_date = data.get("local_date")
|
||||
if not kind or not isinstance(kind, str):
|
||||
return jsonify({"error": "kind is required"}), 400
|
||||
if not local_date or not isinstance(local_date, str):
|
||||
_, _, local_date = user_local_today_bounds_utc(current_user)
|
||||
ok = NotificationService.dismiss(current_user, kind.strip(), local_date.strip()[:10])
|
||||
if not ok:
|
||||
return jsonify({"error": "invalid kind or local_date"}), 400
|
||||
return jsonify({"success": True})
|
||||
|
||||
|
||||
@api_bp.route("/api/activity/timeline")
|
||||
|
||||
+29
-6
@@ -1,6 +1,8 @@
|
||||
"""API Documentation with Swagger UI"""
|
||||
|
||||
from flask import Blueprint, jsonify, render_template_string
|
||||
from flask import Blueprint, current_app, jsonify, render_template_string
|
||||
|
||||
from app.config.analytics_defaults import get_version_from_setup
|
||||
from flask_swagger_ui import get_swaggerui_blueprint
|
||||
|
||||
# Create blueprint for serving OpenAPI spec
|
||||
@@ -29,19 +31,33 @@ swaggerui_blueprint = get_swaggerui_blueprint(
|
||||
@api_docs_bp.route("/api/openapi.json")
|
||||
def openapi_spec():
|
||||
"""Serve the OpenAPI specification"""
|
||||
app_version = get_version_from_setup()
|
||||
if app_version == "unknown":
|
||||
app_version = current_app.config.get("APP_VERSION", "1.0.0")
|
||||
|
||||
spec = {
|
||||
"openapi": "3.0.0",
|
||||
"info": {
|
||||
"title": "TimeTracker REST API",
|
||||
"version": "4.20.9",
|
||||
"version": app_version,
|
||||
"description": """
|
||||
# TimeTracker REST API
|
||||
|
||||
A comprehensive REST API for time tracking, project management, and reporting.
|
||||
|
||||
## Authentication
|
||||
## Two HTTP JSON surfaces
|
||||
|
||||
All API endpoints require authentication using an API token. You can obtain an API token from the admin dashboard.
|
||||
TimeTracker exposes two JSON HTTP surfaces. **This OpenAPI document describes only `/api/v1`** (paths are relative to the v1 server URL).
|
||||
|
||||
1. **`/api/v1` (documented here)** — Primary, versioned **REST API** for integrations (desktop, mobile, automation). Uses **API token** authentication (`Authorization: Bearer` or `X-API-Key`), scoped permissions, and stable JSON contracts.
|
||||
|
||||
2. **`/api/*` (not fully documented here)** — Same-origin **session** JSON used by the **logged-in web UI** (Flask-Login cookie): search, timer helpers, notifications, dashboard fragments, uploads, and similar. These routes may change with the UI. Where a v1 equivalent exists, legacy `/api` routes may return **`X-API-Deprecated: true`** and a **`Link`** header with `rel="successor-version"` pointing at the v1 path. **Integrations should not rely on `/api/*`.**
|
||||
|
||||
**Exception:** a few `/api` routes (for example version check/dismiss) may accept **either** session or token for admin tooling; see product docs for details.
|
||||
|
||||
## Authentication (paths under `/api/v1` in this spec)
|
||||
|
||||
All **documented** API endpoints use authentication as described below. You can obtain an API token from the admin dashboard.
|
||||
|
||||
### Authentication Methods
|
||||
|
||||
@@ -153,8 +169,11 @@ Example: `2024-01-15T14:30:00Z`
|
||||
"license": {"name": "MIT"},
|
||||
},
|
||||
"servers": [
|
||||
{"url": "/api/v1", "description": "REST API v1"},
|
||||
{"url": "", "description": "App root (for /api/analytics, etc.)"},
|
||||
{"url": "/api/v1", "description": "Versioned REST API (token auth); OpenAPI paths are relative to this base."},
|
||||
{
|
||||
"url": "",
|
||||
"description": "Application origin only (HTML, static assets, session `/api/*`, and other non-spec routes—not covered by this document).",
|
||||
},
|
||||
],
|
||||
"components": {
|
||||
"securitySchemes": {
|
||||
@@ -257,6 +276,10 @@ Example: `2024-01-15T14:30:00Z`
|
||||
},
|
||||
"security": [{"BearerAuth": []}, {"ApiKeyAuth": []}],
|
||||
"tags": [
|
||||
{
|
||||
"name": "SessionWebApi",
|
||||
"description": "Session-based JSON under `/api/*` is for the browser UI only; it is not defined in this spec. Use `/api/v1` for integrations.",
|
||||
},
|
||||
{"name": "System", "description": "System information and health checks"},
|
||||
{"name": "Projects", "description": "Project management operations"},
|
||||
{"name": "Time Entries", "description": "Time tracking operations"},
|
||||
|
||||
+39
-145
@@ -56,6 +56,7 @@ from app.models import (
|
||||
WebhookDelivery,
|
||||
)
|
||||
from app.models.time_entry import local_now
|
||||
from app.services.global_search_service import run_global_search
|
||||
from app.models.time_entry_approval import ApprovalStatus, TimeEntryApproval
|
||||
from app.utils.api_auth import require_api_token
|
||||
from app.utils.api_responses import (
|
||||
@@ -67,6 +68,7 @@ from app.utils.api_responses import (
|
||||
validation_error_response,
|
||||
)
|
||||
from app.utils.error_handling import safe_log
|
||||
from app.utils.scope_filter import apply_client_scope, apply_project_scope
|
||||
from app.utils.timezone import get_app_timezone, parse_local_datetime, utc_to_local
|
||||
|
||||
api_v1_bp = Blueprint("api_v1", __name__, url_prefix="/api/v1")
|
||||
@@ -112,10 +114,16 @@ def api_info():
|
||||
# Fallback to config or default
|
||||
app_version = current_app.config.get("APP_VERSION", "1.0.0")
|
||||
|
||||
from app.utils.installation import get_installation_config
|
||||
|
||||
installation_config = get_installation_config()
|
||||
setup_required = not installation_config.is_setup_complete()
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"api_version": "v1",
|
||||
"app_version": app_version,
|
||||
"setup_required": setup_required,
|
||||
"documentation_url": "/api/docs",
|
||||
"authentication": "API Token (Bearer or X-API-Key header)",
|
||||
"endpoints": {
|
||||
@@ -4485,6 +4493,11 @@ def search():
|
||||
type: string
|
||||
count:
|
||||
type: integer
|
||||
partial:
|
||||
type: boolean
|
||||
errors:
|
||||
type: object
|
||||
description: Domain key to error message (projects, tasks, clients, entries)
|
||||
400:
|
||||
description: Invalid query (too short)
|
||||
"""
|
||||
@@ -4493,153 +4506,34 @@ def search():
|
||||
types_filter = request.args.get("types", "").strip().lower()
|
||||
|
||||
if not query or len(query) < 2:
|
||||
return jsonify({"error": "Query must be at least 2 characters", "results": []}), 400
|
||||
return (
|
||||
jsonify(
|
||||
{
|
||||
"error": "Query must be at least 2 characters",
|
||||
"results": [],
|
||||
"partial": False,
|
||||
"errors": {},
|
||||
}
|
||||
),
|
||||
400,
|
||||
)
|
||||
|
||||
# Parse types filter
|
||||
allowed_types = {"project", "task", "client", "entry"}
|
||||
if types_filter:
|
||||
requested_types = {t.strip() for t in types_filter.split(",") if t.strip()}
|
||||
search_types = requested_types.intersection(allowed_types)
|
||||
else:
|
||||
search_types = allowed_types
|
||||
results, errors = run_global_search(
|
||||
g.api_user,
|
||||
query,
|
||||
limit=limit,
|
||||
types_filter=types_filter,
|
||||
)
|
||||
|
||||
results = []
|
||||
search_pattern = f"%{query}%"
|
||||
|
||||
# Get authenticated user from API token
|
||||
user = g.api_user
|
||||
|
||||
# Search projects (scoped for subcontractors)
|
||||
if "project" in search_types:
|
||||
try:
|
||||
from app.utils.scope_filter import apply_project_scope_to_model
|
||||
|
||||
projects_query = Project.query.filter(
|
||||
Project.status == "active",
|
||||
or_(Project.name.ilike(search_pattern), Project.description.ilike(search_pattern)),
|
||||
)
|
||||
scope_p = apply_project_scope_to_model(Project, user)
|
||||
if scope_p is not None:
|
||||
projects_query = projects_query.filter(scope_p)
|
||||
projects = projects_query.limit(limit).all()
|
||||
|
||||
for project in projects:
|
||||
results.append(
|
||||
{
|
||||
"type": "project",
|
||||
"category": "project",
|
||||
"id": project.id,
|
||||
"title": project.name,
|
||||
"description": project.description or "",
|
||||
"url": f"/projects/{project.id}",
|
||||
"badge": "Project",
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error searching projects: {e}")
|
||||
|
||||
# Search tasks
|
||||
if "task" in search_types:
|
||||
try:
|
||||
tasks = (
|
||||
Task.query.join(Project)
|
||||
.filter(
|
||||
Project.status == "active",
|
||||
or_(Task.name.ilike(search_pattern), Task.description.ilike(search_pattern)),
|
||||
)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
for task in tasks:
|
||||
results.append(
|
||||
{
|
||||
"type": "task",
|
||||
"category": "task",
|
||||
"id": task.id,
|
||||
"title": task.name,
|
||||
"description": f"{task.project.name if task.project else 'No Project'}",
|
||||
"url": f"/tasks/{task.id}",
|
||||
"badge": task.status.replace("_", " ").title() if task.status else "Task",
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error searching tasks: {e}")
|
||||
|
||||
# Search clients (scoped for subcontractors)
|
||||
if "client" in search_types:
|
||||
try:
|
||||
from app.utils.scope_filter import apply_client_scope_to_model
|
||||
|
||||
clients_query = Client.query.filter(
|
||||
or_(
|
||||
Client.name.ilike(search_pattern),
|
||||
Client.email.ilike(search_pattern),
|
||||
Client.company.ilike(search_pattern),
|
||||
)
|
||||
)
|
||||
scope_c = apply_client_scope_to_model(Client, user)
|
||||
if scope_c is not None:
|
||||
clients_query = clients_query.filter(scope_c)
|
||||
clients = clients_query.limit(limit).all()
|
||||
|
||||
for client in clients:
|
||||
results.append(
|
||||
{
|
||||
"type": "client",
|
||||
"category": "client",
|
||||
"id": client.id,
|
||||
"title": client.name,
|
||||
"description": client.company or client.email or "",
|
||||
"url": f"/clients/{client.id}",
|
||||
"badge": "Client",
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error searching clients: {e}")
|
||||
|
||||
# Search time entries (notes and tags)
|
||||
# Non-admin users can only see their own entries
|
||||
if "entry" in search_types:
|
||||
try:
|
||||
entries_query = TimeEntry.query.filter(
|
||||
TimeEntry.end_time.isnot(None),
|
||||
or_(TimeEntry.notes.ilike(search_pattern), TimeEntry.tags.ilike(search_pattern)),
|
||||
)
|
||||
|
||||
# Restrict to user's entries if not admin
|
||||
if not user.is_admin:
|
||||
entries_query = entries_query.filter(TimeEntry.user_id == user.id)
|
||||
|
||||
entries = entries_query.order_by(TimeEntry.start_time.desc()).limit(limit).all()
|
||||
|
||||
for entry in entries:
|
||||
title_parts = []
|
||||
if entry.project:
|
||||
title_parts.append(entry.project.name)
|
||||
if entry.task:
|
||||
title_parts.append(f"• {entry.task.name}")
|
||||
title = " ".join(title_parts) if title_parts else "Time Entry"
|
||||
|
||||
description = entry.notes[:100] if entry.notes else ""
|
||||
if entry.tags:
|
||||
description += f" [{entry.tags}]"
|
||||
|
||||
results.append(
|
||||
{
|
||||
"type": "entry",
|
||||
"category": "entry",
|
||||
"id": entry.id,
|
||||
"title": title,
|
||||
"description": description,
|
||||
"url": f"/timer/edit/{entry.id}",
|
||||
"badge": entry.duration_formatted,
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error searching time entries: {e}")
|
||||
|
||||
return jsonify({"results": results, "query": query, "count": len(results)})
|
||||
return jsonify(
|
||||
{
|
||||
"results": results,
|
||||
"query": query,
|
||||
"count": len(results),
|
||||
"partial": bool(errors),
|
||||
"errors": errors,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# ==================== Timesheet Governance ====================
|
||||
|
||||
@@ -14,6 +14,7 @@ from app.models import Client, Expense, Invoice, Project, SavedReportView, Task,
|
||||
from app.services.unpaid_hours_service import UnpaidHoursService
|
||||
from app.utils.db import safe_commit
|
||||
from app.utils.module_helpers import module_enabled
|
||||
from app.utils.support_report_generation import record_report_generation_for_current_user
|
||||
|
||||
custom_reports_bp = Blueprint("custom_reports", __name__)
|
||||
|
||||
@@ -203,11 +204,13 @@ def view_custom_report(view_id):
|
||||
# Check if iterative report generation is enabled
|
||||
if saved_view.iterative_report_generation and saved_view.iterative_custom_field_name:
|
||||
# Generate reports for each custom field value
|
||||
record_report_generation_for_current_user()
|
||||
return _generate_iterative_reports(saved_view, config, current_user.id)
|
||||
|
||||
# Generate single report data based on config
|
||||
report_data = generate_report_data(config, current_user.id)
|
||||
|
||||
record_report_generation_for_current_user()
|
||||
return render_template("reports/custom_view.html", saved_view=saved_view, config=config, report_data=report_data)
|
||||
|
||||
|
||||
|
||||
+113
-6
@@ -14,10 +14,11 @@ from flask import (
|
||||
session,
|
||||
url_for,
|
||||
)
|
||||
from flask_babel import gettext as _
|
||||
from flask_login import current_user, login_required
|
||||
from sqlalchemy import text
|
||||
|
||||
from app import db, track_page_view
|
||||
from app import db, track_event, track_page_view
|
||||
from app.models import Activity, Client, Project, Settings, TimeEntry, TimeEntryTemplate, User, WeeklyTimeGoal
|
||||
from app.models.time_entry import local_now
|
||||
from app.utils.license_utils import is_license_activated
|
||||
@@ -190,10 +191,12 @@ def dashboard():
|
||||
timer_stopped_toast["time_entries_url"] = url_for("timer.time_entries_overview")
|
||||
|
||||
# Get user stats for smart banner and donation widget
|
||||
support_banner_suppressed_dashboard = False
|
||||
try:
|
||||
from app.models import DonationInteraction
|
||||
|
||||
user_stats = DonationInteraction.get_user_engagement_metrics(current_user.id)
|
||||
support_banner_suppressed_dashboard = DonationInteraction.has_recent_donation_click(current_user.id, days=30)
|
||||
except Exception:
|
||||
# Fallback if table doesn't exist yet
|
||||
days_since_signup = (datetime.utcnow() - current_user.created_at).days if current_user.created_at else 0
|
||||
@@ -209,11 +212,40 @@ def dashboard():
|
||||
time_entries_count = user_stats.get("time_entries_count", 0)
|
||||
total_hours = user_stats.get("total_hours", 0.0)
|
||||
|
||||
# Optional support reminder: show at most once per session for unlicensed instances
|
||||
settings_obj = Settings.get_settings()
|
||||
show_support_reminder = not is_license_activated(settings_obj) and not session.get("support_reminder_shown", False)
|
||||
if show_support_reminder:
|
||||
session["support_reminder_shown"] = True
|
||||
from app.services.support_prompt_service import SupportPromptService
|
||||
from app.services.usage_stats_service import UsageStatsService
|
||||
|
||||
usage_support_stats = UsageStatsService.get_for_user(current_user.id, month_hours=float(month_hours or 0))
|
||||
is_supporter = is_license_activated(settings_obj)
|
||||
ui_show_donate = getattr(current_user, "ui_show_donate", True)
|
||||
support_dashboard_prompt = SupportPromptService.pick_dashboard_prompt(
|
||||
session,
|
||||
user_stats,
|
||||
ui_show_donate=ui_show_donate,
|
||||
is_supporter=is_supporter,
|
||||
support_banner_suppressed=support_banner_suppressed_dashboard,
|
||||
today_hours=float(today_hours or 0),
|
||||
)
|
||||
if support_dashboard_prompt:
|
||||
SupportPromptService.mark_prompt_shown(session, support_dashboard_prompt["variant"])
|
||||
v = support_dashboard_prompt.get("variant")
|
||||
if v == SupportPromptService.VARIANT_SEVEN_DAY:
|
||||
support_dashboard_prompt = {
|
||||
**support_dashboard_prompt,
|
||||
"message": _(
|
||||
"You have been using TimeTracker for a week or more. If it fits your workflow, "
|
||||
"consider supporting continued development."
|
||||
),
|
||||
}
|
||||
elif v == SupportPromptService.VARIANT_ACTIVE_TODAY:
|
||||
support_dashboard_prompt = {
|
||||
**support_dashboard_prompt,
|
||||
"message": _(
|
||||
"You have tracked a solid amount of time today. If TimeTracker makes your day easier, "
|
||||
"you can support the project in a click."
|
||||
),
|
||||
}
|
||||
|
||||
# Prepare template data
|
||||
template_data = {
|
||||
@@ -246,7 +278,9 @@ def dashboard():
|
||||
"time_entries_count": time_entries_count, # For donation widget
|
||||
"total_hours": total_hours, # For donation widget
|
||||
"timer_stopped_toast": timer_stopped_toast,
|
||||
"show_support_reminder": show_support_reminder,
|
||||
"usage_support_stats": usage_support_stats,
|
||||
"support_dashboard_prompt": support_dashboard_prompt,
|
||||
"is_supporter_instance": is_supporter,
|
||||
}
|
||||
|
||||
return render_template("main/dashboard.html", **template_data)
|
||||
@@ -398,6 +432,79 @@ def track_support_impression():
|
||||
return jsonify({"success": True, "note": "Tracking unavailable"})
|
||||
|
||||
|
||||
@main_bp.route("/donate/request-soft-prompt", methods=["POST"])
|
||||
@login_required
|
||||
def request_soft_support_prompt():
|
||||
"""Authorize a single long-session soft prompt (session rules enforced server-side)."""
|
||||
from app.models import DonationInteraction, Settings
|
||||
|
||||
from app.services.support_prompt_service import SupportPromptService
|
||||
|
||||
data = request.get_json() or {}
|
||||
kind = (data.get("kind") or "long_session").strip()
|
||||
if kind != "long_session":
|
||||
return jsonify({"show": False})
|
||||
|
||||
settings_obj = Settings.get_settings()
|
||||
is_supporter = is_license_activated(settings_obj)
|
||||
ui_show = getattr(current_user, "ui_show_donate", True)
|
||||
try:
|
||||
suppressed = DonationInteraction.has_recent_donation_click(current_user.id, days=30)
|
||||
except Exception:
|
||||
suppressed = False
|
||||
|
||||
if not SupportPromptService.long_session_prompt_allowed(
|
||||
session,
|
||||
ui_show_donate=ui_show,
|
||||
is_supporter=is_supporter,
|
||||
support_banner_suppressed=suppressed,
|
||||
):
|
||||
return jsonify({"show": False})
|
||||
|
||||
SupportPromptService.mark_prompt_shown(session, SupportPromptService.VARIANT_LONG_SESSION)
|
||||
return jsonify({"show": True, "variant": "long_session"})
|
||||
|
||||
|
||||
@main_bp.route("/donate/track-support-event", methods=["POST"])
|
||||
@login_required
|
||||
def track_support_event():
|
||||
"""Telemetry + DonationInteraction funnel for support UI (best-effort)."""
|
||||
from app.models import DonationInteraction
|
||||
|
||||
data = request.get_json() or {}
|
||||
event = (data.get("event") or "").strip()
|
||||
variant = data.get("variant")
|
||||
source = (data.get("source") or "support_ui").strip()
|
||||
|
||||
event_map = {
|
||||
"modal_opened": ("support.modal_opened", "support_modal_opened"),
|
||||
"donation_clicked": ("support.donation_clicked", "support_donation_clicked"),
|
||||
"license_clicked": ("support.license_clicked", "support_license_clicked"),
|
||||
"prompt_shown": ("support.prompt_shown", "support_prompt_shown"),
|
||||
"prompt_dismissed": ("support.prompt_dismissed", "support_prompt_dismissed"),
|
||||
}
|
||||
if event not in event_map:
|
||||
return jsonify({"success": False, "error": "unknown event"}), 400
|
||||
|
||||
analytics_name, interaction_type = event_map[event]
|
||||
props = {"variant": variant, "source": source}
|
||||
track_event(current_user.id, analytics_name, props)
|
||||
|
||||
try:
|
||||
metrics = DonationInteraction.get_user_engagement_metrics(current_user.id)
|
||||
DonationInteraction.record_interaction(
|
||||
user_id=current_user.id,
|
||||
interaction_type=interaction_type,
|
||||
source=source,
|
||||
user_metrics=metrics,
|
||||
variant=variant,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return jsonify({"success": True})
|
||||
|
||||
|
||||
@main_bp.route("/debug/i18n")
|
||||
@login_required
|
||||
def debug_i18n():
|
||||
|
||||
@@ -25,6 +25,7 @@ from app.models import (
|
||||
)
|
||||
from app.repositories import TimeEntryRepository
|
||||
from app.services.scheduled_report_service import ScheduledReportService
|
||||
from app.utils.support_report_generation import record_report_generation_for_current_user
|
||||
from app.utils.excel_export import create_project_report_excel, create_time_entries_excel
|
||||
from app.utils.posthog_monitoring import track_error, track_export_performance, track_validation_error
|
||||
|
||||
@@ -513,6 +514,7 @@ def export_csv():
|
||||
# Don't let tracking errors break the export
|
||||
pass
|
||||
|
||||
record_report_generation_for_current_user()
|
||||
return send_file(io.BytesIO(csv_content), mimetype="text/csv", as_attachment=True, download_name=filename)
|
||||
except Exception:
|
||||
current_app.logger.exception("CSV export failed (reports.export_csv)")
|
||||
@@ -569,6 +571,7 @@ def export_summary_pdf():
|
||||
current_app.logger.warning("Summary report PDF export failed: %s", e, exc_info=True)
|
||||
flash(_("PDF export failed: %(error)s", error=str(e)), "error")
|
||||
return redirect(url_for("reports.summary_report"))
|
||||
record_report_generation_for_current_user()
|
||||
filename = f"summary_report_{datetime.utcnow().strftime('%Y%m%d')}.pdf"
|
||||
return send_file(
|
||||
io.BytesIO(pdf_bytes),
|
||||
@@ -977,6 +980,7 @@ def time_entries_export_excel():
|
||||
"export.excel",
|
||||
{"export_type": "time_entries_report", "num_rows": len(entries)},
|
||||
)
|
||||
record_report_generation_for_current_user()
|
||||
return send_file(
|
||||
output,
|
||||
mimetype="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
@@ -1039,6 +1043,7 @@ def time_entries_export_csv():
|
||||
writer.writerow(row)
|
||||
output.seek(0)
|
||||
filename = f"time_entries_report_{start_date}_to_{end_date}.csv"
|
||||
record_report_generation_for_current_user()
|
||||
return send_file(
|
||||
io.BytesIO(output.getvalue().encode("utf-8")),
|
||||
mimetype="text/csv",
|
||||
@@ -1118,6 +1123,7 @@ def export_excel():
|
||||
{"export_type": "time_entries", "num_rows": len(entries), "date_range_days": (end_dt - start_dt).days},
|
||||
)
|
||||
|
||||
record_report_generation_for_current_user()
|
||||
return send_file(
|
||||
output,
|
||||
mimetype="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
|
||||
+20
-1
@@ -66,6 +66,25 @@ def settings():
|
||||
else:
|
||||
current_user.reminder_to_log_time = None
|
||||
|
||||
# Smart in-app notifications (separate from email remind-to-log)
|
||||
current_user.smart_notifications_enabled = "smart_notifications_enabled" in request.form
|
||||
current_user.smart_notify_no_tracking = "smart_notify_no_tracking" in request.form
|
||||
current_user.smart_notify_long_timer = "smart_notify_long_timer" in request.form
|
||||
current_user.smart_notify_daily_summary = "smart_notify_daily_summary" in request.form
|
||||
current_user.smart_notify_browser = "smart_notify_browser" in request.form
|
||||
for form_key, attr in (
|
||||
("smart_notify_no_tracking_after", "smart_notify_no_tracking_after"),
|
||||
("smart_notify_summary_at", "smart_notify_summary_at"),
|
||||
):
|
||||
raw = (request.form.get(form_key) or "").strip()
|
||||
if raw and len(raw) <= 5:
|
||||
if re.match(r"^([01]?\d|2[0-3]):[0-5]\d$", raw):
|
||||
setattr(current_user, attr, raw)
|
||||
else:
|
||||
setattr(current_user, attr, None)
|
||||
else:
|
||||
setattr(current_user, attr, None)
|
||||
|
||||
# Profile information
|
||||
full_name = request.form.get("full_name", "").strip()
|
||||
if full_name:
|
||||
@@ -218,7 +237,7 @@ def settings():
|
||||
@user_bp.route("/settings/license", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def license():
|
||||
"""License management page: show status, enter key, validate (sets donate_ui_hidden for instance)."""
|
||||
"""License management: supporter key validation (sets donate_ui_hidden / supporter instance flag)."""
|
||||
settings_obj = Settings.get_settings()
|
||||
if request.method == "POST":
|
||||
if is_license_activated(settings_obj):
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
"""Typed response for GET /api/version/check."""
|
||||
|
||||
from typing import TypedDict
|
||||
|
||||
|
||||
class VersionCheckResponse(TypedDict):
|
||||
update_available: bool
|
||||
current_version: str
|
||||
latest_version: str | None
|
||||
release_notes: str | None
|
||||
published_at: str | None
|
||||
release_url: str | None
|
||||
@@ -23,6 +23,7 @@ from .reporting_service import ReportingService
|
||||
from .task_service import TaskService
|
||||
from .time_tracking_service import TimeTrackingService
|
||||
from .user_service import UserService
|
||||
from .version_service import VersionService
|
||||
from .workforce_governance_service import WorkforceGovernanceService
|
||||
|
||||
__all__ = [
|
||||
@@ -46,5 +47,6 @@ __all__ = [
|
||||
"PermissionService",
|
||||
"BackupService",
|
||||
"HealthService",
|
||||
"VersionService",
|
||||
"WorkforceGovernanceService",
|
||||
]
|
||||
|
||||
@@ -65,7 +65,6 @@ class ClientService:
|
||||
phone=phone,
|
||||
address=address,
|
||||
default_hourly_rate=default_hourly_rate,
|
||||
status="active",
|
||||
custom_fields=custom_fields,
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
"""Shared global search for session /api/search and token /api/v1/search."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Set, Tuple
|
||||
|
||||
from flask import current_app
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from app.models import Client, Project, Task, TimeEntry, User
|
||||
from app.utils.scope_filter import apply_client_scope, apply_project_scope
|
||||
|
||||
|
||||
def _parse_search_types(types_filter: str) -> Set[str]:
|
||||
allowed = {"project", "task", "client", "entry"}
|
||||
raw = (types_filter or "").strip().lower()
|
||||
if raw:
|
||||
requested = {t.strip() for t in raw.split(",") if t.strip()}
|
||||
return requested.intersection(allowed)
|
||||
return allowed
|
||||
|
||||
|
||||
def run_global_search(
|
||||
user: User,
|
||||
query: str,
|
||||
*,
|
||||
limit: int,
|
||||
types_filter: str,
|
||||
) -> Tuple[List[Dict[str, Any]], Dict[str, str]]:
|
||||
"""
|
||||
Run global search for projects, tasks, clients, and finished time entries.
|
||||
|
||||
Returns (results, errors) where errors maps domain key to message if that domain failed.
|
||||
Caller handles short-query policy (legacy 200 empty vs v1 400).
|
||||
"""
|
||||
limit = min(max(limit, 1), 50)
|
||||
search_types = _parse_search_types(types_filter)
|
||||
results: List[Dict[str, Any]] = []
|
||||
errors: Dict[str, str] = {}
|
||||
search_pattern = f"%{query}%"
|
||||
|
||||
if "project" in search_types:
|
||||
try:
|
||||
projects_query = Project.query.filter(
|
||||
Project.status == "active",
|
||||
or_(Project.name.ilike(search_pattern), Project.description.ilike(search_pattern)),
|
||||
)
|
||||
projects_query = apply_project_scope(Project, projects_query, user)
|
||||
projects = projects_query.limit(limit).all()
|
||||
|
||||
for project in projects:
|
||||
results.append(
|
||||
{
|
||||
"type": "project",
|
||||
"category": "project",
|
||||
"id": project.id,
|
||||
"title": project.name,
|
||||
"description": project.description or "",
|
||||
"url": f"/projects/{project.id}",
|
||||
"badge": "Project",
|
||||
}
|
||||
)
|
||||
except SQLAlchemyError as e:
|
||||
current_app.logger.exception(
|
||||
"Error searching projects",
|
||||
extra={"event": "api_search_domain_failed", "domain": "projects"},
|
||||
)
|
||||
errors["projects"] = str(e)
|
||||
|
||||
if "task" in search_types:
|
||||
try:
|
||||
tasks_query = Task.query.join(Project).filter(
|
||||
Project.status == "active",
|
||||
or_(Task.name.ilike(search_pattern), Task.description.ilike(search_pattern)),
|
||||
)
|
||||
tasks_query = apply_project_scope(Project, tasks_query, user)
|
||||
tasks = tasks_query.limit(limit).all()
|
||||
|
||||
for task in tasks:
|
||||
results.append(
|
||||
{
|
||||
"type": "task",
|
||||
"category": "task",
|
||||
"id": task.id,
|
||||
"title": task.name,
|
||||
"description": f"{task.project.name if task.project else 'No Project'}",
|
||||
"url": f"/tasks/{task.id}",
|
||||
"badge": task.status.replace("_", " ").title() if task.status else "Task",
|
||||
}
|
||||
)
|
||||
except SQLAlchemyError as e:
|
||||
current_app.logger.exception(
|
||||
"Error searching tasks",
|
||||
extra={"event": "api_search_domain_failed", "domain": "tasks"},
|
||||
)
|
||||
errors["tasks"] = str(e)
|
||||
|
||||
if "client" in search_types:
|
||||
try:
|
||||
clients_query = Client.query.filter(
|
||||
or_(
|
||||
Client.name.ilike(search_pattern),
|
||||
Client.email.ilike(search_pattern),
|
||||
Client.description.ilike(search_pattern),
|
||||
Client.contact_person.ilike(search_pattern),
|
||||
)
|
||||
)
|
||||
clients_query = apply_client_scope(Client, clients_query, user)
|
||||
clients = clients_query.limit(limit).all()
|
||||
|
||||
for client in clients:
|
||||
results.append(
|
||||
{
|
||||
"type": "client",
|
||||
"category": "client",
|
||||
"id": client.id,
|
||||
"title": client.name,
|
||||
"description": (client.description or client.contact_person or client.email or ""),
|
||||
"url": f"/clients/{client.id}",
|
||||
"badge": "Client",
|
||||
}
|
||||
)
|
||||
except SQLAlchemyError as e:
|
||||
current_app.logger.exception(
|
||||
"Error searching clients",
|
||||
extra={"event": "api_search_domain_failed", "domain": "clients"},
|
||||
)
|
||||
errors["clients"] = str(e)
|
||||
|
||||
if "entry" in search_types:
|
||||
try:
|
||||
entries_query = TimeEntry.query.filter(
|
||||
TimeEntry.end_time.isnot(None),
|
||||
or_(TimeEntry.notes.ilike(search_pattern), TimeEntry.tags.ilike(search_pattern)),
|
||||
)
|
||||
if not user.is_admin:
|
||||
entries_query = entries_query.filter(TimeEntry.user_id == user.id)
|
||||
|
||||
entries = entries_query.order_by(TimeEntry.start_time.desc()).limit(limit).all()
|
||||
|
||||
for entry in entries:
|
||||
title_parts = []
|
||||
if entry.project:
|
||||
title_parts.append(entry.project.name)
|
||||
if entry.task:
|
||||
title_parts.append(f"• {entry.task.name}")
|
||||
title = " ".join(title_parts) if title_parts else "Time Entry"
|
||||
|
||||
description = entry.notes[:100] if entry.notes else ""
|
||||
if entry.tags:
|
||||
description += f" [{entry.tags}]"
|
||||
|
||||
results.append(
|
||||
{
|
||||
"type": "entry",
|
||||
"category": "entry",
|
||||
"id": entry.id,
|
||||
"title": title,
|
||||
"description": description,
|
||||
"url": f"/timer/edit/{entry.id}",
|
||||
"badge": entry.duration_formatted,
|
||||
}
|
||||
)
|
||||
except SQLAlchemyError as e:
|
||||
current_app.logger.exception(
|
||||
"Error searching time entries",
|
||||
extra={"event": "api_search_domain_failed", "domain": "entries"},
|
||||
)
|
||||
errors["entries"] = str(e)
|
||||
|
||||
return results, errors
|
||||
@@ -1,53 +1,264 @@
|
||||
"""
|
||||
Service for notifications and event handling.
|
||||
"""
|
||||
"""Smart in-app notifications: eligibility, ranking, and dismissal-aware payloads."""
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import date, datetime, time, timedelta, timezone
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||
|
||||
from flask import current_app
|
||||
from sqlalchemy import func
|
||||
|
||||
from app.constants import NotificationType, WebhookEvent
|
||||
from app.utils.webhook_dispatcher import dispatch_webhook
|
||||
from app import db
|
||||
from app.models import TimeEntry, UserSmartNotificationDismissal
|
||||
from app.utils.db import safe_commit
|
||||
|
||||
KIND_NO_TRACKING = "no_tracking_today"
|
||||
KIND_LONG_TIMER = "timer_running_long"
|
||||
KIND_DAILY_SUMMARY = "daily_summary"
|
||||
|
||||
_HHMM_RE = re.compile(r"^([01]?\d|2[0-3]):([0-5]\d)$")
|
||||
|
||||
|
||||
def get_today_summary_for_user(user) -> Dict[str, Any]:
|
||||
"""Hours and distinct project count for completed entries in the user's local today."""
|
||||
start_utc, end_utc, _local_date = user_local_today_bounds_utc(user)
|
||||
hours = _completed_hours_today(user.id, start_utc, end_utc)
|
||||
projects = completed_projects_today_count(user.id, start_utc, end_utc)
|
||||
return {"hours": round(hours, 2), "projects": projects}
|
||||
|
||||
|
||||
def parse_hhmm(raw: Optional[str]) -> Optional[Tuple[int, int]]:
|
||||
if not raw or not isinstance(raw, str):
|
||||
return None
|
||||
s = raw.strip()
|
||||
m = _HHMM_RE.match(s)
|
||||
if not m:
|
||||
return None
|
||||
return int(m.group(1)), int(m.group(2))
|
||||
|
||||
|
||||
def user_local_today_bounds_utc(user) -> Tuple[datetime, datetime, str]:
|
||||
"""Return (start_utc, end_utc_exclusive, local_date_iso) for the user's current local calendar day."""
|
||||
from datetime import time as dt_time
|
||||
|
||||
from app.utils.timezone import get_timezone_for_user, now_in_user_timezone
|
||||
|
||||
user_now = now_in_user_timezone(user)
|
||||
user_tz = get_timezone_for_user(user)
|
||||
user_today: date = user_now.date()
|
||||
start_local = datetime.combine(user_today, dt_time.min).replace(tzinfo=user_tz)
|
||||
end_local = start_local + timedelta(days=1)
|
||||
start_utc = start_local.astimezone(timezone.utc)
|
||||
end_utc = end_local.astimezone(timezone.utc)
|
||||
local_date_iso = user_today.isoformat()
|
||||
return start_utc, end_utc, local_date_iso
|
||||
|
||||
|
||||
def _entry_start_as_utc_aware(dt: Optional[datetime]) -> Optional[datetime]:
|
||||
if dt is None:
|
||||
return None
|
||||
if dt.tzinfo is None:
|
||||
return dt.replace(tzinfo=timezone.utc)
|
||||
return dt.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _dismissed_kinds(user_id: int, local_date: str) -> Set[str]:
|
||||
rows = (
|
||||
UserSmartNotificationDismissal.query.filter_by(user_id=user_id, local_date=local_date)
|
||||
.with_entities(UserSmartNotificationDismissal.kind)
|
||||
.all()
|
||||
)
|
||||
return {r[0] for r in rows}
|
||||
|
||||
|
||||
def _completed_hours_today(user_id: int, start_utc: datetime, end_utc: datetime) -> float:
|
||||
total_seconds = (
|
||||
db.session.query(func.coalesce(func.sum(TimeEntry.duration_seconds), 0))
|
||||
.filter(
|
||||
TimeEntry.user_id == user_id,
|
||||
TimeEntry.start_time >= start_utc,
|
||||
TimeEntry.start_time < end_utc,
|
||||
TimeEntry.end_time.isnot(None),
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
return float(total_seconds) / 3600.0
|
||||
|
||||
|
||||
def completed_projects_today_count(user_id: int, start_utc: datetime, end_utc: datetime) -> int:
|
||||
q = (
|
||||
db.session.query(func.count(func.distinct(TimeEntry.project_id)))
|
||||
.filter(
|
||||
TimeEntry.user_id == user_id,
|
||||
TimeEntry.start_time >= start_utc,
|
||||
TimeEntry.start_time < end_utc,
|
||||
TimeEntry.end_time.isnot(None),
|
||||
TimeEntry.project_id.isnot(None),
|
||||
)
|
||||
.scalar()
|
||||
)
|
||||
return int(q or 0)
|
||||
|
||||
|
||||
def _completed_entry_count_today(user_id: int, start_utc: datetime, end_utc: datetime) -> int:
|
||||
return (
|
||||
TimeEntry.query.filter(
|
||||
TimeEntry.user_id == user_id,
|
||||
TimeEntry.start_time >= start_utc,
|
||||
TimeEntry.start_time < end_utc,
|
||||
TimeEntry.end_time.isnot(None),
|
||||
).count()
|
||||
)
|
||||
|
||||
|
||||
def _in_hour_slot(user_local_now: datetime, target_hour: int, slot_minutes: int) -> bool:
|
||||
"""Match remind-to-log style: fire during `target_hour` when minute < slot_minutes (e.g. 16:00–16:29)."""
|
||||
return user_local_now.hour == target_hour and user_local_now.minute < slot_minutes
|
||||
|
||||
|
||||
class NotificationService:
|
||||
"""Service for notifications and events"""
|
||||
"""Builds smart notification payloads for the authenticated user."""
|
||||
|
||||
def notify_time_entry_created(self, entry_id: int, user_id: int, project_id: int) -> None:
|
||||
"""Notify that a time entry was created"""
|
||||
try:
|
||||
dispatch_webhook(
|
||||
event=WebhookEvent.TIME_ENTRY_CREATED.value,
|
||||
data={"entry_id": entry_id, "user_id": user_id, "project_id": project_id},
|
||||
)
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Failed to dispatch time entry created webhook: {e}")
|
||||
_PRIORITY = {KIND_LONG_TIMER: 0, KIND_NO_TRACKING: 1, KIND_DAILY_SUMMARY: 2}
|
||||
|
||||
def notify_time_entry_updated(self, entry_id: int, user_id: int, project_id: int) -> None:
|
||||
"""Notify that a time entry was updated"""
|
||||
try:
|
||||
dispatch_webhook(
|
||||
event=WebhookEvent.TIME_ENTRY_UPDATED.value,
|
||||
data={"entry_id": entry_id, "user_id": user_id, "project_id": project_id},
|
||||
@classmethod
|
||||
def dismiss(cls, user, kind: str, local_date: str) -> bool:
|
||||
if kind not in (KIND_NO_TRACKING, KIND_LONG_TIMER, KIND_DAILY_SUMMARY):
|
||||
return False
|
||||
if not local_date or len(local_date) != 10:
|
||||
return False
|
||||
existing = UserSmartNotificationDismissal.query.filter_by(
|
||||
user_id=user.id, local_date=local_date, kind=kind
|
||||
).first()
|
||||
if existing:
|
||||
return True
|
||||
db.session.add(
|
||||
UserSmartNotificationDismissal(
|
||||
user_id=user.id,
|
||||
local_date=local_date,
|
||||
kind=kind,
|
||||
dismissed_at=datetime.utcnow(),
|
||||
)
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Failed to dispatch time entry updated webhook: {e}")
|
||||
)
|
||||
safe_commit()
|
||||
return True
|
||||
|
||||
def notify_project_created(self, project_id: int, client_id: int) -> None:
|
||||
"""Notify that a project was created"""
|
||||
try:
|
||||
dispatch_webhook(
|
||||
event=WebhookEvent.PROJECT_CREATED.value, data={"project_id": project_id, "client_id": client_id}
|
||||
)
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Failed to dispatch project created webhook: {e}")
|
||||
@classmethod
|
||||
def build_for_user(cls, user, now_utc: Optional[datetime] = None) -> Dict[str, Any]:
|
||||
from app.utils.timezone import now_in_user_timezone
|
||||
|
||||
def notify_invoice_created(self, invoice_id: int, project_id: int, client_id: int) -> None:
|
||||
"""Notify that an invoice was created"""
|
||||
try:
|
||||
dispatch_webhook(
|
||||
event=WebhookEvent.INVOICE_CREATED.value,
|
||||
data={"invoice_id": invoice_id, "project_id": project_id, "client_id": client_id},
|
||||
)
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Failed to dispatch invoice created webhook: {e}")
|
||||
cfg = current_app.config
|
||||
max_per = int(cfg.get("SMART_NOTIFY_MAX_PER_DAY") or 2)
|
||||
slot_minutes = int(cfg.get("SMART_NOTIFY_SCHEDULER_SLOT_MINUTES") or 30)
|
||||
long_threshold_h = float(cfg.get("SMART_NOTIFY_LONG_TIMER_HOURS") or 4.0)
|
||||
|
||||
default_nudge = (cfg.get("SMART_NOTIFY_NO_TRACKING_AFTER") or "16:00").strip()
|
||||
default_summary = (cfg.get("SMART_NOTIFY_SUMMARY_AT") or "18:00").strip()
|
||||
|
||||
meta_base = {
|
||||
"max_per_day": max_per,
|
||||
"scheduler_slot_minutes": slot_minutes,
|
||||
"long_timer_hours": long_threshold_h,
|
||||
}
|
||||
|
||||
if not getattr(user, "is_active", True) or not getattr(user, "smart_notifications_enabled", False):
|
||||
start_utc, end_utc, local_date = user_local_today_bounds_utc(user)
|
||||
return {
|
||||
"notifications": [],
|
||||
"meta": {
|
||||
**meta_base,
|
||||
"local_date": local_date,
|
||||
"enabled": False,
|
||||
"no_tracking_after": (getattr(user, "smart_notify_no_tracking_after", None) or default_nudge),
|
||||
"summary_at": (getattr(user, "smart_notify_summary_at", None) or default_summary),
|
||||
"browser_push": bool(getattr(user, "smart_notify_browser", False)),
|
||||
},
|
||||
}
|
||||
|
||||
now_utc = now_utc or datetime.now(timezone.utc)
|
||||
if now_utc.tzinfo is None:
|
||||
now_utc = now_utc.replace(tzinfo=timezone.utc)
|
||||
|
||||
user_local_now = now_in_user_timezone(user)
|
||||
start_utc, end_utc, local_date = user_local_today_bounds_utc(user)
|
||||
|
||||
nudge_t = parse_hhmm(getattr(user, "smart_notify_no_tracking_after", None)) or parse_hhmm(default_nudge)
|
||||
summary_t = parse_hhmm(getattr(user, "smart_notify_summary_at", None)) or parse_hhmm(default_summary)
|
||||
if not nudge_t:
|
||||
nudge_t = (16, 0)
|
||||
if not summary_t:
|
||||
summary_t = (18, 0)
|
||||
|
||||
meta = {
|
||||
**meta_base,
|
||||
"local_date": local_date,
|
||||
"enabled": True,
|
||||
"no_tracking_after": f"{nudge_t[0]:02d}:{nudge_t[1]:02d}",
|
||||
"summary_at": f"{summary_t[0]:02d}:{summary_t[1]:02d}",
|
||||
"browser_push": bool(getattr(user, "smart_notify_browser", False)),
|
||||
}
|
||||
|
||||
dismissed = _dismissed_kinds(user.id, local_date)
|
||||
candidates: List[Dict[str, Any]] = []
|
||||
|
||||
# Long-running active timer
|
||||
if getattr(user, "smart_notify_long_timer", True) and KIND_LONG_TIMER not in dismissed:
|
||||
active = TimeEntry.get_user_active_timer(user.id)
|
||||
if active and active.start_time:
|
||||
start_u = _entry_start_as_utc_aware(active.start_time)
|
||||
if start_u:
|
||||
elapsed_h = (now_utc - start_u).total_seconds() / 3600.0
|
||||
if elapsed_h >= long_threshold_h:
|
||||
h = int(elapsed_h)
|
||||
m = int((elapsed_h - h) * 60)
|
||||
candidates.append(
|
||||
{
|
||||
"kind": KIND_LONG_TIMER,
|
||||
"title": "Timer still running",
|
||||
"message": (
|
||||
f"Your timer has been running for about {h}h {m}m — still active?"
|
||||
if h or m
|
||||
else f"Your timer has been running for {long_threshold_h:g}h or more — still active?"
|
||||
),
|
||||
"type": "warning",
|
||||
"priority": "high",
|
||||
}
|
||||
)
|
||||
|
||||
hours_today = _completed_hours_today(user.id, start_utc, end_utc)
|
||||
entry_count = _completed_entry_count_today(user.id, start_utc, end_utc)
|
||||
active_timer = TimeEntry.get_user_active_timer(user.id)
|
||||
|
||||
# No tracking today (time slot, no completed entries, no active timer)
|
||||
if getattr(user, "smart_notify_no_tracking", True) and KIND_NO_TRACKING not in dismissed:
|
||||
if _in_hour_slot(user_local_now, nudge_t[0], slot_minutes):
|
||||
if entry_count == 0 and active_timer is None:
|
||||
candidates.append(
|
||||
{
|
||||
"kind": KIND_NO_TRACKING,
|
||||
"title": "No time logged yet",
|
||||
"message": "You have not tracked anything today. Start a timer or add an entry.",
|
||||
"type": "info",
|
||||
"priority": "normal",
|
||||
}
|
||||
)
|
||||
|
||||
# Daily summary (time slot)
|
||||
if getattr(user, "smart_notify_daily_summary", True) and KIND_DAILY_SUMMARY not in dismissed:
|
||||
if _in_hour_slot(user_local_now, summary_t[0], slot_minutes):
|
||||
candidates.append(
|
||||
{
|
||||
"kind": KIND_DAILY_SUMMARY,
|
||||
"title": "Daily summary",
|
||||
"message": f"Today you logged {hours_today:.1f}h in completed entries.",
|
||||
"type": "success",
|
||||
"priority": "normal",
|
||||
}
|
||||
)
|
||||
|
||||
candidates.sort(key=lambda n: cls._PRIORITY.get(n["kind"], 99))
|
||||
notifications = candidates[:max_per]
|
||||
|
||||
return {"notifications": notifications, "meta": meta}
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
"""Aggregated productivity stats for the Value Dashboard (cached, SQL-efficient)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, time, timedelta
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from sqlalchemy import Integer, and_, case, func
|
||||
from sqlalchemy.orm import aliased
|
||||
|
||||
from app import db
|
||||
from app.config import Config
|
||||
from app.models import Client, Project, TimeEntry
|
||||
from app.models.time_entry import local_now
|
||||
from app.utils.cache_redis import cache_key, get_cache, set_cache
|
||||
from app.utils.overtime import get_week_start_for_date
|
||||
|
||||
_CACHE_PREFIX = "value_dashboard"
|
||||
_CACHE_TTL_SEC = 600
|
||||
|
||||
# SQLite strftime('%%w') and PostgreSQL EXTRACT(dow): 0=Sunday .. 6=Saturday
|
||||
_DOW_ENGLISH = ("Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday")
|
||||
|
||||
|
||||
class StatsService:
|
||||
"""Read-only aggregates over time entries for dashboard insights."""
|
||||
|
||||
@classmethod
|
||||
def get_value_dashboard(cls, user) -> Dict[str, Any]:
|
||||
"""Return value-dashboard payload for the given user (session user). Cached 10 min when Redis is up."""
|
||||
uid = int(getattr(user, "id", 0) or 0)
|
||||
key = cache_key(_CACHE_PREFIX, uid)
|
||||
cached = get_cache(key, default=None)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
payload = cls._compute_value_dashboard(user)
|
||||
set_cache(key, payload, ttl=_CACHE_TTL_SEC)
|
||||
return payload
|
||||
|
||||
@classmethod
|
||||
def _compute_value_dashboard(cls, user) -> Dict[str, Any]:
|
||||
from app.models.settings import Settings
|
||||
|
||||
user_id = int(user.id)
|
||||
now = local_now()
|
||||
today: date = now.date()
|
||||
week_start = get_week_start_for_date(today, user)
|
||||
month_start = today.replace(day=1)
|
||||
day_after_today = today + timedelta(days=1)
|
||||
range_start_date = today - timedelta(days=6)
|
||||
|
||||
end_exclusive = datetime.combine(day_after_today, time.min)
|
||||
week_start_dt = datetime.combine(week_start, time.min)
|
||||
month_start_dt = datetime.combine(month_start, time.min)
|
||||
range_start_dt = datetime.combine(range_start_date, time.min)
|
||||
|
||||
base_filter = and_(TimeEntry.user_id == user_id, TimeEntry.end_time.isnot(None))
|
||||
|
||||
week_cond = and_(TimeEntry.start_time >= week_start_dt, TimeEntry.start_time < end_exclusive)
|
||||
month_cond = and_(TimeEntry.start_time >= month_start_dt, TimeEntry.start_time < end_exclusive)
|
||||
|
||||
main_row = (
|
||||
db.session.query(
|
||||
func.coalesce(func.sum(TimeEntry.duration_seconds), 0).label("total_sec"),
|
||||
func.count(TimeEntry.id).label("entry_count"),
|
||||
func.count(func.distinct(func.date(TimeEntry.start_time))).label("active_days"),
|
||||
func.coalesce(
|
||||
func.sum(case((week_cond, TimeEntry.duration_seconds), else_=0)),
|
||||
0,
|
||||
).label("week_sec"),
|
||||
func.coalesce(
|
||||
func.sum(case((month_cond, TimeEntry.duration_seconds), else_=0)),
|
||||
0,
|
||||
).label("month_sec"),
|
||||
)
|
||||
.filter(base_filter)
|
||||
.one()
|
||||
)
|
||||
|
||||
total_sec = int(main_row.total_sec or 0)
|
||||
entries_count = int(main_row.entry_count or 0)
|
||||
active_days = int(main_row.active_days or 0)
|
||||
total_hours = round(total_sec / 3600.0, 2)
|
||||
this_week_hours = round(int(main_row.week_sec or 0) / 3600.0, 2)
|
||||
this_month_hours = round(int(main_row.month_sec or 0) / 3600.0, 2)
|
||||
avg_session_length = round(total_hours / entries_count, 2) if entries_count else 0.0
|
||||
|
||||
most_productive_day = cls._most_productive_day_english(base_filter)
|
||||
last_7_days = cls._last_7_days_hours(base_filter, range_start_dt, end_exclusive, range_start_date, today)
|
||||
estimated_value_tracked = cls._estimated_value_tracked(base_filter)
|
||||
|
||||
settings = Settings.get_settings()
|
||||
currency = (getattr(settings, "currency", None) or Config.CURRENCY or "EUR").strip()[:3] or "EUR"
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
"total_hours": total_hours,
|
||||
"entries_count": entries_count,
|
||||
"active_days": active_days,
|
||||
"avg_session_length": avg_session_length,
|
||||
"most_productive_day": most_productive_day,
|
||||
"this_week_hours": this_week_hours,
|
||||
"this_month_hours": this_month_hours,
|
||||
"last_7_days": last_7_days,
|
||||
"estimated_value_tracked": round(estimated_value_tracked, 2)
|
||||
if estimated_value_tracked and estimated_value_tracked > 0
|
||||
else None,
|
||||
"estimated_value_currency": currency,
|
||||
}
|
||||
return payload
|
||||
|
||||
@classmethod
|
||||
def _dow_expression(cls):
|
||||
bind = db.session.get_bind()
|
||||
dialect = (bind.dialect.name if bind else "") or ""
|
||||
if dialect == "sqlite":
|
||||
return func.strftime("%w", TimeEntry.start_time)
|
||||
return func.cast(func.extract("dow", TimeEntry.start_time), Integer)
|
||||
|
||||
@classmethod
|
||||
def _most_productive_day_english(cls, base_filter) -> Optional[str]:
|
||||
dow_col = cls._dow_expression().label("dow")
|
||||
rows = (
|
||||
db.session.query(dow_col, func.coalesce(func.sum(TimeEntry.duration_seconds), 0).label("sec"))
|
||||
.filter(base_filter)
|
||||
.group_by(dow_col)
|
||||
.all()
|
||||
)
|
||||
if not rows:
|
||||
return None
|
||||
best_dow: Optional[int] = None
|
||||
best_sec = -1
|
||||
for dow, sec in rows:
|
||||
s = int(sec or 0)
|
||||
if s > best_sec:
|
||||
best_sec = s
|
||||
try:
|
||||
di = int(dow) if dow is not None else None
|
||||
except (TypeError, ValueError):
|
||||
di = None
|
||||
best_dow = di
|
||||
if best_dow is None or best_sec <= 0:
|
||||
return None
|
||||
if 0 <= best_dow <= 6:
|
||||
return _DOW_ENGLISH[best_dow]
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _last_7_days_hours(
|
||||
cls,
|
||||
base_filter,
|
||||
range_start_dt: datetime,
|
||||
end_exclusive: datetime,
|
||||
range_start_date: date,
|
||||
today: date,
|
||||
) -> List[Dict[str, Any]]:
|
||||
q = (
|
||||
db.session.query(
|
||||
func.date(TimeEntry.start_time).label("day"),
|
||||
func.coalesce(func.sum(TimeEntry.duration_seconds), 0).label("sec"),
|
||||
)
|
||||
.filter(
|
||||
base_filter,
|
||||
TimeEntry.start_time >= range_start_dt,
|
||||
TimeEntry.start_time < end_exclusive,
|
||||
)
|
||||
.group_by(func.date(TimeEntry.start_time))
|
||||
)
|
||||
by_day: Dict[date, float] = {}
|
||||
for row in q:
|
||||
d = row.day
|
||||
if d is None:
|
||||
continue
|
||||
if hasattr(d, "date") and callable(getattr(d, "date")) and not isinstance(d, date):
|
||||
try:
|
||||
d = d.date()
|
||||
except Exception:
|
||||
continue
|
||||
elif isinstance(d, str):
|
||||
try:
|
||||
d = date.fromisoformat(d[:10])
|
||||
except ValueError:
|
||||
continue
|
||||
by_day[d] = round(int(row.sec or 0) / 3600.0, 2)
|
||||
|
||||
out: List[Dict[str, Any]] = []
|
||||
cur = range_start_date
|
||||
while cur <= today:
|
||||
out.append({"date": cur.isoformat(), "hours": by_day.get(cur, 0.0)})
|
||||
cur += timedelta(days=1)
|
||||
return out
|
||||
|
||||
@classmethod
|
||||
def _estimated_value_tracked(cls, base_filter) -> float:
|
||||
"""Sum (hours * effective rate) using project rate, else client defaults."""
|
||||
ClientDirect = aliased(Client)
|
||||
ClientProj = aliased(Client)
|
||||
hours = func.coalesce(TimeEntry.duration_seconds, 0) / 3600.0
|
||||
rate = func.coalesce(Project.hourly_rate, ClientDirect.default_hourly_rate, ClientProj.default_hourly_rate, 0)
|
||||
|
||||
total = (
|
||||
db.session.query(func.coalesce(func.sum(hours * rate), 0))
|
||||
.select_from(TimeEntry)
|
||||
.outerjoin(Project, Project.id == TimeEntry.project_id)
|
||||
.outerjoin(ClientDirect, ClientDirect.id == TimeEntry.client_id)
|
||||
.outerjoin(ClientProj, ClientProj.id == Project.client_id)
|
||||
.filter(base_filter)
|
||||
.scalar()
|
||||
)
|
||||
|
||||
return float(total or 0)
|
||||
|
||||
|
||||
# Expose for tests (bypass cache)
|
||||
def compute_value_dashboard_for_tests(user) -> Dict[str, Any]:
|
||||
return StatsService._compute_value_dashboard(user)
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Rules for soft, non-blocking support prompts (session-scoped)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
|
||||
class SupportPromptService:
|
||||
"""At most one soft prompt per session; respect supporter and donation-click cooldown."""
|
||||
|
||||
SESSION_SOFT_PROMPT_CONSUMED = "support_soft_prompt_consumed"
|
||||
SESSION_PROMPT_TRIGGER = "support_prompt_trigger"
|
||||
SESSION_SEVEN_DAY_OFFERED = "support_prompt_7d_offered"
|
||||
SESSION_ACTIVE_DAY_OFFERED = "support_prompt_active_day_offered"
|
||||
|
||||
VARIANT_AFTER_REPORT = "after_report"
|
||||
VARIANT_SEVEN_DAY = "seven_day"
|
||||
VARIANT_ACTIVE_TODAY = "active_today"
|
||||
VARIANT_LONG_SESSION = "long_session"
|
||||
|
||||
@staticmethod
|
||||
def _base_eligible(
|
||||
session: Dict[str, Any],
|
||||
*,
|
||||
ui_show_donate: bool,
|
||||
is_supporter: bool,
|
||||
support_banner_suppressed: bool,
|
||||
) -> bool:
|
||||
if not ui_show_donate:
|
||||
return False
|
||||
if is_supporter:
|
||||
return False
|
||||
if support_banner_suppressed:
|
||||
return False
|
||||
if session.get(SupportPromptService.SESSION_SOFT_PROMPT_CONSUMED):
|
||||
return False
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def consume_layout_prompt(
|
||||
session: Dict[str, Any],
|
||||
*,
|
||||
ui_show_donate: bool,
|
||||
is_supporter: bool,
|
||||
support_banner_suppressed: bool,
|
||||
) -> Optional[Dict[str, str]]:
|
||||
"""
|
||||
If the user just finished a report export, show one after-report toast on next full page load.
|
||||
Marks the session as having shown a soft prompt when returning a payload.
|
||||
"""
|
||||
if not SupportPromptService._base_eligible(
|
||||
session,
|
||||
ui_show_donate=ui_show_donate,
|
||||
is_supporter=is_supporter,
|
||||
support_banner_suppressed=support_banner_suppressed,
|
||||
):
|
||||
return None
|
||||
trigger = session.get(SupportPromptService.SESSION_PROMPT_TRIGGER)
|
||||
if trigger != SupportPromptService.VARIANT_AFTER_REPORT:
|
||||
return None
|
||||
session.pop(SupportPromptService.SESSION_PROMPT_TRIGGER, None)
|
||||
session[SupportPromptService.SESSION_SOFT_PROMPT_CONSUMED] = True
|
||||
return {"variant": SupportPromptService.VARIANT_AFTER_REPORT, "source": "after_report"}
|
||||
|
||||
@staticmethod
|
||||
def pick_dashboard_prompt(
|
||||
session: Dict[str, Any],
|
||||
user_stats: Dict[str, Any],
|
||||
*,
|
||||
ui_show_donate: bool,
|
||||
is_supporter: bool,
|
||||
support_banner_suppressed: bool,
|
||||
today_hours: float,
|
||||
) -> Optional[Dict[str, str]]:
|
||||
"""
|
||||
Eligible only on dashboard: milestone (7+ days since signup) or active tracking day.
|
||||
Does not consume session slot until caller records prompt shown (caller should set consumed).
|
||||
"""
|
||||
if not SupportPromptService._base_eligible(
|
||||
session,
|
||||
ui_show_donate=ui_show_donate,
|
||||
is_supporter=is_supporter,
|
||||
support_banner_suppressed=support_banner_suppressed,
|
||||
):
|
||||
return None
|
||||
# After-report takes priority; leave trigger for layout pass
|
||||
if session.get(SupportPromptService.SESSION_PROMPT_TRIGGER) == SupportPromptService.VARIANT_AFTER_REPORT:
|
||||
return None
|
||||
|
||||
days = int(user_stats.get("days_since_signup") or 0)
|
||||
if days >= 7 and not session.get(SupportPromptService.SESSION_SEVEN_DAY_OFFERED):
|
||||
return {"variant": SupportPromptService.VARIANT_SEVEN_DAY, "source": "dashboard"}
|
||||
|
||||
if float(today_hours or 0) >= 4.0 and not session.get(SupportPromptService.SESSION_ACTIVE_DAY_OFFERED):
|
||||
return {"variant": SupportPromptService.VARIANT_ACTIVE_TODAY, "source": "dashboard"}
|
||||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def mark_prompt_shown(session: Dict[str, Any], variant: str) -> None:
|
||||
session[SupportPromptService.SESSION_SOFT_PROMPT_CONSUMED] = True
|
||||
if variant == SupportPromptService.VARIANT_SEVEN_DAY:
|
||||
session[SupportPromptService.SESSION_SEVEN_DAY_OFFERED] = True
|
||||
elif variant == SupportPromptService.VARIANT_ACTIVE_TODAY:
|
||||
session[SupportPromptService.SESSION_ACTIVE_DAY_OFFERED] = True
|
||||
elif variant == SupportPromptService.VARIANT_LONG_SESSION:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def long_session_prompt_allowed(
|
||||
session: Dict[str, Any],
|
||||
*,
|
||||
ui_show_donate: bool,
|
||||
is_supporter: bool,
|
||||
support_banner_suppressed: bool,
|
||||
) -> bool:
|
||||
"""JSON endpoint: allow long-session nudge only if no prompt consumed yet this session."""
|
||||
if not SupportPromptService._base_eligible(
|
||||
session,
|
||||
ui_show_donate=ui_show_donate,
|
||||
is_supporter=is_supporter,
|
||||
support_banner_suppressed=support_banner_suppressed,
|
||||
):
|
||||
return False
|
||||
if session.get(SupportPromptService.SESSION_PROMPT_TRIGGER) == SupportPromptService.VARIANT_AFTER_REPORT:
|
||||
return False
|
||||
return True
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Aggregated usage stats for support modal, dashboard widget, and prompts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from app import db
|
||||
|
||||
|
||||
class UsageStatsService:
|
||||
"""Read/write lightweight counters and engagement metrics for support UI."""
|
||||
|
||||
@staticmethod
|
||||
def get_for_user(user_id: int, month_hours: Optional[float] = None) -> Dict[str, Any]:
|
||||
from app.models import DonationInteraction, User
|
||||
|
||||
base = DonationInteraction.get_user_engagement_metrics(user_id) or {}
|
||||
reports_count = 0
|
||||
try:
|
||||
u = db.session.get(User, user_id)
|
||||
if u is not None:
|
||||
reports_count = int(getattr(u, "support_stats_reports_generated", 0) or 0)
|
||||
except Exception:
|
||||
reports_count = 0
|
||||
|
||||
out = {
|
||||
"total_hours": float(base.get("total_hours") or 0.0),
|
||||
"time_entries_count": int(base.get("time_entries_count") or 0),
|
||||
"days_since_signup": int(base.get("days_since_signup") or 0),
|
||||
"reports_generated_count": reports_count,
|
||||
}
|
||||
if month_hours is not None:
|
||||
out["month_hours"] = float(month_hours)
|
||||
return out
|
||||
|
||||
@staticmethod
|
||||
def increment_reports_generated(user_id: int) -> None:
|
||||
"""Persist +1 report generation (export or custom report view). Never raises."""
|
||||
if not user_id:
|
||||
return
|
||||
try:
|
||||
from sqlalchemy import text
|
||||
|
||||
db.session.execute(
|
||||
text(
|
||||
"UPDATE users SET support_stats_reports_generated = "
|
||||
"COALESCE(support_stats_reports_generated, 0) + 1 WHERE id = :uid"
|
||||
),
|
||||
{"uid": user_id},
|
||||
)
|
||||
db.session.commit()
|
||||
except Exception:
|
||||
try:
|
||||
db.session.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
@@ -0,0 +1,250 @@
|
||||
"""Fetch and compare app version to latest GitHub release (admin update notification)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
from flask import current_app
|
||||
|
||||
from app.config.analytics_defaults import get_version_from_setup
|
||||
from app.models.user import User
|
||||
from app.schemas.version_check import VersionCheckResponse
|
||||
from app.utils.cache import get_cache
|
||||
from app.utils.version_compare import is_upgrade, normalize_version_tag
|
||||
|
||||
HOT_CACHE_PREFIX = "version_check:github:"
|
||||
STALE_CACHE_PREFIX = "version_check:github_stale:"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GithubReleaseData:
|
||||
latest_version: str
|
||||
release_notes: str
|
||||
published_at: str
|
||||
release_url: str
|
||||
|
||||
|
||||
def _release_to_dict(r: GithubReleaseData) -> dict[str, str]:
|
||||
return {
|
||||
"latest_version": r.latest_version,
|
||||
"release_notes": r.release_notes,
|
||||
"published_at": r.published_at,
|
||||
"release_url": r.release_url,
|
||||
}
|
||||
|
||||
|
||||
def _dict_to_release(d: dict[str, Any]) -> GithubReleaseData | None:
|
||||
try:
|
||||
lv = d.get("latest_version")
|
||||
if not isinstance(lv, str) or not lv:
|
||||
return None
|
||||
return GithubReleaseData(
|
||||
latest_version=lv,
|
||||
release_notes=str(d.get("release_notes") or ""),
|
||||
published_at=str(d.get("published_at") or ""),
|
||||
release_url=str(d.get("release_url") or ""),
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _github_headers() -> dict[str, str]:
|
||||
headers = {
|
||||
"Accept": "application/vnd.github+json",
|
||||
"User-Agent": "TimeTracker-VersionCheck/1.0",
|
||||
}
|
||||
token = current_app.config.get("GITHUB_RELEASES_TOKEN")
|
||||
if token:
|
||||
headers["Authorization"] = f"token {token}"
|
||||
return headers
|
||||
|
||||
|
||||
def parse_release_object(data: dict[str, Any]) -> GithubReleaseData | None:
|
||||
"""Parse a single GitHub release JSON object."""
|
||||
tag = data.get("tag_name")
|
||||
if not isinstance(tag, str):
|
||||
current_app.logger.warning("Version check: GitHub release missing tag_name: %r", tag)
|
||||
return None
|
||||
norm = normalize_version_tag(tag)
|
||||
if not norm:
|
||||
current_app.logger.warning("Version check: invalid tag_name for semver: %r", tag)
|
||||
return None
|
||||
body = data.get("body")
|
||||
notes = body if isinstance(body, str) else ""
|
||||
pub = data.get("published_at")
|
||||
published = pub if isinstance(pub, str) else ""
|
||||
url = data.get("html_url")
|
||||
release_url = url if isinstance(url, str) else ""
|
||||
return GithubReleaseData(
|
||||
latest_version=norm,
|
||||
release_notes=notes,
|
||||
published_at=published,
|
||||
release_url=release_url,
|
||||
)
|
||||
|
||||
|
||||
def resolve_current_installed_version() -> tuple[str | None, str]:
|
||||
"""
|
||||
Returns (normalized_semver_or_none, display_current_version_string).
|
||||
Prefer APP_VERSION from config when it normalizes to semver; else setup.py.
|
||||
"""
|
||||
raw = (current_app.config.get("APP_VERSION") or "").strip()
|
||||
if raw:
|
||||
norm = normalize_version_tag(raw)
|
||||
if norm:
|
||||
return norm, raw
|
||||
raw_setup = get_version_from_setup() or ""
|
||||
norm = normalize_version_tag(raw_setup)
|
||||
if norm:
|
||||
return norm, raw_setup
|
||||
current_app.logger.warning(
|
||||
"Version check: no comparable semver for current install (APP_VERSION=%r, setup.py=%r)",
|
||||
current_app.config.get("APP_VERSION"),
|
||||
raw_setup,
|
||||
)
|
||||
return None, raw or raw_setup or "unknown"
|
||||
|
||||
|
||||
class VersionService:
|
||||
"""GitHub latest release + caching + semver comparison for admin update prompts."""
|
||||
|
||||
@staticmethod
|
||||
def _cache_keys(repo: str) -> tuple[str, str]:
|
||||
safe = repo.replace("/", ":")
|
||||
return f"{HOT_CACHE_PREFIX}{safe}", f"{STALE_CACHE_PREFIX}{safe}"
|
||||
|
||||
@classmethod
|
||||
def _fetch_from_github_api(cls) -> GithubReleaseData | None:
|
||||
repo = (current_app.config.get("VERSION_CHECK_GITHUB_REPO") or "DRYTRIX/TimeTracker").strip()
|
||||
timeout = int(current_app.config.get("VERSION_CHECK_HTTP_TIMEOUT") or 10)
|
||||
include_prerelease = bool(current_app.config.get("ENABLE_PRE_RELEASE_NOTIFICATIONS"))
|
||||
|
||||
if include_prerelease:
|
||||
url = f"https://api.github.com/repos/{repo}/releases?per_page=20"
|
||||
else:
|
||||
url = f"https://api.github.com/repos/{repo}/releases/latest"
|
||||
|
||||
try:
|
||||
resp = requests.get(url, headers=_github_headers(), timeout=timeout)
|
||||
except requests.RequestException as exc:
|
||||
current_app.logger.error("Version check: GitHub request failed: %s", exc)
|
||||
return None
|
||||
|
||||
if resp.status_code == 403:
|
||||
current_app.logger.warning(
|
||||
"Version check: GitHub returned 403 (rate limit or forbidden); body_snippet=%r",
|
||||
(resp.text or "")[:200],
|
||||
)
|
||||
return None
|
||||
if resp.status_code >= 500:
|
||||
current_app.logger.warning(
|
||||
"Version check: GitHub server error %s; body_snippet=%r",
|
||||
resp.status_code,
|
||||
(resp.text or "")[:200],
|
||||
)
|
||||
return None
|
||||
if resp.status_code != 200:
|
||||
current_app.logger.warning(
|
||||
"Version check: GitHub unexpected status %s; body_snippet=%r",
|
||||
resp.status_code,
|
||||
(resp.text or "")[:200],
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
payload = resp.json()
|
||||
except json.JSONDecodeError as exc:
|
||||
current_app.logger.error("Version check: invalid JSON from GitHub: %s", exc)
|
||||
return None
|
||||
|
||||
if include_prerelease:
|
||||
if not isinstance(payload, list):
|
||||
current_app.logger.error("Version check: expected JSON list for releases, got %s", type(payload))
|
||||
return None
|
||||
for item in payload:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
if item.get("draft"):
|
||||
continue
|
||||
parsed = parse_release_object(item)
|
||||
if parsed:
|
||||
return parsed
|
||||
current_app.logger.warning("Version check: no usable release in GitHub list response")
|
||||
return None
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
current_app.logger.error("Version check: expected JSON object for latest release, got %s", type(payload))
|
||||
return None
|
||||
return parse_release_object(payload)
|
||||
|
||||
@classmethod
|
||||
def get_latest_release(cls) -> GithubReleaseData | None:
|
||||
"""Return latest release metadata, using hot cache, then network, then stale cache."""
|
||||
repo = (current_app.config.get("VERSION_CHECK_GITHUB_REPO") or "DRYTRIX/TimeTracker").strip()
|
||||
hot_key, stale_key = cls._cache_keys(repo)
|
||||
hot_ttl = int(current_app.config.get("VERSION_CHECK_GITHUB_CACHE_TTL") or 43200)
|
||||
stale_ttl = int(current_app.config.get("VERSION_CHECK_GITHUB_STALE_TTL") or 604800)
|
||||
cache = get_cache()
|
||||
|
||||
cached_hot = cache.get(hot_key)
|
||||
if isinstance(cached_hot, dict):
|
||||
parsed = _dict_to_release(cached_hot)
|
||||
if parsed:
|
||||
return parsed
|
||||
|
||||
fresh = cls._fetch_from_github_api()
|
||||
if fresh:
|
||||
as_dict = _release_to_dict(fresh)
|
||||
try:
|
||||
cache.set(hot_key, as_dict, ttl=hot_ttl)
|
||||
cache.set(stale_key, as_dict, ttl=stale_ttl)
|
||||
except Exception as exc:
|
||||
current_app.logger.warning("Version check: failed to write cache: %s", exc)
|
||||
return fresh
|
||||
|
||||
cached_stale = cache.get(stale_key)
|
||||
if isinstance(cached_stale, dict):
|
||||
parsed = _dict_to_release(cached_stale)
|
||||
if parsed:
|
||||
current_app.logger.warning("Version check: returning stale cached GitHub release after fetch failure")
|
||||
return parsed
|
||||
|
||||
current_app.logger.warning("Version check: no GitHub data and no stale cache available")
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def build_check_response(cls, user: User | None) -> VersionCheckResponse:
|
||||
current_norm, current_display = resolve_current_installed_version()
|
||||
release = cls.get_latest_release()
|
||||
|
||||
latest_version: str | None = None
|
||||
release_notes: str | None = None
|
||||
published_at: str | None = None
|
||||
release_url: str | None = None
|
||||
|
||||
if release:
|
||||
latest_version = release.latest_version
|
||||
release_notes = release.release_notes or ""
|
||||
published_at = release.published_at or None
|
||||
release_url = release.release_url or None
|
||||
|
||||
update_available = False
|
||||
if current_norm and latest_version:
|
||||
update_available = is_upgrade(current_norm, latest_version)
|
||||
|
||||
if update_available and user is not None and user.dismissed_release_version:
|
||||
dismissed_norm = normalize_version_tag(user.dismissed_release_version)
|
||||
if dismissed_norm and latest_version and dismissed_norm == latest_version:
|
||||
update_available = False
|
||||
|
||||
return VersionCheckResponse(
|
||||
update_available=update_available,
|
||||
current_version=current_display,
|
||||
latest_version=latest_version,
|
||||
release_notes=release_notes,
|
||||
published_at=published_at,
|
||||
release_url=release_url,
|
||||
)
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* Admin-only: fetch /api/version/check and show a non-blocking update card.
|
||||
*/
|
||||
(function () {
|
||||
var LS_KEY = "tt_dismissed_release_version";
|
||||
var NOTE_PREVIEW_LEN = 280;
|
||||
|
||||
function getCsrfToken() {
|
||||
var m = document.querySelector('meta[name="csrf-token"]');
|
||||
return m ? m.getAttribute("content") || "" : "";
|
||||
}
|
||||
|
||||
function localDismissedMatches(latest) {
|
||||
try {
|
||||
return latest && localStorage.getItem(LS_KEY) === latest;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function setLocalDismissed(latest) {
|
||||
try {
|
||||
if (latest) localStorage.setItem(LS_KEY, latest);
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function hide(root) {
|
||||
if (root) root.classList.add("hidden");
|
||||
}
|
||||
|
||||
function show(root) {
|
||||
if (root) root.classList.remove("hidden");
|
||||
}
|
||||
|
||||
function postDismiss(latest, onDone) {
|
||||
fetch("/api/version/dismiss", {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRFToken": getCsrfToken(),
|
||||
},
|
||||
body: JSON.stringify({ latest_version: latest }),
|
||||
})
|
||||
.then(function (r) {
|
||||
return r.json().then(function (j) {
|
||||
return { ok: r.ok, json: j };
|
||||
});
|
||||
})
|
||||
.then(function (res) {
|
||||
if (typeof onDone === "function") onDone(res.ok);
|
||||
})
|
||||
.catch(function () {
|
||||
if (typeof onDone === "function") onDone(false);
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
var root = document.getElementById("adminVersionUpdateRoot");
|
||||
if (!root) return;
|
||||
|
||||
fetch("/api/version/check", { credentials: "same-origin" })
|
||||
.then(function (r) {
|
||||
if (r.status === 401 || r.status === 403) return null;
|
||||
return r.json();
|
||||
})
|
||||
.then(function (data) {
|
||||
if (!data || !data.latest_version) return;
|
||||
if (localDismissedMatches(data.latest_version)) return;
|
||||
if (!data.update_available) return;
|
||||
|
||||
var title = document.getElementById("adminVersionUpdateTitle");
|
||||
var published = document.getElementById("adminVersionUpdatePublished");
|
||||
var notesEl = document.getElementById("adminVersionUpdateNotes");
|
||||
var readMore = document.getElementById("adminVersionUpdateReadMore");
|
||||
var viewLink = document.getElementById("adminVersionUpdateViewRelease");
|
||||
var closeBtn = document.getElementById("adminVersionUpdateClose");
|
||||
var dismissBtn = document.getElementById("adminVersionUpdateDismiss");
|
||||
var dismissVerBtn = document.getElementById("adminVersionUpdateDismissVersion");
|
||||
|
||||
if (title) {
|
||||
title.textContent =
|
||||
String.fromCodePoint(0x1f680) + " New version available: " + data.latest_version;
|
||||
}
|
||||
|
||||
if (published) {
|
||||
if (data.published_at) {
|
||||
try {
|
||||
var d = new Date(data.published_at);
|
||||
published.textContent = d.toLocaleString(undefined, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
});
|
||||
} catch (e) {
|
||||
published.textContent = data.published_at;
|
||||
}
|
||||
} else {
|
||||
published.textContent = "";
|
||||
}
|
||||
}
|
||||
|
||||
var notes = data.release_notes || "";
|
||||
var expanded = false;
|
||||
function renderNotes() {
|
||||
if (!notesEl) return;
|
||||
if (!notes) {
|
||||
notesEl.textContent = "";
|
||||
if (readMore) readMore.classList.add("hidden");
|
||||
return;
|
||||
}
|
||||
if (expanded || notes.length <= NOTE_PREVIEW_LEN) {
|
||||
notesEl.textContent = notes;
|
||||
if (readMore) readMore.classList.add("hidden");
|
||||
} else {
|
||||
notesEl.textContent = notes.slice(0, NOTE_PREVIEW_LEN).trimEnd() + "\u2026";
|
||||
if (readMore) {
|
||||
readMore.classList.remove("hidden");
|
||||
readMore.onclick = function () {
|
||||
expanded = true;
|
||||
notesEl.textContent = notes;
|
||||
readMore.classList.add("hidden");
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
renderNotes();
|
||||
|
||||
if (viewLink) {
|
||||
if (data.release_url) {
|
||||
viewLink.href = data.release_url;
|
||||
viewLink.classList.remove("pointer-events-none", "opacity-50");
|
||||
} else {
|
||||
viewLink.href = "#";
|
||||
viewLink.classList.add("pointer-events-none", "opacity-50");
|
||||
}
|
||||
}
|
||||
|
||||
function wireClose() {
|
||||
hide(root);
|
||||
}
|
||||
if (closeBtn) closeBtn.addEventListener("click", wireClose);
|
||||
if (dismissBtn) dismissBtn.addEventListener("click", wireClose);
|
||||
if (dismissVerBtn) {
|
||||
dismissVerBtn.addEventListener("click", function () {
|
||||
postDismiss(data.latest_version, function () {
|
||||
hide(root);
|
||||
setLocalDismissed(data.latest_version);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
show(root);
|
||||
})
|
||||
.catch(function () {});
|
||||
});
|
||||
})();
|
||||
@@ -21,13 +21,15 @@
|
||||
function isDashboardPage() {
|
||||
return window.location.pathname === '/dashboard' ||
|
||||
document.getElementById('todayHoursValue') != null ||
|
||||
document.querySelector('[data-sparkline]') != null;
|
||||
document.querySelector('[data-sparkline]') != null ||
|
||||
document.getElementById('valueDashboardRoot') != null;
|
||||
}
|
||||
|
||||
function init() {
|
||||
initSparklines();
|
||||
initActivityTimeline();
|
||||
initRealTimeUpdates();
|
||||
initValueDashboard();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -307,6 +309,8 @@
|
||||
|
||||
// Update sparklines
|
||||
await updateSparklines();
|
||||
|
||||
await loadValueDashboard();
|
||||
} catch (error) {
|
||||
console.error('Error updating dashboard:', error);
|
||||
}
|
||||
@@ -424,6 +428,129 @@
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Value Dashboard widget (/api/stats/value-dashboard)
|
||||
*/
|
||||
function initValueDashboard() {
|
||||
loadValueDashboard();
|
||||
}
|
||||
|
||||
async function loadValueDashboard() {
|
||||
const root = document.getElementById('valueDashboardRoot');
|
||||
if (!root) return;
|
||||
|
||||
const loadingEl = document.getElementById('valueDashboardLoading');
|
||||
const emptyEl = document.getElementById('valueDashboardEmpty');
|
||||
const emptyTextEl = document.getElementById('valueDashboardEmptyText');
|
||||
const contentEl = document.getElementById('valueDashboardContent');
|
||||
|
||||
if (loadingEl) {
|
||||
loadingEl.classList.remove('hidden');
|
||||
loadingEl.textContent = root.getAttribute('data-loading-msg') || 'Loading…';
|
||||
}
|
||||
if (emptyEl) emptyEl.classList.add('hidden');
|
||||
if (contentEl) contentEl.classList.add('hidden');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/stats/value-dashboard', { credentials: 'same-origin' });
|
||||
if (!response.ok) {
|
||||
throw new Error('value-dashboard failed');
|
||||
}
|
||||
const data = await response.json();
|
||||
|
||||
if (loadingEl) loadingEl.classList.add('hidden');
|
||||
|
||||
const entries = Number(data.entries_count) || 0;
|
||||
const totalH = Number(data.total_hours) || 0;
|
||||
if (entries === 0 || totalH <= 0) {
|
||||
if (emptyEl) emptyEl.classList.remove('hidden');
|
||||
if (emptyTextEl) {
|
||||
emptyTextEl.textContent = root.getAttribute('data-empty-msg') || '';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (contentEl) contentEl.classList.remove('hidden');
|
||||
|
||||
const th = document.getElementById('valueDashboardTotalHours');
|
||||
if (th) th.textContent = Number(data.total_hours).toFixed(1);
|
||||
const ec = document.getElementById('valueDashboardEntriesCount');
|
||||
if (ec) ec.textContent = String(entries);
|
||||
const ad = document.getElementById('valueDashboardActiveDays');
|
||||
if (ad) ad.textContent = String(Number(data.active_days) || 0);
|
||||
|
||||
const mpd = document.getElementById('valueDashboardMostProductiveDay');
|
||||
if (mpd) mpd.textContent = data.most_productive_day || '—';
|
||||
|
||||
const avg = document.getElementById('valueDashboardAvgSession');
|
||||
if (avg) avg.textContent = Number(data.avg_session_length).toFixed(1);
|
||||
|
||||
renderValueDashboardChart(document.getElementById('valueDashboardChart'), data.last_7_days || []);
|
||||
|
||||
const estWrap = document.getElementById('valueDashboardEstimated');
|
||||
const estAmt = document.getElementById('valueDashboardEstimatedAmount');
|
||||
const estCur = document.getElementById('valueDashboardCurrency');
|
||||
if (data.estimated_value_tracked != null && data.estimated_value_tracked > 0) {
|
||||
if (estWrap) estWrap.classList.remove('hidden');
|
||||
if (estCur) estCur.textContent = data.estimated_value_currency || 'EUR';
|
||||
if (estAmt) estAmt.textContent = Number(data.estimated_value_tracked).toFixed(2);
|
||||
} else if (estWrap) {
|
||||
estWrap.classList.add('hidden');
|
||||
}
|
||||
|
||||
const sup = document.getElementById('valueDashboardSupport');
|
||||
if (sup) {
|
||||
sup.textContent = root.getAttribute('data-support-msg') || '';
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Value dashboard load error', e);
|
||||
if (loadingEl) {
|
||||
loadingEl.classList.remove('hidden');
|
||||
loadingEl.textContent = '—';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderValueDashboardChart(container, series) {
|
||||
if (!container) return;
|
||||
container.innerHTML = '';
|
||||
if (!Array.isArray(series) || series.length === 0) return;
|
||||
|
||||
const hours = series.map(function (d) { return Number(d.hours) || 0; });
|
||||
var maxH = Math.max.apply(null, hours.concat([0.01]));
|
||||
var maxBarPx = 88;
|
||||
|
||||
series.forEach(function (day) {
|
||||
var h = Number(day.hours) || 0;
|
||||
var barH = Math.max(3, Math.round((h / maxH) * maxBarPx));
|
||||
var col = document.createElement('div');
|
||||
col.className = 'flex-1 flex flex-col items-center justify-end min-w-0';
|
||||
|
||||
var bar = document.createElement('div');
|
||||
bar.className = 'w-full max-w-[3rem] mx-auto rounded-t-md bg-primary/80 dark:bg-primary/60 transition-all';
|
||||
bar.style.height = barH + 'px';
|
||||
bar.style.minHeight = '3px';
|
||||
bar.title = (day.date || '') + ': ' + h.toFixed(1) + ' h';
|
||||
|
||||
var lbl = document.createElement('span');
|
||||
lbl.className = 'mt-2 text-[10px] sm:text-xs text-text-muted-light dark:text-text-muted-dark truncate w-full text-center';
|
||||
try {
|
||||
var dt = new Date((day.date || '') + 'T12:00:00');
|
||||
lbl.textContent = dt.toLocaleDateString(undefined, { weekday: 'short' });
|
||||
} catch (err) {
|
||||
lbl.textContent = (day.date || '').slice(5);
|
||||
}
|
||||
|
||||
var barOuter = document.createElement('div');
|
||||
barOuter.className = 'w-full flex items-end justify-center';
|
||||
barOuter.style.height = maxBarPx + 'px';
|
||||
barOuter.appendChild(bar);
|
||||
col.appendChild(barOuter);
|
||||
col.appendChild(lbl);
|
||||
container.appendChild(col);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup on page unload
|
||||
*/
|
||||
@@ -437,7 +564,8 @@
|
||||
window.DashboardEnhancements = {
|
||||
createSparkline,
|
||||
loadActivityTimeline,
|
||||
updateDashboardData
|
||||
updateDashboardData,
|
||||
loadValueDashboard
|
||||
};
|
||||
|
||||
})();
|
||||
|
||||
@@ -9,6 +9,9 @@ class SmartNotificationManager {
|
||||
this.preferences = this.loadPreferences();
|
||||
this.queue = [];
|
||||
this.permissionGranted = false;
|
||||
/** @type {Set<string>} dedupe server-driven toasts per localDate:kind in this tab session */
|
||||
this._serverSmartShown = new Set();
|
||||
this._serverSmartPollMs = (typeof window !== 'undefined' && window.SMART_NOTIFY_POLL_MS) || 600000;
|
||||
this.init();
|
||||
}
|
||||
|
||||
@@ -18,7 +21,7 @@ class SmartNotificationManager {
|
||||
this.setupServiceWorkerMessaging();
|
||||
this.checkIdleTime();
|
||||
this.checkDeadlines();
|
||||
this.checkDailySummary();
|
||||
this.startServerSmartNotificationsPolling();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -351,108 +354,101 @@ class SmartNotificationManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Daily summary
|
||||
checkDailySummary() {
|
||||
const storageKey = 'smart_notifications_last_daily_summary';
|
||||
const getTodayKey = () => {
|
||||
const d = new Date();
|
||||
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
|
||||
};
|
||||
/**
|
||||
* Poll server for smart notifications (no-tracking nudge, long timer, daily summary).
|
||||
* Timing and copy come from the server; dismissals sync via POST /api/notifications/dismiss.
|
||||
*/
|
||||
startServerSmartNotificationsPolling() {
|
||||
try {
|
||||
const targetHour = 18; // 6 PM
|
||||
|
||||
const sendSummary = () => {
|
||||
try {
|
||||
const now = new Date();
|
||||
const hour = now.getHours();
|
||||
const todayKey = getTodayKey();
|
||||
let lastSent = null;
|
||||
try {
|
||||
lastSent = localStorage.getItem(storageKey);
|
||||
} catch (e) { /* ignore */ }
|
||||
if (hour === targetHour && lastSent !== todayKey) {
|
||||
this.sendDailySummary();
|
||||
try {
|
||||
localStorage.setItem(storageKey, todayKey);
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[SmartNotifications] Error in daily summary check:', error);
|
||||
}
|
||||
};
|
||||
|
||||
setInterval(sendSummary, 60 * 60 * 1000);
|
||||
const now = new Date();
|
||||
if (now.getHours() === targetHour) {
|
||||
sendSummary();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[SmartNotifications] Error initializing daily summary check:', error);
|
||||
setTimeout(() => this.pollServerSmartNotifications(), 12000);
|
||||
setInterval(() => this.pollServerSmartNotifications(), this._serverSmartPollMs);
|
||||
} catch (e) {
|
||||
console.error('[SmartNotifications] server poll init:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async sendDailySummary() {
|
||||
if (!this.preferences.dailySummary) return;
|
||||
|
||||
async pollServerSmartNotifications() {
|
||||
try {
|
||||
// Check if fetch is available
|
||||
if (typeof navigator !== 'undefined' && navigator.onLine === false) {
|
||||
return;
|
||||
}
|
||||
if (typeof fetch === 'undefined') {
|
||||
console.warn('[SmartNotifications] Fetch not available for daily summary');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch('/api/summary/today', {
|
||||
const res = await fetch('/api/notifications', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
credentials: 'same-origin',
|
||||
headers: { Accept: 'application/json' }
|
||||
});
|
||||
|
||||
// Check if response is OK before reading body
|
||||
if (!response.ok) {
|
||||
// Log status but don't throw error (404 is expected if endpoint doesn't exist)
|
||||
if (response.status !== 404) {
|
||||
console.warn('[SmartNotifications] Daily summary check failed:', response.status, response.statusText);
|
||||
}
|
||||
if (!res.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const summary = await response.json();
|
||||
const data = await res.json();
|
||||
const meta = data.meta || {};
|
||||
if (!meta.enabled) {
|
||||
return;
|
||||
}
|
||||
const localDate = meta.local_date || '';
|
||||
const list = data.notifications || [];
|
||||
const csrf = (typeof document !== 'undefined' && document.querySelector('meta[name="csrf-token"]'))
|
||||
? document.querySelector('meta[name="csrf-token"]').content
|
||||
: '';
|
||||
|
||||
// Safely extract values with defaults
|
||||
const hours = (summary && typeof summary.hours === 'number')
|
||||
? summary.hours
|
||||
: (summary && summary.hours ? Number(summary.hours) : 0);
|
||||
const projects = (summary && typeof summary.projects === 'number')
|
||||
? summary.projects
|
||||
: (summary && summary.projects ? Number(summary.projects) : 0);
|
||||
for (const n of list) {
|
||||
if (!n || !n.kind) {
|
||||
continue;
|
||||
}
|
||||
const dedupeKey = `${localDate}:${n.kind}`;
|
||||
if (this._serverSmartShown.has(dedupeKey)) {
|
||||
continue;
|
||||
}
|
||||
this._serverSmartShown.add(dedupeKey);
|
||||
|
||||
// Build a safe, human-friendly message
|
||||
const hoursText = isNaN(hours) || hours < 0 ? '0' : hours.toFixed(1);
|
||||
const projectsText = isNaN(projects) || projects < 0 ? '0' : String(projects);
|
||||
const message = `Today you logged ${hoursText}h across ${projectsText} project${projects !== 1 ? 's' : ''}. Great work!`;
|
||||
const dismissToServer = () => {
|
||||
fetch('/api/notifications/dismiss', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': csrf
|
||||
},
|
||||
body: JSON.stringify({ kind: n.kind, local_date: localDate })
|
||||
}).catch(() => {});
|
||||
};
|
||||
|
||||
// Auto-dismiss after 8s; no permanent sticky summary to avoid lingering toasts
|
||||
if (window.toastManager && typeof window.toastManager.show === 'function') {
|
||||
window.toastManager.show({
|
||||
message: message,
|
||||
title: 'Daily Summary',
|
||||
type: 'success',
|
||||
duration: 8000
|
||||
});
|
||||
} else {
|
||||
this.show({
|
||||
title: 'Daily Summary',
|
||||
message,
|
||||
type: 'success',
|
||||
priority: 'normal',
|
||||
persistent: false
|
||||
});
|
||||
if (window.toastManager && typeof window.toastManager.show === 'function') {
|
||||
window.toastManager.show({
|
||||
title: n.title || '',
|
||||
message: n.message || '',
|
||||
type: n.type || 'info',
|
||||
duration: 12000,
|
||||
onDismiss: () => {
|
||||
dismissToServer();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
this.show({
|
||||
title: n.title,
|
||||
message: n.message,
|
||||
type: n.type || 'info',
|
||||
priority: n.priority || 'normal',
|
||||
persistent: false
|
||||
});
|
||||
}
|
||||
|
||||
if (meta.browser_push && this.permissionGranted && (n.priority || '') !== 'low') {
|
||||
this.showBrowserNotification({
|
||||
id: `smart_${n.kind}_${localDate}`,
|
||||
title: n.title,
|
||||
message: n.message,
|
||||
group: `smart_${n.kind}`,
|
||||
actions: []
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Only log if it's not a network/abort error (which are expected in some cases)
|
||||
if (error.name !== 'AbortError' && error.name !== 'TypeError') {
|
||||
console.error('[SmartNotifications] Error fetching daily summary:', error);
|
||||
console.debug('[SmartNotifications] server poll:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* Support modal, header pulse, offline-aware outbound links, soft prompts.
|
||||
* Copy lives in Jinja / JSON (support_ui_json); this file is behavior only.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
function getCsrfToken() {
|
||||
var meta = document.querySelector('meta[name="csrf-token"]');
|
||||
return meta ? meta.getAttribute('content') || '' : '';
|
||||
}
|
||||
|
||||
function parseSupportConfig() {
|
||||
var el = document.getElementById('support-ui-bootstrap');
|
||||
if (!el || !el.textContent) return null;
|
||||
try {
|
||||
return JSON.parse(el.textContent);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function postTrack(cfg, event, extra) {
|
||||
if (!cfg || !cfg.trackUrl) return;
|
||||
var body = Object.assign({ event: event }, extra || {});
|
||||
fetch(cfg.trackUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': getCsrfToken()
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
credentials: 'same-origin'
|
||||
}).catch(function () {});
|
||||
}
|
||||
|
||||
function applyOfflineState(cfg) {
|
||||
var offlineEl = document.getElementById('supportModalOffline');
|
||||
var tierBtns = document.querySelectorAll('a.support-tier-btn');
|
||||
var online = typeof navigator !== 'undefined' && navigator.onLine;
|
||||
if (!offlineEl) return;
|
||||
if (!online && cfg && cfg.i18n && cfg.i18n.offlineNote) {
|
||||
offlineEl.textContent = cfg.i18n.offlineNote;
|
||||
offlineEl.classList.remove('hidden');
|
||||
tierBtns.forEach(function (a) {
|
||||
a.setAttribute('tabindex', '-1');
|
||||
a.classList.add('pointer-events-none', 'opacity-50');
|
||||
});
|
||||
} else {
|
||||
offlineEl.classList.add('hidden');
|
||||
tierBtns.forEach(function (a) {
|
||||
a.removeAttribute('tabindex');
|
||||
a.classList.remove('pointer-events-none', 'opacity-50');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function wireTierLinks(cfg) {
|
||||
if (!cfg || !cfg.urls) return;
|
||||
document.querySelectorAll('a.support-tier-btn[data-support-tier]').forEach(function (a) {
|
||||
var key = a.getAttribute('data-support-tier');
|
||||
if (key && cfg.urls[key]) {
|
||||
a.href = cfg.urls[key];
|
||||
}
|
||||
a.addEventListener('click', function () {
|
||||
postTrack(cfg, 'donation_clicked', { variant: key, source: 'support_modal' });
|
||||
});
|
||||
});
|
||||
var lic = document.querySelector('a[data-support-tier="license"]');
|
||||
if (lic) {
|
||||
lic.addEventListener('click', function () {
|
||||
postTrack(cfg, 'license_clicked', { source: 'support_modal' });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function syncStatsFromConfig(cfg) {
|
||||
if (!cfg || !cfg.stats) return;
|
||||
var h = document.getElementById('supportStatHours');
|
||||
var e = document.getElementById('supportStatEntries');
|
||||
var r = document.getElementById('supportStatReports');
|
||||
if (h) h.textContent = Number(cfg.stats.total_hours || 0).toFixed(1);
|
||||
if (e) e.textContent = String(cfg.stats.time_entries_count != null ? cfg.stats.time_entries_count : 0);
|
||||
if (r) r.textContent = String(cfg.stats.reports_generated_count != null ? cfg.stats.reports_generated_count : 0);
|
||||
var social = document.getElementById('supportSocialLine');
|
||||
if (social && cfg.socialProofLine) {
|
||||
social.textContent = cfg.socialProofLine;
|
||||
}
|
||||
}
|
||||
|
||||
function openSupportModal() {
|
||||
var modal = document.getElementById('supportModal');
|
||||
if (!modal) return;
|
||||
var cfg = parseSupportConfig();
|
||||
syncStatsFromConfig(cfg);
|
||||
applyOfflineState(cfg);
|
||||
modal.classList.remove('hidden');
|
||||
modal.setAttribute('aria-hidden', 'false');
|
||||
if (cfg) postTrack(cfg, 'modal_opened', { source: 'support_modal' });
|
||||
}
|
||||
|
||||
function closeSupportModal() {
|
||||
var modal = document.getElementById('supportModal');
|
||||
if (!modal) return;
|
||||
modal.classList.add('hidden');
|
||||
modal.setAttribute('aria-hidden', 'true');
|
||||
}
|
||||
|
||||
window.openSupportModal = openSupportModal;
|
||||
window.closeSupportModal = closeSupportModal;
|
||||
|
||||
function showSoftToast(cfg, message, variant, source) {
|
||||
if (!window.toastManager || typeof window.toastManager.show !== 'function') return;
|
||||
window.toastManager.show({
|
||||
message: message,
|
||||
type: 'info',
|
||||
duration: 8000,
|
||||
dismissible: true,
|
||||
actionLink: '__support_modal__',
|
||||
actionLabel: (cfg && cfg.i18n && cfg.i18n.supportAction) || 'Support'
|
||||
});
|
||||
postTrack(cfg, 'prompt_shown', { variant: variant, source: source || 'toast' });
|
||||
}
|
||||
|
||||
function maybeLongSessionPrompt(cfg) {
|
||||
if (!cfg || !cfg.sessionStartedAt || !cfg.softPromptUrl) return;
|
||||
var mins = Number(cfg.longSessionMinutes) || 120;
|
||||
var started = Date.parse(cfg.sessionStartedAt);
|
||||
if (!started) return;
|
||||
|
||||
function check() {
|
||||
var elapsedMin = (Date.now() - started) / 60000;
|
||||
if (elapsedMin < mins) return;
|
||||
clearInterval(timer);
|
||||
fetch(cfg.softPromptUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': getCsrfToken()
|
||||
},
|
||||
body: JSON.stringify({ kind: 'long_session' }),
|
||||
credentials: 'same-origin'
|
||||
})
|
||||
.then(function (r) {
|
||||
return r.json();
|
||||
})
|
||||
.then(function (data) {
|
||||
if (!data || !data.show) return;
|
||||
var msg =
|
||||
(cfg.i18n && cfg.i18n.longSessionToast) ||
|
||||
'If TimeTracker helps your day, consider supporting its development.';
|
||||
var act = (cfg.i18n && cfg.i18n.supportAction) || 'Support';
|
||||
if (window.toastManager && typeof window.toastManager.show === 'function') {
|
||||
window.toastManager.show({
|
||||
message: msg,
|
||||
type: 'info',
|
||||
duration: 9000,
|
||||
dismissible: true,
|
||||
actionLink: '__support_modal__',
|
||||
actionLabel: act
|
||||
});
|
||||
}
|
||||
postTrack(cfg, 'prompt_shown', { variant: 'long_session', source: 'long_session_timer' });
|
||||
})
|
||||
.catch(function () {});
|
||||
}
|
||||
|
||||
var timer = setInterval(check, 60000);
|
||||
setTimeout(check, 5000);
|
||||
}
|
||||
|
||||
function layoutPromptFromConfig(cfg) {
|
||||
if (!cfg || !cfg.layoutPrompt || !cfg.layoutPrompt.message) return;
|
||||
showSoftToast(cfg, cfg.layoutPrompt.message, cfg.layoutPrompt.variant || 'after_report', 'layout');
|
||||
}
|
||||
|
||||
function dashboardPrompt() {
|
||||
var cfg = parseSupportConfig();
|
||||
var raw = window.__TT_DASHBOARD_SUPPORT_PROMPT;
|
||||
if (!cfg || !raw || !raw.message) return;
|
||||
showSoftToast(cfg, raw.message, raw.variant || 'dashboard', raw.source || 'dashboard');
|
||||
}
|
||||
|
||||
function headerPulse(btn) {
|
||||
if (!btn) return;
|
||||
try {
|
||||
if (sessionStorage.getItem('tt_support_header_pulse_done')) return;
|
||||
btn.classList.add('animate-pulse', 'ring-2', 'ring-amber-400/60');
|
||||
setTimeout(function () {
|
||||
btn.classList.remove('animate-pulse', 'ring-2', 'ring-amber-400/60');
|
||||
}, 2400);
|
||||
sessionStorage.setItem('tt_support_header_pulse_done', '1');
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function wireModalDom(cfg) {
|
||||
var modal = document.getElementById('supportModal');
|
||||
if (!modal) return;
|
||||
modal.querySelectorAll('[data-support-modal-close], [data-support-modal-overlay]').forEach(function (el) {
|
||||
el.addEventListener('click', function () {
|
||||
closeSupportModal();
|
||||
});
|
||||
});
|
||||
document.addEventListener('keydown', function (ev) {
|
||||
if (ev.key === 'Escape' && !modal.classList.contains('hidden')) {
|
||||
closeSupportModal();
|
||||
}
|
||||
});
|
||||
var shareBtn = document.getElementById('supportShareBtn');
|
||||
if (shareBtn && cfg && cfg.shareUrl) {
|
||||
shareBtn.addEventListener('click', function () {
|
||||
var url = cfg.shareUrl;
|
||||
if (navigator.share) {
|
||||
navigator
|
||||
.share({
|
||||
title: document.title,
|
||||
url: url
|
||||
})
|
||||
.catch(function () {});
|
||||
} else if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(url).then(
|
||||
function () {
|
||||
if (window.toastManager) {
|
||||
window.toastManager.show(
|
||||
(cfg.i18n && cfg.i18n.shareSuccess) || 'Copied',
|
||||
'success'
|
||||
);
|
||||
}
|
||||
},
|
||||
function () {
|
||||
if (window.toastManager) {
|
||||
window.toastManager.show(
|
||||
(cfg.i18n && cfg.i18n.shareFail) || 'Copy failed',
|
||||
'error'
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
var hdr = document.getElementById('headerSupportBtn');
|
||||
if (hdr) {
|
||||
hdr.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
openSupportModal();
|
||||
});
|
||||
headerPulse(hdr);
|
||||
}
|
||||
document.querySelectorAll('.js-open-support-modal').forEach(function (btn) {
|
||||
btn.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
openSupportModal();
|
||||
});
|
||||
});
|
||||
window.addEventListener('online', function () {
|
||||
applyOfflineState(cfg);
|
||||
});
|
||||
window.addEventListener('offline', function () {
|
||||
applyOfflineState(cfg);
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
var cfg = parseSupportConfig();
|
||||
if (!cfg) return;
|
||||
cfg.i18n = cfg.i18n || {};
|
||||
cfg.i18n.supportAction = cfg.i18n.supportAction || 'Support';
|
||||
wireTierLinks(cfg);
|
||||
wireModalDom(cfg);
|
||||
layoutPromptFromConfig(cfg);
|
||||
dashboardPrompt();
|
||||
maybeLongSessionPrompt(cfg);
|
||||
});
|
||||
})();
|
||||
@@ -74,6 +74,7 @@ class ToastNotificationManager {
|
||||
* @param {boolean} options.dismissible - Show close button (default: true)
|
||||
* @param {string} options.actionLink - Optional URL for action link
|
||||
* @param {string} options.actionLabel - Label for action link (e.g. "View time entries")
|
||||
* @param {function(string): void} [options.onDismiss] - Called when toast closes (reason: 'close'|'timeout')
|
||||
*/
|
||||
show(options) {
|
||||
// Legacy signature: show(message, type) for backward compatibility with templates
|
||||
@@ -121,7 +122,8 @@ class ToastNotificationManager {
|
||||
this.toasts.set(toastId, {
|
||||
element: toast,
|
||||
config: config,
|
||||
timeoutId: null
|
||||
timeoutId: null,
|
||||
onDismiss: typeof options.onDismiss === 'function' ? options.onDismiss : null
|
||||
});
|
||||
|
||||
// Add to container
|
||||
@@ -136,7 +138,7 @@ class ToastNotificationManager {
|
||||
// Auto-dismiss
|
||||
if (config.duration > 0) {
|
||||
const timeoutId = setTimeout(() => {
|
||||
this.dismiss(toastId);
|
||||
this.dismiss(toastId, 'timeout');
|
||||
}, config.duration);
|
||||
this.toasts.get(toastId).timeoutId = timeoutId;
|
||||
}
|
||||
@@ -250,6 +252,15 @@ class ToastNotificationManager {
|
||||
opacity: '0.95',
|
||||
textDecoration: 'underline'
|
||||
});
|
||||
if (config.actionLink === '__support_modal__') {
|
||||
actionLink.href = '#';
|
||||
actionLink.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
if (typeof window.openSupportModal === 'function') {
|
||||
window.openSupportModal();
|
||||
}
|
||||
});
|
||||
}
|
||||
content.appendChild(actionLink);
|
||||
}
|
||||
|
||||
@@ -329,7 +340,7 @@ class ToastNotificationManager {
|
||||
if (closeBtn) {
|
||||
closeBtn.addEventListener('click', () => {
|
||||
const toastId = this.findToastId(toast);
|
||||
if (toastId) this.dismiss(toastId);
|
||||
if (toastId) this.dismiss(toastId, 'close');
|
||||
});
|
||||
closeBtn.addEventListener('mouseenter', () => {
|
||||
closeBtn.style.opacity = '1';
|
||||
@@ -368,17 +379,25 @@ class ToastNotificationManager {
|
||||
return toast;
|
||||
}
|
||||
|
||||
dismiss(toastId) {
|
||||
dismiss(toastId, reason) {
|
||||
const toastData = this.toasts.get(toastId);
|
||||
if (!toastData) return;
|
||||
|
||||
const { element, timeoutId } = toastData;
|
||||
const { element, timeoutId, onDismiss } = toastData;
|
||||
|
||||
// Clear timeout
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
if (typeof onDismiss === 'function') {
|
||||
try {
|
||||
onDismiss(reason === undefined ? 'unknown' : reason);
|
||||
} catch (e) {
|
||||
console.warn('Toast onDismiss error', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Animate out
|
||||
element.classList.add('hiding');
|
||||
|
||||
|
||||
@@ -184,7 +184,7 @@
|
||||
{% if settings.donate_ui_hidden %}
|
||||
<div class="p-4 rounded-lg bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800">
|
||||
<p class="text-sm font-medium text-green-800 dark:text-green-200">
|
||||
<i class="fas fa-check-circle mr-2"></i>{{ _('Donate and support UI are hidden for all users.') }}
|
||||
<i class="fas fa-check-circle mr-2"></i>{{ _('Supporter instance: prompts are minimized; support entry points remain available.') }}
|
||||
</p>
|
||||
</div>
|
||||
{% else %}
|
||||
|
||||
+57
-13
@@ -1054,13 +1054,12 @@
|
||||
<span class="ml-3 sidebar-label">{{ _('Help') }}</span>
|
||||
</a>
|
||||
</li>
|
||||
{% if current_user.is_authenticated and (not settings or not getattr(settings, 'donate_ui_hidden', false)) and current_user.ui_show_donate %}
|
||||
{% if current_user.is_authenticated and current_user.ui_show_donate %}
|
||||
<li class="mt-2">
|
||||
<a href="{{ url_for('main.donate') }}" class="sidebar-nav-item flex items-center p-2 rounded-lg {% if ep == 'main.donate' %}bg-background-light dark:bg-background-dark text-primary font-semibold{% else %}bg-gradient-to-r from-amber-500/10 to-orange-500/10 border border-amber-500/20 text-amber-600 dark:text-amber-400 hover:from-amber-500/20 hover:to-orange-500/20 hover:border-amber-500/30{% endif %} transition-all duration-200 group" title="{{ _('Support updates — or remove prompts with a key') }}">
|
||||
<i class="fas fa-mug-saucer w-6 text-center group-hover:scale-110 transition-transform"></i>
|
||||
<span class="ml-3 sidebar-label font-medium">{{ _('Support Development') }}</span>
|
||||
<span class="ml-auto text-xs opacity-70">☕</span>
|
||||
</a>
|
||||
<button type="button" class="sidebar-nav-item w-full text-left flex items-center p-2 rounded-lg bg-gradient-to-r from-amber-500/10 to-orange-500/10 border border-amber-500/20 text-amber-600 dark:text-amber-400 hover:from-amber-500/20 hover:to-orange-500/20 hover:border-amber-500/30 transition-all duration-200 group js-open-support-modal" title="{{ _('Open support options') }}">
|
||||
<i class="fas fa-heart w-6 text-center group-hover:scale-110 transition-transform" aria-hidden="true"></i>
|
||||
<span class="ml-3 sidebar-label font-medium">{{ _('Support TimeTracker') }}</span>
|
||||
</button>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
@@ -1126,6 +1125,12 @@
|
||||
<a href="{{ url_for('main.help') }}" class="flex items-center justify-center w-9 h-9 rounded-lg text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-primary/50" aria-label="{{ _('Help') }}" title="{{ _('Help') }}">
|
||||
<i class="fas fa-life-ring"></i>
|
||||
</a>
|
||||
{% if current_user.is_authenticated %}
|
||||
<button type="button" id="headerSupportBtn" class="hidden sm:inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-sm font-medium text-amber-800 dark:text-amber-200 bg-amber-500/15 hover:bg-amber-500/25 border border-amber-500/30 dark:border-amber-500/40 focus:outline-none focus:ring-2 focus:ring-amber-500/40" title="{{ _('Support TimeTracker') }}">
|
||||
<i class="fas fa-heart text-amber-600 dark:text-amber-300" aria-hidden="true"></i>
|
||||
<span class="max-w-[10rem] truncate">{{ _('Support TimeTracker') }}</span>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Language Switcher -->
|
||||
@@ -1164,6 +1169,9 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
<span class="hidden md:inline text-text-light dark:text-text-dark font-medium">{{ current_user.display_name }}</span>
|
||||
{% if is_license_activated %}
|
||||
<span class="hidden md:inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-200 border border-emerald-200/80 dark:border-emerald-700/60" title="{{ _('Supporter') }}">{{ _('Supporter') }}</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="w-8 h-8 rounded-full bg-gray-400 flex items-center justify-center">
|
||||
<i class="fas fa-user text-white"></i>
|
||||
@@ -1182,8 +1190,8 @@
|
||||
{% if current_user.is_authenticated %}
|
||||
<li><a href="{{ url_for('user.license') }}" class="flex items-center gap-2 px-4 py-2 text-sm text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700"><i class="fas fa-key w-4"></i> {{ _('License') }}</a></li>
|
||||
{% endif %}
|
||||
{% if not is_license_activated and current_user.is_authenticated %}
|
||||
<li><a href="{{ support_purchase_url }}" target="_blank" rel="noopener noreferrer" class="flex flex-col gap-0.5 px-4 py-2 text-sm text-amber-600 dark:text-amber-400 hover:bg-gray-100 dark:hover:bg-gray-700"><span class="font-medium"><i class="fas fa-heart w-4" aria-hidden="true"></i> {{ _('Support TimeTracker') }}</span><span class="text-xs text-text-muted-light dark:text-text-muted-dark">{{ _('Buy a license key (€25)') }}</span></a></li>
|
||||
{% if current_user.is_authenticated %}
|
||||
<li><button type="button" class="js-open-support-modal w-full text-left flex flex-col gap-0.5 px-4 py-2 text-sm text-amber-600 dark:text-amber-400 hover:bg-gray-100 dark:hover:bg-gray-700"><span class="font-medium"><i class="fas fa-heart w-4" aria-hidden="true"></i> {{ _('Support TimeTracker') }}</span><span class="text-xs text-text-muted-light dark:text-text-muted-dark">{{ _('Donate or get a supporter license') }}</span></button></li>
|
||||
{% endif %}
|
||||
<li class="border-t border-border-light dark:border-border-dark"><a href="{{ url_for('auth.logout') }}" class="flex items-center gap-2 px-4 py-2 text-sm text-rose-600 dark:text-rose-400 hover:bg-gray-100 dark:hover:bg-gray-700"><i class="fas fa-sign-out-alt w-4"></i> {{ _('Logout') }}</a></li>
|
||||
</ul>
|
||||
@@ -1211,7 +1219,7 @@
|
||||
{% endwith %}
|
||||
</div>
|
||||
|
||||
{% if current_user.is_authenticated and (not settings or not getattr(settings, 'donate_ui_hidden', false)) and current_user.ui_show_donate %}
|
||||
{% if current_user.is_authenticated and current_user.ui_show_donate %}
|
||||
<!-- Dismissible Support Banner -->
|
||||
<div id="supportBanner" class="bg-gradient-to-r from-amber-50 to-orange-50 dark:from-amber-900/20 dark:to-orange-900/20 border-b border-amber-200 dark:border-amber-800 px-4 py-3 opacity-0 invisible max-h-0 overflow-hidden transition-all duration-300 ease-in-out">
|
||||
<div class="max-w-7xl mx-auto flex items-center justify-between gap-4">
|
||||
@@ -1222,7 +1230,7 @@
|
||||
{{ _('Enjoying TimeTracker?') }}
|
||||
</p>
|
||||
<p class="text-xs text-amber-700 dark:text-amber-300" id="bannerMessage">
|
||||
{{ _('Support updates and new features — or remove prompts with a key') }} ☕
|
||||
{{ _('Support independent development — licenses are supporter badges, not paywalls.') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1245,9 +1253,9 @@
|
||||
class="px-3 py-1.5 text-white text-sm font-medium rounded-lg transition-colors border border-blue-600 hover:opacity-90" style="background: linear-gradient(to right, #0070ba, #003087);">
|
||||
<i class="fab fa-paypal mr-1"></i>{{ _('PayPal') }}
|
||||
</a>
|
||||
<a href="{{ support_purchase_url }}" target="_blank" rel="noopener noreferrer" onclick="trackDonationClick('banner_key')" class="text-xs text-amber-700 dark:text-amber-300 hover:underline self-center">
|
||||
{{ _('Remove prompts with key') }}
|
||||
</a>
|
||||
<button type="button" class="text-xs text-amber-700 dark:text-amber-300 hover:underline self-center js-open-support-modal">
|
||||
{{ _('Support / License') }}
|
||||
</button>
|
||||
<button onclick="dismissSupportBanner()"
|
||||
class="p-1.5 text-amber-600 dark:text-amber-400 hover:bg-amber-100 dark:hover:bg-amber-900/30 rounded transition-colors"
|
||||
aria-label="{{ _('Dismiss') }}">
|
||||
@@ -1262,6 +1270,9 @@
|
||||
<main id="mainContentAnchor" class="flex-1 min-w-0 p-4 sm:p-6 w-full max-w-7xl mx-auto overflow-x-hidden">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
{% if current_user.is_authenticated %}
|
||||
<p class="text-center text-xs text-text-muted-light dark:text-text-muted-dark px-4 pb-2 max-w-7xl mx-auto w-full">{{ _('Built by an independent developer') }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if current_user.is_authenticated %}
|
||||
@@ -1296,6 +1307,33 @@
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
{% if is_admin_user %}
|
||||
<div id="adminVersionUpdateRoot" class="hidden fixed bottom-20 right-4 z-[60] max-w-sm w-[min(24rem,calc(100vw-2rem))] rounded-xl border border-border-light dark:border-border-dark bg-card-light dark:bg-card-dark shadow-xl" role="region" aria-label="{{ _('Software update') }}">
|
||||
<div class="p-4 sm:p-5">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="text-2xl shrink-0" aria-hidden="true">🚀</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h2 id="adminVersionUpdateTitle" class="text-sm font-semibold text-text-light dark:text-text-dark"></h2>
|
||||
<p id="adminVersionUpdatePublished" class="mt-1 text-xs text-text-muted-light dark:text-text-muted-dark"></p>
|
||||
<div id="adminVersionUpdateNotes" class="mt-2 text-sm text-text-light dark:text-text-dark whitespace-pre-wrap break-words max-h-40 overflow-y-auto"></div>
|
||||
<button type="button" id="adminVersionUpdateReadMore" class="hidden mt-1 text-xs text-primary hover:underline">{{ _('Read more') }}</button>
|
||||
</div>
|
||||
<button type="button" id="adminVersionUpdateClose" class="shrink-0 p-1 rounded-md text-text-muted-light hover:bg-background-light dark:text-text-muted-dark dark:hover:bg-background-dark" aria-label="{{ _('Close') }}">×</button>
|
||||
</div>
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
<a id="adminVersionUpdateViewRelease" href="#" target="_blank" rel="noopener noreferrer" class="inline-flex items-center justify-center px-3 py-1.5 rounded-lg bg-primary text-white text-sm font-medium hover:bg-primary-dark">{{ _('View Release') }}</a>
|
||||
<button type="button" id="adminVersionUpdateDismiss" class="inline-flex items-center justify-center px-3 py-1.5 rounded-lg border border-border-light dark:border-border-dark text-sm">{{ _('Dismiss') }}</button>
|
||||
<button type="button" id="adminVersionUpdateDismissVersion" class="inline-flex items-center justify-center px-3 py-1.5 rounded-lg border border-border-light dark:border-border-dark text-sm">{{ _("Don't show again for this version") }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if current_user.is_authenticated and support_ui_json %}
|
||||
<script type="application/json" id="support-ui-bootstrap">{{ support_ui_json|safe }}</script>
|
||||
{% include 'components/support_modal.html' %}
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/js/all.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/animejs/3.2.1/anime.min.js"></script>
|
||||
@@ -1305,6 +1343,9 @@
|
||||
<script src="{{ url_for('static', filename='enhanced-search.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='form-validation.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='toast-notifications.js') }}?v={{ app_version }}-toastfix1"></script>
|
||||
{% if current_user.is_authenticated and support_ui_json %}
|
||||
<script src="{{ url_for('static', filename='support-ui.js') }}?v={{ app_version }}-sup1"></script>
|
||||
{% endif %}
|
||||
<script src="{{ url_for('static', filename='enhanced-tables.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='interactions.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='offline-sync.js') }}"></script>
|
||||
@@ -1314,6 +1355,9 @@
|
||||
<script src="{{ url_for('static', filename='floating-timer-bar.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='idle.js') }}"></script>
|
||||
{% endif %}
|
||||
{% if is_admin_user %}
|
||||
<script src="{{ url_for('static', filename='admin-version-update.js') }}?v={{ app_version }}"></script>
|
||||
{% endif %}
|
||||
<!-- Old command palette and keyboard navigation (restored) -->
|
||||
<script src="{{ url_for('static', filename='commands.js') }}?v=2.0"></script>
|
||||
<script>window.__BASE_INIT__={timerStatus:"{{ url_for('timer.timer_status') }}",stopTimer:"{{ url_for('timer.stop_timer') }}",dashboard:"{{ url_for('main.dashboard') }}",manualEntry:"{{ url_for('timer.manual_entry') }}"};</script>
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
{# Support & donation modal — strings use Flask-Babel; stats from support_usage_stats_modal #}
|
||||
{% set s = support_usage_stats_modal or {} %}
|
||||
<div id="supportModal" class="fixed inset-0 z-[100] hidden" aria-hidden="true" role="dialog" aria-labelledby="supportModalTitle" aria-modal="true">
|
||||
<div class="absolute inset-0 bg-black/50" data-support-modal-overlay tabindex="-1"></div>
|
||||
<div class="relative max-w-lg mx-auto mt-16 sm:mt-24 mb-8 px-4 max-h-[calc(100vh-4rem)] overflow-y-auto">
|
||||
<div class="bg-card-light dark:bg-card-dark text-text-light dark:text-text-dark rounded-xl shadow-xl border border-border-light dark:border-border-dark">
|
||||
<div class="flex items-start justify-between gap-3 p-5 border-b border-border-light dark:border-border-dark">
|
||||
<div>
|
||||
<h2 id="supportModalTitle" class="text-lg font-semibold">{{ _('Support TimeTracker') }}</h2>
|
||||
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mt-1">
|
||||
{% if is_license_activated %}
|
||||
{{ _('Thank you for being a supporter. Sharing the app helps others discover it too.') }}
|
||||
{% else %}
|
||||
{{ _('TimeTracker is free and built independently. If it helps you, consider supporting its development.') }}
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" class="p-2 rounded-lg hover:bg-background-light dark:hover:bg-background-dark text-text-muted-light" data-support-modal-close aria-label="{{ _('Close') }}">
|
||||
<i class="fas fa-times" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-5 space-y-4">
|
||||
<div class="grid grid-cols-3 gap-2 text-center text-sm">
|
||||
<div class="rounded-lg bg-background-light dark:bg-background-dark p-3">
|
||||
<div class="text-xs text-text-muted-light dark:text-text-muted-dark">{{ _('Hours tracked') }}</div>
|
||||
<div class="font-semibold tabular-nums" id="supportStatHours">{{ '%.1f'|format(s.get('total_hours', 0)|float) }}</div>
|
||||
</div>
|
||||
<div class="rounded-lg bg-background-light dark:bg-background-dark p-3">
|
||||
<div class="text-xs text-text-muted-light dark:text-text-muted-dark">{{ _('Entries') }}</div>
|
||||
<div class="font-semibold tabular-nums" id="supportStatEntries">{{ s.get('time_entries_count', 0)|int }}</div>
|
||||
</div>
|
||||
<div class="rounded-lg bg-background-light dark:bg-background-dark p-3">
|
||||
<div class="text-xs text-text-muted-light dark:text-text-muted-dark">{{ _('Reports') }}</div>
|
||||
<div class="font-semibold tabular-nums" id="supportStatReports">{{ s.get('reports_generated_count', 0)|int }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<p id="supportSocialLine" class="text-xs text-text-muted-light dark:text-text-muted-dark text-center">{{ _('Trusted by teams and freelancers who want simple, reliable time tracking.') }}</p>
|
||||
<p id="supportModalOffline" class="hidden text-sm text-amber-700 dark:text-amber-300"></p>
|
||||
<div class="flex flex-col sm:flex-row gap-2">
|
||||
<a href="#" rel="noopener noreferrer" data-support-tier="eur5" class="support-tier-btn btn btn-secondary text-center flex-1">{{ _('Donate') }} (€5)</a>
|
||||
<a href="#" rel="noopener noreferrer" data-support-tier="eur10" class="support-tier-btn btn btn-secondary text-center flex-1">{{ _('Donate') }} (€10)</a>
|
||||
<a href="#" rel="noopener noreferrer" data-support-tier="eur25" class="support-tier-btn btn btn-secondary text-center flex-1">{{ _('Donate') }} (€25)</a>
|
||||
</div>
|
||||
<div class="flex flex-col sm:flex-row gap-2">
|
||||
<a href="{{ support_purchase_url }}" target="_blank" rel="noopener noreferrer" data-support-tier="license" class="btn btn-primary text-center flex-1">
|
||||
{{ _('Buy license (€25)') }} <i class="fas fa-external-link-alt text-xs ml-1" aria-hidden="true"></i>
|
||||
</a>
|
||||
<button type="button" id="supportShareBtn" class="btn btn-secondary text-center flex-1">{{ _('Love TimeTracker? Share it') }}</button>
|
||||
</div>
|
||||
<p class="text-xs text-text-muted-light dark:text-text-muted-dark">
|
||||
{{ _('A license is a supporter badge — it does not lock features. You keep full access either way.') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -40,13 +40,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if not is_license_activated and current_user.is_authenticated %}
|
||||
<!-- Support block: same messaging as Settings (optional license key) -->
|
||||
{% if current_user.is_authenticated and current_user.ui_show_donate %}
|
||||
<div class="mb-6 sm:mb-8 p-4 sm:p-5 rounded-xl border border-border-light dark:border-border-dark bg-card-light dark:bg-card-dark flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<p class="text-sm text-text-light dark:text-text-dark">{{ _('TimeTracker is free and open-source. If you enjoy using it, you can support development by purchasing a license key.') }}</p>
|
||||
<a href="{{ support_purchase_url }}" target="_blank" rel="noopener noreferrer" class="btn btn-primary flex-shrink-0 w-full sm:w-auto text-center">
|
||||
{{ _('Buy license (€25)') }} <i class="fas fa-external-link-alt ml-1 text-xs" aria-hidden="true"></i>
|
||||
</a>
|
||||
<p class="text-sm text-text-light dark:text-text-dark">{{ _('TimeTracker is free and open source. You can donate or buy a supporter license — features are never locked.') }}</p>
|
||||
<button type="button" class="btn btn-primary flex-shrink-0 w-full sm:w-auto text-center js-open-support-modal">{{ _('Support TimeTracker') }}</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
@@ -223,6 +223,60 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Value Dashboard: productivity insights (filled via /api/stats/value-dashboard) -->
|
||||
<div id="valueDashboardRoot" class="mb-6 bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark rounded-xl shadow-sm p-6 dashboard-widget" data-support-msg="{{ _('If this saved you even 1 hour, consider supporting ❤️') }}" data-empty-msg="{{ _('Start tracking to see your productivity insights here.') }}" data-loading-msg="{{ _('Loading insights…') }}">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="bg-amber-500/10 dark:bg-amber-400/10 p-3 rounded-lg">
|
||||
<i class="fas fa-chart-line text-amber-600 dark:text-amber-400 text-xl" aria-hidden="true"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-text-light dark:text-text-dark">{{ _('Value insights') }}</h2>
|
||||
<p class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Your tracked time at a glance') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p id="valueDashboardLoading" class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Loading insights…') }}</p>
|
||||
<div id="valueDashboardEmpty" class="hidden text-center py-8 text-text-muted-light dark:text-text-muted-dark">
|
||||
<i class="fas fa-seedling text-4xl mb-3 opacity-60" aria-hidden="true"></i>
|
||||
<p id="valueDashboardEmptyText" class="mb-4"></p>
|
||||
<a href="{{ url_for('timer.time_entries_overview') }}" class="btn btn-primary btn-sm">{{ _('View time entries') }}</a>
|
||||
</div>
|
||||
<div id="valueDashboardContent" class="hidden">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6">
|
||||
<div class="rounded-lg border border-border-light dark:border-border-dark bg-gradient-to-br from-amber-50/80 to-orange-50/50 dark:from-amber-900/15 dark:to-orange-900/10 p-4">
|
||||
<p class="text-xs font-medium text-amber-800 dark:text-amber-200/90 mb-1">{{ _('Total hours tracked') }}</p>
|
||||
<p class="text-2xl font-bold text-text-light dark:text-text-dark"><span id="valueDashboardTotalHours">0</span> <span class="text-sm font-normal text-text-muted-light dark:text-text-muted-dark">h</span></p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-border-light dark:border-border-dark bg-card-light dark:bg-card-dark p-4">
|
||||
<p class="text-xs font-medium text-text-muted-light dark:text-text-muted-dark mb-1">{{ _('Time entries') }}</p>
|
||||
<p class="text-2xl font-bold text-text-light dark:text-text-dark" id="valueDashboardEntriesCount">0</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-border-light dark:border-border-dark bg-card-light dark:bg-card-dark p-4">
|
||||
<p class="text-xs font-medium text-text-muted-light dark:text-text-muted-dark mb-1">{{ _('Active days') }}</p>
|
||||
<p class="text-2xl font-bold text-text-light dark:text-text-dark" id="valueDashboardActiveDays">0</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<p class="text-sm font-medium text-text-light dark:text-text-dark mb-2">{{ _('Hours per day (last 7 days)') }}</p>
|
||||
<div id="valueDashboardChart" class="flex items-end justify-between gap-1 sm:gap-2 min-h-[140px] pt-2 border-t border-border-light dark:border-border-dark" role="img" aria-label="{{ _('Hours per day last seven days') }}"></div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-4 text-sm">
|
||||
<div class="rounded-lg bg-gray-50 dark:bg-gray-800/50 p-4 border border-border-light dark:border-border-dark">
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark mb-1">{{ _('Most productive day') }}</p>
|
||||
<p class="font-semibold text-text-light dark:text-text-dark" id="valueDashboardMostProductiveDay">—</p>
|
||||
</div>
|
||||
<div class="rounded-lg bg-gray-50 dark:bg-gray-800/50 p-4 border border-border-light dark:border-border-dark">
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark mb-1">{{ _('Avg session length') }}</p>
|
||||
<p class="font-semibold text-text-light dark:text-text-dark"><span id="valueDashboardAvgSession">0</span> h</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="valueDashboardEstimated" class="hidden mb-4 rounded-lg border border-emerald-200 dark:border-emerald-800 bg-emerald-50/60 dark:bg-emerald-900/20 p-4">
|
||||
<p class="text-sm font-medium text-emerald-900 dark:text-emerald-100">{{ _('Estimated value tracked') }} (<span id="valueDashboardCurrency"></span>)</p>
|
||||
<p class="text-xl font-bold text-emerald-800 dark:text-emerald-200" id="valueDashboardEstimatedAmount"></p>
|
||||
</div>
|
||||
<p id="valueDashboardSupport" class="text-sm text-text-muted-light dark:text-text-muted-dark text-center sm:text-left"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
|
||||
<!-- Left Column: Recent Entries -->
|
||||
<div class="lg:col-span-2">
|
||||
@@ -534,23 +588,39 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if show_support_reminder %}
|
||||
<!-- Support reminder - once per session, unlicensed only -->
|
||||
<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">
|
||||
{% if current_user.ui_show_donate %}
|
||||
<div class="bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark 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-heart text-amber-600 dark:text-amber-400"></i>
|
||||
<i class="fas fa-heart text-amber-600 dark:text-amber-400" aria-hidden="true"></i>
|
||||
</div>
|
||||
<h2 class="text-base font-semibold text-text-light dark:text-text-dark">{{ _('Enjoying TimeTracker?') }}</h2>
|
||||
</div>
|
||||
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mb-4">
|
||||
{{ _('You can support development by purchasing a license key.') }}
|
||||
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mb-2">
|
||||
{{ _('You have tracked %(hours)s hours', hours=('%.1f'|format((usage_support_stats.total_hours or 0)|float))) }}
|
||||
· {{ _('You have created %(count)s entries', count=usage_support_stats.time_entries_count or 0) }}
|
||||
{% if (usage_support_stats.reports_generated_count or 0) > 0 %}
|
||||
· {{ _('Reports generated: %(n)s', n=usage_support_stats.reports_generated_count) }}
|
||||
{% endif %}
|
||||
</p>
|
||||
<a href="{{ support_purchase_url }}" target="_blank" rel="noopener noreferrer" class="inline-flex items-center justify-center w-full sm:w-auto bg-amber-500 hover:bg-amber-600 text-white px-4 py-2 rounded-lg font-medium text-sm transition-colors">
|
||||
<i class="fas fa-heart mr-1.5" aria-hidden="true"></i>{{ _('Support the project') }}
|
||||
</a>
|
||||
{% if is_supporter_instance %}
|
||||
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mb-4">{{ _('Thank you for supporting development. Sharing TimeTracker still helps a lot.') }}</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button type="button" class="btn btn-secondary js-open-support-modal">{{ _('Share & support') }}</button>
|
||||
<a href="{{ url_for('user.license') }}" class="btn btn-secondary">{{ _('License') }}</a>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mb-4">{{ _('If this saves you time, consider supporting development — everything stays free and open.') }}</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button type="button" class="btn btn-primary js-open-support-modal"><i class="fas fa-heart mr-1.5" aria-hidden="true"></i>{{ _('Donate') }}</button>
|
||||
<a href="{{ support_purchase_url }}" target="_blank" rel="noopener noreferrer" class="btn btn-secondary">{{ _('Buy License (€25)') }} <i class="fas fa-external-link-alt text-xs" aria-hidden="true"></i></a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if support_dashboard_prompt %}
|
||||
<script>window.__TT_DASHBOARD_SUPPORT_PROMPT = {{ support_dashboard_prompt|tojson }};</script>
|
||||
{% endif %}
|
||||
</div>
|
||||
<!-- Delete Entry Confirmation Dialogs -->
|
||||
{% for entry in recent_entries %}
|
||||
|
||||
@@ -831,17 +831,17 @@
|
||||
<a href="https://github.com/drytrix/TimeTracker/issues" target="_blank" rel="noopener" class="px-4 py-2 rounded-lg border border-amber-600 text-amber-600 hover:bg-amber-50 dark:hover:bg-amber-900/20">
|
||||
<i class="fas fa-bug mr-1"></i>{{ _('Report Issue') }}
|
||||
</a>
|
||||
{% if current_user.is_authenticated and (not settings or not getattr(settings, 'donate_ui_hidden', false)) and current_user.ui_show_donate %}
|
||||
<a href="{{ url_for('main.donate') }}" class="px-4 py-2 rounded-lg bg-gradient-to-r from-amber-500 to-orange-500 hover:from-amber-600 hover:to-orange-600 text-white font-semibold shadow-md hover:shadow-lg transition-all">
|
||||
<i class="fas fa-heart mr-1"></i>{{ _('Support updates') }}
|
||||
</a>
|
||||
{% if current_user.is_authenticated and current_user.ui_show_donate %}
|
||||
<button type="button" class="px-4 py-2 rounded-lg bg-gradient-to-r from-amber-500 to-orange-500 hover:from-amber-600 hover:to-orange-600 text-white font-semibold shadow-md hover:shadow-lg transition-all js-open-support-modal">
|
||||
<i class="fas fa-heart mr-1" aria-hidden="true"></i>{{ _('Support TimeTracker') }}
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% if not is_license_activated and current_user.is_authenticated %}
|
||||
{% if current_user.is_authenticated and current_user.ui_show_donate %}
|
||||
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mt-6 pt-4 border-t border-border-light dark:border-border-dark">
|
||||
{{ _('Enjoying TimeTracker?') }} {{ _('You can support development by purchasing a license key.') }}
|
||||
<a href="{{ support_purchase_url }}" target="_blank" rel="noopener noreferrer" class="text-primary hover:underline font-medium">{{ _('Support the project') }}</a>
|
||||
{{ _('Enjoying TimeTracker?') }} {{ _('Donations and licenses fund development; nothing is paywalled.') }}
|
||||
<button type="button" class="text-primary hover:underline font-medium js-open-support-modal">{{ _('Open support') }}</button>
|
||||
</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
@@ -15,10 +15,10 @@
|
||||
actions_html=None
|
||||
) }}
|
||||
|
||||
{% if not is_license_activated and current_user.is_authenticated %}
|
||||
{% if current_user.is_authenticated and current_user.ui_show_donate %}
|
||||
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mb-4">
|
||||
{{ _('Enjoying TimeTracker?') }} {{ _('You can support development by purchasing a license key.') }}
|
||||
<a href="{{ support_purchase_url }}" target="_blank" rel="noopener noreferrer" class="text-primary hover:underline font-medium">{{ _('Support the project') }}</a>
|
||||
{{ _('Enjoying TimeTracker?') }} {{ _('Support development or get a supporter license — the app stays free for everyone.') }}
|
||||
<button type="button" class="text-primary hover:underline font-medium js-open-support-modal">{{ _('Open support') }}</button>
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<div class="container mx-auto px-4 py-8 max-w-2xl">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">{{ _('License') }}</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400 mt-2">{{ _('View activation status and enter a license key') }}</p>
|
||||
<p class="text-gray-600 dark:text-gray-400 mt-2">{{ _('Supporter badge: confirm your key and thank you for funding development.') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark rounded-lg shadow-md p-6 mb-6">
|
||||
@@ -13,16 +13,16 @@
|
||||
{% if is_license_activated %}
|
||||
<p class="flex items-center gap-2 text-green-600 dark:text-green-400">
|
||||
<i class="fas fa-check-circle" aria-hidden="true"></i>
|
||||
{{ _('Active license') }}
|
||||
{{ _('Supporter license active') }}
|
||||
</p>
|
||||
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mt-2">{{ _('Thank you for supporting TimeTracker.') }}</p>
|
||||
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mt-2">{{ _('Thank you for supporting TimeTracker. Your badge confirms this instance as a supporter — no features are locked.') }}</p>
|
||||
{% else %}
|
||||
<p class="flex items-center gap-2 text-text-muted-light dark:text-text-muted-dark">
|
||||
<i class="fas fa-circle-info" aria-hidden="true"></i>
|
||||
{{ _('Not activated') }}
|
||||
</p>
|
||||
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mt-2">
|
||||
{{ _('Want to support development? You can purchase a license key for €25.') }}
|
||||
{{ _('Purchase a supporter license (€25) to unlock the supporter badge for this instance. The app stays fully free either way.') }}
|
||||
</p>
|
||||
<a href="{{ support_purchase_url }}" target="_blank" rel="noopener noreferrer" class="inline-flex items-center gap-2 mt-3 px-4 py-2 bg-primary hover:bg-primary/90 text-white rounded-md text-sm font-medium transition">
|
||||
{{ _('Buy license (€25)') }} <i class="fas fa-external-link-alt text-xs" aria-hidden="true"></i>
|
||||
|
||||
@@ -8,20 +8,27 @@
|
||||
<p class="text-gray-600 dark:text-gray-400 mt-2">{{ _('Manage your account settings and preferences') }}</p>
|
||||
</div>
|
||||
|
||||
{% if not is_license_activated %}
|
||||
<div class="mb-6 p-4 rounded-lg border border-border-light dark:border-border-dark bg-card-light dark:bg-card-dark flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<p class="text-sm text-text-light dark:text-text-dark">{{ _('TimeTracker is free and open-source. If you enjoy using it, you can support development by purchasing a license key.') }}</p>
|
||||
<a href="{{ support_purchase_url }}" target="_blank" rel="noopener noreferrer" class="btn btn-primary flex-shrink-0 w-full sm:w-auto text-center">
|
||||
{{ _('Buy license (€25)') }} <i class="fas fa-external-link-alt ml-1 text-xs" aria-hidden="true"></i>
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="mb-6">
|
||||
<a href="{{ url_for('user.license') }}" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg border border-border-light dark:border-border-dark bg-card-light dark:bg-card-dark text-text-light dark:text-text-dark hover:bg-gray-50 dark:hover:bg-gray-700/50 transition text-sm font-medium">
|
||||
<i class="fas fa-key" aria-hidden="true"></i>{{ _('License') }}
|
||||
</a>
|
||||
</div>
|
||||
<details class="bg-card-light dark:bg-card-dark rounded-lg shadow-md group mb-6" open>
|
||||
<summary class="flex items-center justify-between p-6 cursor-pointer select-none list-none [&::-webkit-details-marker]:hidden">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
<i class="fas fa-heart mr-2 text-amber-600 dark:text-amber-400" aria-hidden="true"></i>{{ _('Support & Community') }}
|
||||
</h2>
|
||||
<i class="fas fa-chevron-down text-gray-400 transition-transform group-open:rotate-180 md:hidden"></i>
|
||||
</summary>
|
||||
<div class="px-6 pb-6 space-y-4 text-sm text-text-light dark:text-text-dark">
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark">{{ _('TimeTracker is free and open source. Funding comes from optional donations and supporter licenses — never from locking features.') }}</p>
|
||||
{% if is_license_activated %}
|
||||
<p>{{ _('This instance already has a supporter license. Thank you — you can still donate or share the app anytime.') }}</p>
|
||||
{% else %}
|
||||
<p>{{ _('If the app saves you time, you can donate or buy a supporter license (€25). A license shows a Supporter badge; it does not change what you can use.') }}</p>
|
||||
{% endif %}
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button type="button" class="btn btn-primary js-open-support-modal">{{ _('Support TimeTracker') }}</button>
|
||||
<a href="{{ url_for('user.license') }}" class="btn btn-secondary inline-flex items-center gap-2"><i class="fas fa-key" aria-hidden="true"></i>{{ _('License & supporter key') }}</a>
|
||||
<a href="{{ support_purchase_url }}" target="_blank" rel="noopener noreferrer" class="btn btn-secondary">{{ _('Checkout on timetracker.drytrix.com') }} <i class="fas fa-external-link-alt text-xs" aria-hidden="true"></i></a>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<form method="POST" class="space-y-8">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
@@ -138,6 +145,56 @@
|
||||
<input type="time" id="reminder_to_log_time" name="reminder_to_log_time" value="{{ user.reminder_to_log_time or '17:00' }}"
|
||||
class="w-32 px-2 py-1.5 border border-gray-300 dark:border-gray-600 rounded-md dark:bg-gray-700 dark:text-white text-sm">
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-4 mb-2">{{ _('In-app reminders (toasts)') }}</p>
|
||||
<label for="smart_notifications_enabled" class="flex items-center min-h-[44px] cursor-pointer rounded-lg px-2 -mx-2 hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
||||
<input type="checkbox" id="smart_notifications_enabled" name="smart_notifications_enabled"
|
||||
{% if user.smart_notifications_enabled %}checked{% endif %}
|
||||
class="h-5 w-5 text-blue-600 focus:ring-blue-500 border-gray-300 rounded flex-shrink-0">
|
||||
<span class="ml-3 block text-sm text-gray-700 dark:text-gray-300">
|
||||
{{ _('Enable smart notifications on this device') }}
|
||||
</span>
|
||||
</label>
|
||||
<div class="ml-2 mt-2 space-y-2 {% if not user.smart_notifications_enabled %}opacity-60{% endif %}" id="smart_notify_sub_wrap">
|
||||
<label for="smart_notify_no_tracking" class="flex items-center min-h-[40px] cursor-pointer rounded-lg px-2 -mx-2 hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
||||
<input type="checkbox" id="smart_notify_no_tracking" name="smart_notify_no_tracking"
|
||||
{% if user.smart_notify_no_tracking %}checked{% endif %}
|
||||
class="h-5 w-5 text-blue-600 focus:ring-blue-500 border-gray-300 rounded flex-shrink-0">
|
||||
<span class="ml-3 block text-sm text-gray-700 dark:text-gray-300">{{ _('Nudge when no time logged today (uses hour from app settings or override below)') }}</span>
|
||||
</label>
|
||||
<label for="smart_notify_long_timer" class="flex items-center min-h-[40px] cursor-pointer rounded-lg px-2 -mx-2 hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
||||
<input type="checkbox" id="smart_notify_long_timer" name="smart_notify_long_timer"
|
||||
{% if user.smart_notify_long_timer %}checked{% endif %}
|
||||
class="h-5 w-5 text-blue-600 focus:ring-blue-500 border-gray-300 rounded flex-shrink-0">
|
||||
<span class="ml-3 block text-sm text-gray-700 dark:text-gray-300">{{ _('Alert when a timer runs longer than the configured threshold') }}</span>
|
||||
</label>
|
||||
<label for="smart_notify_daily_summary" class="flex items-center min-h-[40px] cursor-pointer rounded-lg px-2 -mx-2 hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
||||
<input type="checkbox" id="smart_notify_daily_summary" name="smart_notify_daily_summary"
|
||||
{% if user.smart_notify_daily_summary %}checked{% endif %}
|
||||
class="h-5 w-5 text-blue-600 focus:ring-blue-500 border-gray-300 rounded flex-shrink-0">
|
||||
<span class="ml-3 block text-sm text-gray-700 dark:text-gray-300">{{ _('End-of-day summary (logged hours today)') }}</span>
|
||||
</label>
|
||||
<label for="smart_notify_browser" class="flex items-center min-h-[40px] cursor-pointer rounded-lg px-2 -mx-2 hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
||||
<input type="checkbox" id="smart_notify_browser" name="smart_notify_browser"
|
||||
{% if user.smart_notify_browser %}checked{% endif %}
|
||||
class="h-5 w-5 text-blue-600 focus:ring-blue-500 border-gray-300 rounded flex-shrink-0">
|
||||
<span class="ml-3 block text-sm text-gray-700 dark:text-gray-300">{{ _('Also use browser notifications when permission is granted') }}</span>
|
||||
</label>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 mt-2">
|
||||
<div>
|
||||
<label for="smart_notify_no_tracking_after" class="block text-xs text-gray-500 dark:text-gray-400 mb-1">{{ _('No-tracking nudge hour (optional override, HH:MM)') }}</label>
|
||||
<input type="time" id="smart_notify_no_tracking_after" name="smart_notify_no_tracking_after"
|
||||
value="{{ user.smart_notify_no_tracking_after or '' }}"
|
||||
class="w-full max-w-[12rem] px-2 py-1.5 border border-gray-300 dark:border-gray-600 rounded-md dark:bg-gray-700 dark:text-white text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label for="smart_notify_summary_at" class="block text-xs text-gray-500 dark:text-gray-400 mb-1">{{ _('Summary hour (optional override, HH:MM)') }}</label>
|
||||
<input type="time" id="smart_notify_summary_at" name="smart_notify_summary_at"
|
||||
value="{{ user.smart_notify_summary_at or '' }}"
|
||||
class="w-full max-w-[12rem] px-2 py-1.5 border border-gray-300 dark:border-gray-600 rounded-md dark:bg-gray-700 dark:text-white text-sm">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Helpers for marking session-based /api routes that overlap with /api/v1."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import wraps
|
||||
from typing import Any, Callable, Optional, Tuple, Union
|
||||
|
||||
from flask import make_response
|
||||
|
||||
RouteReturn = Union[Any, Tuple[Any, int], Tuple[Any, int, dict]]
|
||||
|
||||
|
||||
def apply_deprecated_headers_to_result(
|
||||
result: RouteReturn,
|
||||
successor_path: Optional[str] = None,
|
||||
) -> Any:
|
||||
"""
|
||||
Add deprecation headers to a Flask view return value (Response, or (rv, status), or triple).
|
||||
|
||||
successor_path: path only (e.g. '/api/v1/search'); emitted as Link rel=successor-version.
|
||||
"""
|
||||
if isinstance(result, tuple):
|
||||
if len(result) == 3:
|
||||
resp = make_response(result[0], result[1], result[2])
|
||||
elif len(result) == 2:
|
||||
resp = make_response(result[0], result[1])
|
||||
else:
|
||||
resp = make_response(result[0])
|
||||
else:
|
||||
resp = make_response(result)
|
||||
|
||||
resp.headers["X-API-Deprecated"] = "true"
|
||||
if successor_path:
|
||||
resp.headers["Link"] = f'<{successor_path}>; rel="successor-version"'
|
||||
return resp
|
||||
|
||||
|
||||
def deprecated_session_api(successor_path: Optional[str]) -> Callable[[Callable[..., RouteReturn]], Callable[..., Any]]:
|
||||
"""
|
||||
Decorate a view: after it runs, stamp X-API-Deprecated (and optional Link) on the response.
|
||||
|
||||
Use *inside* @login_required so unauthenticated responses are unchanged:
|
||||
|
||||
@login_required
|
||||
@deprecated_session_api("/api/v1/search")
|
||||
def search(): ...
|
||||
"""
|
||||
|
||||
def decorator(view_func: Callable[..., RouteReturn]) -> Callable[..., Any]:
|
||||
@wraps(view_func)
|
||||
def wrapped(*args: Any, **kwargs: Any) -> Any:
|
||||
out = view_func(*args, **kwargs)
|
||||
return apply_deprecated_headers_to_result(out, successor_path)
|
||||
|
||||
return wrapped
|
||||
|
||||
return decorator
|
||||
@@ -1,5 +1,8 @@
|
||||
from flask import current_app, g, request
|
||||
from flask_babel import get_locale
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
from flask import current_app, g, request, session, url_for
|
||||
from flask_babel import get_locale, gettext as _
|
||||
from flask_login import current_user
|
||||
|
||||
from app.models import Settings
|
||||
@@ -174,9 +177,101 @@ def register_context_processors(app):
|
||||
user_stats = {}
|
||||
support_banner_suppressed = False
|
||||
|
||||
is_admin_user = bool(
|
||||
getattr(current_user, "is_authenticated", False) and getattr(current_user, "is_admin", False)
|
||||
)
|
||||
|
||||
support_ui_json = None
|
||||
layout_support_prompt = None
|
||||
support_usage_stats_modal = None
|
||||
if getattr(current_user, "is_authenticated", False):
|
||||
try:
|
||||
from app.config.support_ui import (
|
||||
build_support_checkout_urls,
|
||||
get_long_session_minutes,
|
||||
get_social_proof_text,
|
||||
)
|
||||
from app.models import Settings
|
||||
from app.services.support_prompt_service import SupportPromptService
|
||||
from app.services.usage_stats_service import UsageStatsService
|
||||
from app.utils.license_utils import is_license_activated
|
||||
|
||||
settings_obj = Settings.get_settings()
|
||||
is_supporter_instance = bool(settings_obj and is_license_activated(settings_obj))
|
||||
ui_show_donate = bool(getattr(current_user, "ui_show_donate", True))
|
||||
|
||||
layout_support_prompt = SupportPromptService.consume_layout_prompt(
|
||||
session,
|
||||
ui_show_donate=ui_show_donate,
|
||||
is_supporter=is_supporter_instance,
|
||||
support_banner_suppressed=support_banner_suppressed,
|
||||
)
|
||||
|
||||
usage_stats = UsageStatsService.get_for_user(current_user.id)
|
||||
support_usage_stats_modal = usage_stats
|
||||
checkout_urls = build_support_checkout_urls(current_app.config)
|
||||
social_line = get_social_proof_text(current_app.config)
|
||||
long_session_minutes = get_long_session_minutes()
|
||||
|
||||
if not session.get("support_session_started_at"):
|
||||
session["support_session_started_at"] = (
|
||||
datetime.utcnow().replace(microsecond=0).isoformat() + "Z"
|
||||
)
|
||||
|
||||
lp_message = ""
|
||||
lp_action = _("Support")
|
||||
if layout_support_prompt:
|
||||
v = layout_support_prompt.get("variant")
|
||||
if v == SupportPromptService.VARIANT_AFTER_REPORT:
|
||||
lp_message = _(
|
||||
"That report was quick to generate. If TimeTracker saves you time, "
|
||||
"consider supporting its development."
|
||||
)
|
||||
|
||||
support_ui_json = json.dumps(
|
||||
{
|
||||
"urls": checkout_urls,
|
||||
"stats": usage_stats,
|
||||
"socialProofLine": social_line,
|
||||
"longSessionMinutes": long_session_minutes,
|
||||
"isSupporter": is_supporter_instance,
|
||||
"sessionStartedAt": session.get("support_session_started_at"),
|
||||
"shareUrl": url_for("main.about", _external=True),
|
||||
"trackUrl": url_for("main.track_support_event"),
|
||||
"softPromptUrl": url_for("main.request_soft_support_prompt"),
|
||||
"layoutPrompt": (
|
||||
{
|
||||
"variant": layout_support_prompt.get("variant"),
|
||||
"message": lp_message,
|
||||
"actionLabel": lp_action,
|
||||
}
|
||||
if layout_support_prompt
|
||||
else None
|
||||
),
|
||||
"i18n": {
|
||||
"offlineNote": _(
|
||||
"You appear to be offline. Reconnect to open donation or checkout links."
|
||||
),
|
||||
"shareSuccess": _("Link copied to clipboard"),
|
||||
"shareFail": _("Could not copy link"),
|
||||
"supportAction": _("Support"),
|
||||
"longSessionToast": _(
|
||||
"You have been using TimeTracker actively for a while. "
|
||||
"If it helps your work, consider supporting its development."
|
||||
),
|
||||
},
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
except Exception:
|
||||
support_ui_json = None
|
||||
layout_support_prompt = None
|
||||
support_usage_stats_modal = None
|
||||
|
||||
return {
|
||||
"app_name": "Time Tracker",
|
||||
"app_version": version_value,
|
||||
"is_admin_user": is_admin_user,
|
||||
"timezone": timezone_name,
|
||||
"timezone_offset": get_timezone_offset_for_timezone(timezone_name),
|
||||
"user_timezone": user_timezone,
|
||||
@@ -190,6 +285,9 @@ def register_context_processors(app):
|
||||
"user_stats": user_stats,
|
||||
"support_banner_suppressed": support_banner_suppressed,
|
||||
"support_ab_variant": support_ab_variant,
|
||||
"support_ui_json": support_ui_json,
|
||||
"layout_support_prompt": layout_support_prompt,
|
||||
"support_usage_stats_modal": support_usage_stats_modal,
|
||||
}
|
||||
|
||||
@app.context_processor
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
"""
|
||||
Optional support/license visibility helpers.
|
||||
|
||||
Instance-level "license activated" state is represented by Settings.donate_ui_hidden
|
||||
(set when a user verifies the donate-hide / license key). This is non-blocking
|
||||
monetization awareness only—no paywall or feature gating.
|
||||
Instance-level supporter state is represented by Settings.donate_ui_hidden
|
||||
(set when a user verifies a license / supporter key). Non-blocking: no paywall
|
||||
or feature gating; UI treats this as a supporter badge and softer prompts.
|
||||
"""
|
||||
|
||||
|
||||
def is_license_activated(settings) -> bool:
|
||||
"""Return True if this instance has an active license (donate/support UI hidden)."""
|
||||
"""Return True if this instance has an activated supporter / license key."""
|
||||
return bool(getattr(settings, "donate_ui_hidden", False))
|
||||
|
||||
@@ -1,269 +0,0 @@
|
||||
"""
|
||||
PostHog Feature Flags and Advanced Features
|
||||
|
||||
This module provides utilities for using PostHog's advanced features:
|
||||
- Feature flags (for A/B testing and gradual rollouts)
|
||||
- Experiments
|
||||
- Feature enablement checks
|
||||
- Remote configuration
|
||||
"""
|
||||
|
||||
import os
|
||||
from functools import wraps
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from flask import request
|
||||
|
||||
|
||||
def is_posthog_enabled() -> bool:
|
||||
"""Legacy feature-flag hook; disabled after Grafana cutover."""
|
||||
return False
|
||||
|
||||
|
||||
def get_feature_flag(user_id: Any, flag_key: str, default: bool = False) -> bool:
|
||||
"""
|
||||
Check if a feature flag is enabled for a user.
|
||||
|
||||
Args:
|
||||
user_id: The user ID (internal ID, not PII)
|
||||
flag_key: The feature flag key in PostHog
|
||||
default: Default value if PostHog is not configured
|
||||
|
||||
Returns:
|
||||
True if feature is enabled, False otherwise
|
||||
"""
|
||||
if not is_posthog_enabled():
|
||||
return default
|
||||
|
||||
return default
|
||||
|
||||
|
||||
def get_feature_flag_payload(user_id: Any, flag_key: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get the payload for a feature flag (for remote configuration).
|
||||
|
||||
Example usage:
|
||||
config = get_feature_flag_payload(user.id, "new-dashboard-config")
|
||||
if config:
|
||||
theme = config.get("theme", "light")
|
||||
features = config.get("features", [])
|
||||
|
||||
Args:
|
||||
user_id: The user ID
|
||||
flag_key: The feature flag key
|
||||
|
||||
Returns:
|
||||
Dict with payload data, or None if not available
|
||||
"""
|
||||
if not is_posthog_enabled():
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_all_feature_flags(user_id: Any) -> Dict[str, Any]:
|
||||
"""
|
||||
Get all feature flags for a user.
|
||||
|
||||
Returns a dictionary of flag_key -> enabled/disabled
|
||||
|
||||
Args:
|
||||
user_id: The user ID
|
||||
|
||||
Returns:
|
||||
Dict of feature flags
|
||||
"""
|
||||
if not is_posthog_enabled():
|
||||
return {}
|
||||
|
||||
return {}
|
||||
|
||||
|
||||
def feature_flag_required(flag_key: str, redirect_to: Optional[str] = None):
|
||||
"""
|
||||
Decorator to require a feature flag for a route.
|
||||
|
||||
Usage:
|
||||
@app.route('/beta-feature')
|
||||
@feature_flag_required('beta-features')
|
||||
def beta_feature():
|
||||
return "This is a beta feature!"
|
||||
|
||||
Args:
|
||||
flag_key: The feature flag key to check
|
||||
redirect_to: URL to redirect to if flag is disabled (optional)
|
||||
"""
|
||||
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
from flask import abort, redirect, url_for
|
||||
from flask_login import current_user
|
||||
|
||||
if not current_user.is_authenticated:
|
||||
# Can't check feature flags for anonymous users
|
||||
if redirect_to:
|
||||
return redirect(redirect_to)
|
||||
abort(403)
|
||||
|
||||
if not get_feature_flag(current_user.id, flag_key):
|
||||
# Feature not enabled for this user
|
||||
if redirect_to:
|
||||
return redirect(redirect_to)
|
||||
abort(403)
|
||||
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def get_active_experiments(user_id: Any) -> Dict[str, str]:
|
||||
"""
|
||||
Get active experiments and their variants for a user.
|
||||
|
||||
This can be used for A/B testing and tracking which
|
||||
variants users are seeing.
|
||||
|
||||
Args:
|
||||
user_id: The user ID
|
||||
|
||||
Returns:
|
||||
Dict of experiment_key -> variant
|
||||
"""
|
||||
flags = get_all_feature_flags(user_id)
|
||||
|
||||
# Filter for experiments (flags that have variants)
|
||||
experiments = {}
|
||||
for flag_key, value in flags.items():
|
||||
if isinstance(value, str) and value not in ["true", "false"]:
|
||||
# This is likely a multivariate flag (experiment)
|
||||
experiments[flag_key] = value
|
||||
|
||||
return experiments
|
||||
|
||||
|
||||
def inject_feature_flags_to_frontend(user_id: Any) -> Dict[str, Any]:
|
||||
"""
|
||||
Get feature flags formatted for frontend injection.
|
||||
|
||||
This can be used to inject feature flags into JavaScript
|
||||
for frontend feature toggling.
|
||||
|
||||
Usage in template:
|
||||
<script>
|
||||
window.featureFlags = {{ feature_flags|tojson }};
|
||||
</script>
|
||||
|
||||
Args:
|
||||
user_id: The user ID
|
||||
|
||||
Returns:
|
||||
Dict of feature flags safe for frontend use
|
||||
"""
|
||||
if not is_posthog_enabled():
|
||||
return {}
|
||||
|
||||
try:
|
||||
flags = get_all_feature_flags(user_id)
|
||||
# Convert to boolean values for frontend
|
||||
return {key: bool(value) for key, value in flags.items()}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def override_feature_flag(user_id: Any, flag_key: str, value: bool):
|
||||
"""
|
||||
Override a feature flag for testing purposes.
|
||||
|
||||
Note: This only works in development/testing environments.
|
||||
|
||||
Args:
|
||||
user_id: The user ID
|
||||
flag_key: The feature flag key
|
||||
value: The value to set
|
||||
"""
|
||||
if os.getenv("FLASK_ENV") not in ["development", "testing"]:
|
||||
# Only allow overrides in dev/test
|
||||
return
|
||||
|
||||
try:
|
||||
# Store override in session or cache
|
||||
# This is a placeholder - implement based on your needs
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def track_feature_flag_interaction(user_id: Any, flag_key: str, action: str, properties: Optional[Dict] = None):
|
||||
"""
|
||||
Track when users interact with features controlled by feature flags.
|
||||
|
||||
This helps measure the impact of features and experiments.
|
||||
|
||||
Args:
|
||||
user_id: The user ID
|
||||
flag_key: The feature flag key
|
||||
action: The action taken (e.g., "clicked", "viewed", "completed")
|
||||
properties: Additional properties to track
|
||||
"""
|
||||
from app import track_event
|
||||
|
||||
event_properties = {"feature_flag": flag_key, "action": action, **(properties or {})}
|
||||
|
||||
track_event(user_id, "feature_interaction", event_properties)
|
||||
|
||||
|
||||
# Predefined feature flags for common use cases
|
||||
class FeatureFlags:
|
||||
"""
|
||||
Centralized feature flag keys for the application.
|
||||
|
||||
Define your feature flags here to avoid typos and enable autocomplete.
|
||||
"""
|
||||
|
||||
# Beta features
|
||||
BETA_FEATURES = "beta-features"
|
||||
NEW_DASHBOARD = "new-dashboard"
|
||||
ADVANCED_REPORTS = "advanced-reports"
|
||||
|
||||
# Experiments
|
||||
TIMER_UI_EXPERIMENT = "timer-ui-experiment"
|
||||
ONBOARDING_FLOW = "onboarding-flow"
|
||||
|
||||
# Rollout features
|
||||
NEW_ANALYTICS_PAGE = "new-analytics-page"
|
||||
BULK_OPERATIONS = "bulk-operations"
|
||||
|
||||
# Kill switches (for emergency feature disabling)
|
||||
ENABLE_EXPORTS = "enable-exports"
|
||||
ENABLE_API = "enable-api"
|
||||
ENABLE_WEBSOCKETS = "enable-websockets"
|
||||
|
||||
# Premium features (if you have paid tiers)
|
||||
CUSTOM_REPORTS = "custom-reports"
|
||||
API_ACCESS = "api-access"
|
||||
INTEGRATIONS = "integrations"
|
||||
|
||||
|
||||
# Example usage helper
|
||||
def is_feature_enabled_for_request(flag_key: str, default: bool = False) -> bool:
|
||||
"""
|
||||
Check if a feature is enabled for the current request's user.
|
||||
|
||||
Convenience function for use in templates and view functions.
|
||||
|
||||
Args:
|
||||
flag_key: The feature flag key
|
||||
default: Default value if user not authenticated
|
||||
|
||||
Returns:
|
||||
True if feature is enabled
|
||||
"""
|
||||
from flask_login import current_user
|
||||
|
||||
if not current_user.is_authenticated:
|
||||
return default
|
||||
|
||||
return get_feature_flag(current_user.id, flag_key, default)
|
||||
@@ -20,9 +20,8 @@ def identify_user_with_segments(user_id: Any, user) -> None:
|
||||
|
||||
This sets person properties in PostHog that can be used for:
|
||||
- Creating cohorts
|
||||
- Targeting feature flags
|
||||
- Analytics segmentation
|
||||
- Analyzing behavior by segment
|
||||
- A/B testing
|
||||
|
||||
Args:
|
||||
user_id: User ID
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Scope filtering for subcontractor role: restrict data to assigned clients/projects."""
|
||||
"""Scope filtering: restrict data to assigned clients/projects (subcontractors, client portal users)."""
|
||||
|
||||
from typing import Set, Tuple
|
||||
|
||||
@@ -29,10 +29,18 @@ def apply_client_scope(Client, query, user=None):
|
||||
return query.filter(scope)
|
||||
|
||||
|
||||
def apply_project_scope(Project, query, user=None):
|
||||
"""Apply project scope to a Project query. Returns query with scope filter applied if restricted."""
|
||||
scope = apply_project_scope_to_model(Project, user)
|
||||
if scope is None:
|
||||
return query
|
||||
return query.filter(scope)
|
||||
|
||||
|
||||
def apply_client_scope_to_model(Client, user=None):
|
||||
"""Return filter expression for Client query (Client.id.in_(...) or None for no filter)."""
|
||||
u = user or (current_user if current_user.is_authenticated else None)
|
||||
if not u or not u.is_scope_restricted:
|
||||
if not u or u.is_admin:
|
||||
return None
|
||||
allowed = u.get_allowed_client_ids()
|
||||
if allowed is None:
|
||||
@@ -45,7 +53,7 @@ def apply_client_scope_to_model(Client, user=None):
|
||||
def apply_project_scope_to_model(Project, user=None):
|
||||
"""Return filter expression for Project query (Project.client_id.in_(...) or Project.id.in_(...))."""
|
||||
u = user or (current_user if current_user.is_authenticated else None)
|
||||
if not u or not u.is_scope_restricted:
|
||||
if not u or u.is_admin:
|
||||
return None
|
||||
allowed_clients = u.get_allowed_client_ids()
|
||||
if allowed_clients is None:
|
||||
@@ -59,20 +67,24 @@ def user_can_access_client(user, client_id):
|
||||
"""Return True if user may access this client (for direct ID checks / 403)."""
|
||||
if not user:
|
||||
return False
|
||||
if user.is_admin or not user.is_scope_restricted:
|
||||
if user.is_admin:
|
||||
return True
|
||||
allowed = user.get_allowed_client_ids()
|
||||
return allowed is not None and client_id in allowed
|
||||
if allowed is None:
|
||||
return True
|
||||
return client_id in allowed
|
||||
|
||||
|
||||
def user_can_access_project(user, project_id):
|
||||
"""Return True if user may access this project (for direct ID checks / 403)."""
|
||||
if not user:
|
||||
return False
|
||||
if user.is_admin or not user.is_scope_restricted:
|
||||
if user.is_admin:
|
||||
return True
|
||||
allowed = user.get_allowed_project_ids()
|
||||
return allowed is not None and project_id in allowed
|
||||
if allowed is None:
|
||||
return True
|
||||
return project_id in allowed
|
||||
|
||||
|
||||
def get_accessible_project_and_client_ids_for_user(user_id: int) -> Tuple[Set[int], Set[int]]:
|
||||
|
||||
+6
-1
@@ -118,7 +118,12 @@ def search_clients(query: str) -> List[Client]:
|
||||
|
||||
return (
|
||||
Client.query.filter(
|
||||
or_(Client.name.ilike(search_term), Client.email.ilike(search_term), Client.company.ilike(search_term))
|
||||
or_(
|
||||
Client.name.ilike(search_term),
|
||||
Client.email.ilike(search_term),
|
||||
Client.description.ilike(search_term),
|
||||
Client.contact_person.ilike(search_term),
|
||||
)
|
||||
)
|
||||
.order_by(Client.name)
|
||||
.all()
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Hook successful report exports/views for support stats and soft prompts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def record_report_generation_for_current_user() -> None:
|
||||
"""Increment per-user report counter and queue a one-shot support prompt trigger."""
|
||||
from flask import session
|
||||
from flask_login import current_user
|
||||
|
||||
from app.services.usage_stats_service import UsageStatsService
|
||||
|
||||
if not getattr(current_user, "is_authenticated", False):
|
||||
return
|
||||
UsageStatsService.increment_reports_generated(current_user.id)
|
||||
session["support_prompt_trigger"] = "after_report"
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Semantic version helpers for release / update checks (uses packaging.version)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from packaging.version import InvalidVersion, Version
|
||||
|
||||
|
||||
def normalize_version_tag(raw: str | None) -> str | None:
|
||||
"""Strip whitespace and leading 'v'; return a normalized string if parseable as a Version, else None."""
|
||||
if raw is None:
|
||||
return None
|
||||
s = raw.strip()
|
||||
if not s:
|
||||
return None
|
||||
if s.lower().startswith("v"):
|
||||
s = s[1:].strip()
|
||||
if not s:
|
||||
return None
|
||||
try:
|
||||
return str(Version(s))
|
||||
except InvalidVersion:
|
||||
return None
|
||||
|
||||
|
||||
def is_upgrade(current: str | None, latest: str | None) -> bool:
|
||||
"""True iff both are valid versions and latest is strictly greater than current."""
|
||||
if not current or not latest:
|
||||
return False
|
||||
try:
|
||||
vc = Version(current)
|
||||
vl = Version(latest)
|
||||
except InvalidVersion:
|
||||
return False
|
||||
return vl > vc
|
||||
@@ -1,4 +1,5 @@
|
||||
# Crowdin CLI / GitHub Action configuration.
|
||||
# Project: https://crowdin.com/project/drytrix-timetracker
|
||||
# See docs/CONTRIBUTING_TRANSLATIONS.md → Crowdin setup.
|
||||
#
|
||||
# Secrets (never commit real values):
|
||||
|
||||
+24
-15
@@ -77,7 +77,7 @@ The UI is bundled from [`src/renderer/js/app.js`](src/renderer/js/app.js) into [
|
||||
npm start
|
||||
```
|
||||
|
||||
(`npm start` runs `build:renderer` first, then launches Electron.)
|
||||
(`npm start` runs `build:renderer` first via **prestart**, then launches Electron. `npm run build` uses **prebuild** the same way so installers do not ship a stale `bundle.js`.)
|
||||
|
||||
### Run with DevTools
|
||||
|
||||
@@ -97,11 +97,12 @@ Before connecting the desktop app, you need to create an API token:
|
||||
4. Fill in the required information:
|
||||
- **Name**: A descriptive name (e.g., "Desktop App - Windows")
|
||||
- **User**: Select the user this token will authenticate as
|
||||
- **Scopes**: Select the following permissions:
|
||||
- **Scopes**: Select at least the following permissions:
|
||||
- `read:projects` - View projects
|
||||
- `read:tasks` - View tasks
|
||||
- `read:time_entries` - View time entries
|
||||
- `read:time_entries` - View time entries (required for timer status; the app uses this if `read:users` is not granted)
|
||||
- `write:time_entries` - Create and update time entries
|
||||
- `read:users` - Recommended: lets the app verify your session with `GET /api/v1/users/me` (otherwise it falls back to timer status)
|
||||
- **Expires In**: Optional expiration period (leave empty for no expiration)
|
||||
5. Click **"Create Token"**
|
||||
6. **Important**: Copy the generated token immediately - you won't be able to see it again!
|
||||
@@ -115,13 +116,11 @@ The desktop app can be configured in multiple ways:
|
||||
#### Method 1: In-App Login (Recommended)
|
||||
|
||||
1. **Launch the desktop app**
|
||||
2. On the login screen, enter:
|
||||
- **Server URL**: Your TimeTracker server URL (e.g., `https://your-server.com`)
|
||||
- Do not include a trailing slash
|
||||
- Use `http://` for local development or `https://` for production
|
||||
- **API Token**: Paste the token you copied from the web app
|
||||
3. Click **"Login"**
|
||||
4. The app will validate your connection and show the main screen if successful
|
||||
2. **Step 1 — Server**: Enter your TimeTracker **base URL** (e.g. `https://your-server.com` or `http://192.168.1.10:5000`). Trailing slashes are normalized away. You may omit the scheme for convenience; the app assumes `https://` when checking.
|
||||
3. Click **Test server** and confirm you see a success message (the app calls `GET /api/v1/info` and checks for a TimeTracker response).
|
||||
4. Click **Continue to token**.
|
||||
5. **Step 2 — API token**: Paste the `tt_…` token from the web app, then **Log in**. The app verifies the token with the server (user profile or timer status).
|
||||
6. If successful, the main window opens. If initial server setup is not finished in the browser, `setup_required` in the API response is surfaced so you can complete setup first.
|
||||
|
||||
#### Method 2: Command Line
|
||||
|
||||
@@ -159,13 +158,23 @@ The app shows a connection status indicator in the header:
|
||||
|
||||
The connection is automatically checked every 30 seconds.
|
||||
|
||||
### Automated tests (renderer client)
|
||||
|
||||
From the `desktop/` directory:
|
||||
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
|
||||
Runs Node’s test runner on `test/api-client.test.js` (URL normalization, TimeTracker JSON shape checks, and error classification).
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
**"Invalid API token" error:**
|
||||
- Verify the token starts with `tt_`
|
||||
- Check that the token hasn't expired
|
||||
- Ensure the token has the required scopes
|
||||
- Try creating a new token in the web app
|
||||
**Login or “Test server” shows a TLS or certificate message:**
|
||||
- Use a certificate trusted by the OS, or for lab use only, try `http://` on a trusted network if your server supports it.
|
||||
|
||||
**Token rejected after “server OK”:**
|
||||
- Verify the token starts with `tt_`, is not expired, and includes at least `read:time_entries` (and ideally `read:users`).
|
||||
|
||||
**"Connection failed" error:**
|
||||
- Verify the server URL is correct and accessible
|
||||
|
||||
Generated
+1036
-1296
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,9 @@
|
||||
"description": "TimeTracker desktop app for Windows, Linux, and macOS",
|
||||
"main": "src/main/main.js",
|
||||
"scripts": {
|
||||
"prestart": "npm run build:renderer",
|
||||
"prebuild": "npm run build:renderer",
|
||||
"test": "node --test test/",
|
||||
"start": "npm run build:renderer && electron .",
|
||||
"dev": "electron . --dev",
|
||||
"build:renderer": "esbuild src/renderer/js/app.js --bundle --outfile=src/renderer/js/bundle.js --platform=browser --format=iife",
|
||||
@@ -29,9 +32,9 @@
|
||||
"homepage": "https://github.com/DRYTRIX/TimeTracker",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"electron": "^35.7.5",
|
||||
"electron-builder": "^25.1.8",
|
||||
"esbuild": "^0.24.2"
|
||||
"electron": "^41.2.1",
|
||||
"electron-builder": "^26.8.1",
|
||||
"esbuild": "^0.28.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.6.2",
|
||||
|
||||
@@ -146,6 +146,25 @@ button, input, select, textarea {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.wizard-step-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.wizard-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.wizard-actions .btn {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
@@ -205,6 +224,47 @@ button, input, select, textarea {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.header-connection {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.header-connection-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
font-size: 11px;
|
||||
line-height: 1.25;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.header-connection-url {
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 36vw;
|
||||
}
|
||||
|
||||
.header-connection-meta {
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.wizard-intro {
|
||||
margin: 0 0 16px;
|
||||
line-height: 1.45;
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.header-left h1 {
|
||||
@@ -233,6 +293,14 @@ button, input, select, textarea {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.connection-offline {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.connection-connecting {
|
||||
color: var(--warning, #b8860b);
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
@@ -23,26 +23,46 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Login Screen -->
|
||||
<!-- Login Screen (first-run wizard: server, then API token) -->
|
||||
<div id="login-screen" class="screen">
|
||||
<div class="login-container">
|
||||
<div class="login-header">
|
||||
<img src="../assets/logo.svg" alt="TimeTracker Logo" class="logo-image" id="login-logo">
|
||||
<h1>TimeTracker</h1>
|
||||
<p>Connect to your server</p>
|
||||
<p id="login-subtitle">Connect to your server</p>
|
||||
</div>
|
||||
<form id="login-form">
|
||||
<div id="wizard-step-welcome" class="login-wizard-step">
|
||||
<p class="wizard-intro">Welcome. This app talks to <strong>your</strong> TimeTracker server—nothing is hardcoded. You will enter the server address, verify the connection, then sign in with an API token.</p>
|
||||
<div class="form-group wizard-actions">
|
||||
<button type="button" id="login-wizard-continue" class="btn btn-primary">Get started</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="wizard-step-server" class="login-wizard-step" style="display: none;">
|
||||
<p class="wizard-step-label" id="wizard-step-server-label">Step 2 of 3 — Server URL</p>
|
||||
<div class="form-group">
|
||||
<label for="server-url">Server URL</label>
|
||||
<input type="url" id="server-url" required placeholder="https://your-server.com">
|
||||
<input type="text" id="server-url" required placeholder="https://192.168.1.50:5000" autocomplete="url" spellcheck="false">
|
||||
<small>Include protocol and port if needed (e.g. <code>http://</code> or <code>https://</code>). Hostname only defaults to <code>https://</code> when you continue.</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="api-token">API Token</label>
|
||||
<input type="password" id="api-token" required placeholder="tt_...">
|
||||
<small>Get your API token from Admin > Security & Access > Api-tokens</small>
|
||||
<div class="form-group wizard-actions">
|
||||
<button type="button" id="login-test-server-btn" class="btn btn-secondary">Test server</button>
|
||||
<button type="button" id="login-wizard-continue-server" class="btn btn-primary" disabled>Continue to sign in</button>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Login</button>
|
||||
</form>
|
||||
</div>
|
||||
<div id="wizard-step-token" class="login-wizard-step" style="display: none;">
|
||||
<p class="wizard-step-label">Step 3 of 3 — Sign in</p>
|
||||
<form id="login-form">
|
||||
<div class="form-group">
|
||||
<label for="api-token">API Token</label>
|
||||
<input type="password" id="api-token" required placeholder="tt_..." autocomplete="off">
|
||||
<small>From the web app: Admin → Security & Access → API tokens</small>
|
||||
</div>
|
||||
<div class="form-group wizard-actions">
|
||||
<button type="button" id="login-wizard-back" class="btn btn-secondary">Back</button>
|
||||
<button type="submit" class="btn btn-primary">Log in</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div id="login-error" class="error-message"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -53,7 +73,13 @@
|
||||
<header class="app-header">
|
||||
<div class="header-left">
|
||||
<h1>TimeTracker</h1>
|
||||
<span id="connection-status" class="connection-status" role="status" aria-live="polite" aria-label="Connection status" title="Connection status"></span>
|
||||
<div class="header-connection" role="group" aria-label="Server connection">
|
||||
<span id="connection-status" class="connection-status" role="status" aria-live="polite" aria-label="Connection status" title=""></span>
|
||||
<div class="header-connection-details">
|
||||
<span id="connection-url-label" class="header-connection-url" title=""></span>
|
||||
<span class="header-connection-meta">Last OK: <span id="connection-last-ok">—</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<button id="minimize-btn" class="header-btn" title="Minimize">−</button>
|
||||
@@ -237,6 +263,7 @@
|
||||
<div class="form-group">
|
||||
<button id="save-settings-btn" class="btn btn-primary">Save Settings</button>
|
||||
<button id="test-connection-btn" class="btn btn-secondary">Test Connection</button>
|
||||
<button type="button" id="reset-configuration-btn" class="btn btn-secondary">Reset configuration</button>
|
||||
</div>
|
||||
<div id="settings-message" class="message"></div>
|
||||
</div>
|
||||
|
||||
@@ -4,40 +4,159 @@ const cfg = (typeof window !== 'undefined' && window.config) ? window.config : (
|
||||
const storeGet = cfg.storeGet || (async (k) => null);
|
||||
const storeSet = cfg.storeSet || (async (k, v) => {});
|
||||
|
||||
/** @typedef {{ ok: true }} OkResult */
|
||||
/** @typedef {{ ok: false, code: string, message: string }} ErrResult */
|
||||
/** @typedef {OkResult | ErrResult} ValidationResult */
|
||||
|
||||
function isTlsRelatedError(error) {
|
||||
const code = error && error.code;
|
||||
const msg = (error && error.message) || '';
|
||||
const tlsCodes = new Set([
|
||||
'DEPTH_ZERO_SELF_SIGNED_CERT',
|
||||
'CERT_HAS_EXPIRED',
|
||||
'CERT_NOT_YET_VALID',
|
||||
'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
|
||||
'ERR_TLS_CERT_ALTNAME_INVALID',
|
||||
'SELF_SIGNED_CERT_IN_CHAIN',
|
||||
'UNABLE_TO_GET_ISSUER_CERT_LOCALLY',
|
||||
]);
|
||||
if (code && tlsCodes.has(code)) return true;
|
||||
if (/certificate|ssl|tls|UNABLE_TO_VERIFY/i.test(msg)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map axios/network errors to a stable code + user-facing message.
|
||||
* @param {import('axios').AxiosError} error
|
||||
* @returns {{ code: string, message: string }}
|
||||
*/
|
||||
function classifyAxiosError(error) {
|
||||
if (isTlsRelatedError(error)) {
|
||||
return {
|
||||
code: 'TLS',
|
||||
message:
|
||||
'SSL/TLS certificate could not be verified. If the server uses a self-signed certificate, install a trusted CA or use http:// only on trusted networks.',
|
||||
};
|
||||
}
|
||||
if (error.response) {
|
||||
const status = error.response.status;
|
||||
const data = error.response.data;
|
||||
if (status === 401) {
|
||||
return {
|
||||
code: 'UNAUTHORIZED',
|
||||
message: 'Authentication failed. Check your API token.',
|
||||
};
|
||||
}
|
||||
if (status === 403) {
|
||||
return {
|
||||
code: 'FORBIDDEN',
|
||||
message: 'Access denied. Your token may not have the required permissions (e.g. read:users).',
|
||||
};
|
||||
}
|
||||
if (status === 404) {
|
||||
return {
|
||||
code: 'NOT_FOUND',
|
||||
message: data?.error || 'Resource not found. Is the base URL correct (no extra path)?',
|
||||
};
|
||||
}
|
||||
if (status >= 500) {
|
||||
return { code: 'SERVER_ERROR', message: 'Server error. Please try again later.' };
|
||||
}
|
||||
if (data && typeof data === 'object' && data.error) {
|
||||
return { code: 'HTTP_' + status, message: String(data.error) };
|
||||
}
|
||||
return { code: 'HTTP_' + status, message: `Server returned HTTP ${status}.` };
|
||||
}
|
||||
if (error.code === 'ECONNABORTED') {
|
||||
return {
|
||||
code: 'TIMEOUT',
|
||||
message: 'Request timed out. Check the server URL, firewall, and network.',
|
||||
};
|
||||
}
|
||||
if (error.code === 'ENOTFOUND') {
|
||||
return {
|
||||
code: 'DNS',
|
||||
message: 'Host not found (DNS). Check the hostname in your server URL.',
|
||||
};
|
||||
}
|
||||
if (error.code === 'ECONNREFUSED') {
|
||||
return {
|
||||
code: 'REFUSED',
|
||||
message: 'Connection refused. Check the host, port, and that the TimeTracker server is running.',
|
||||
};
|
||||
}
|
||||
if (error.code === 'ENETUNREACH' || error.code === 'EHOSTUNREACH') {
|
||||
return {
|
||||
code: 'UNREACHABLE',
|
||||
message: 'Network unreachable. Check your connection and server address.',
|
||||
};
|
||||
}
|
||||
const msg = error.message || 'Unknown error';
|
||||
if (!error.response) {
|
||||
return {
|
||||
code: 'UNKNOWN',
|
||||
message:
|
||||
'Server not reachable. Check the URL, VPN, firewall, and that the TimeTracker server is running.',
|
||||
};
|
||||
}
|
||||
return { code: 'UNKNOWN', message: msg };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} data
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isTimeTrackerInfoPayload(data) {
|
||||
return (
|
||||
data !== null &&
|
||||
typeof data === 'object' &&
|
||||
!Array.isArray(data) &&
|
||||
data.api_version === 'v1' &&
|
||||
typeof data.endpoints === 'object'
|
||||
);
|
||||
}
|
||||
|
||||
const { attachIdempotentRetryInterceptors } = require('../connection/request_policy');
|
||||
|
||||
class ApiClient {
|
||||
constructor(baseUrl) {
|
||||
this.baseUrl = baseUrl;
|
||||
/**
|
||||
* @param {string} baseUrl
|
||||
* @param {{ enableIdempotentRetry?: boolean }} [options]
|
||||
*/
|
||||
constructor(baseUrl, options = {}) {
|
||||
const normalized = ApiClient.normalizeBaseUrl(baseUrl);
|
||||
this.baseUrl = normalized;
|
||||
this.client = axios.create({
|
||||
baseURL: baseUrl,
|
||||
baseURL: normalized,
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
this.setupInterceptors();
|
||||
if (options.enableIdempotentRetry !== false) {
|
||||
attachIdempotentRetryInterceptors(this.client);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
setupInterceptors() {
|
||||
// Add auth token to requests
|
||||
this.client.interceptors.request.use(async (config) => {
|
||||
const token = await storeGet('api_token');
|
||||
if (token) {
|
||||
config.headers['Authorization'] = `Bearer ${token}`;
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
// Handle errors
|
||||
|
||||
this.client.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
// Enhance error messages
|
||||
if (error.response) {
|
||||
const status = error.response.status;
|
||||
const data = error.response.data;
|
||||
|
||||
|
||||
if (status === 401) {
|
||||
error.message = 'Authentication failed. Please check your API token.';
|
||||
} else if (status === 403) {
|
||||
@@ -52,37 +171,156 @@ class ApiClient {
|
||||
} else if (error.code === 'ECONNABORTED') {
|
||||
error.message = 'Request timeout. Please check your internet connection.';
|
||||
} else if (error.code === 'ENOTFOUND' || error.code === 'ECONNREFUSED') {
|
||||
error.message = 'Unable to connect to server. Please check the server URL and your internet connection.';
|
||||
error.message =
|
||||
'Unable to connect to server. Please check the server URL and your internet connection.';
|
||||
} else if (isTlsRelatedError(error)) {
|
||||
error.message =
|
||||
'SSL/TLS error: certificate could not be verified. Use a trusted certificate or verify the server URL.';
|
||||
}
|
||||
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
static normalizeBaseUrl(url) {
|
||||
let u = String(url || '').trim();
|
||||
if (!u) return u;
|
||||
u = u.replace(/\/+$/, '');
|
||||
return u;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unauthenticated check: reachable TimeTracker JSON at GET /api/v1/info.
|
||||
* @param {string} baseUrl
|
||||
* @returns {Promise<ValidationResult>}
|
||||
*/
|
||||
static async testPublicServerInfo(baseUrl) {
|
||||
const normalized = ApiClient.normalizeBaseUrl(baseUrl);
|
||||
if (!normalized) {
|
||||
return { ok: false, code: 'NO_URL', message: 'Please enter a server URL.' };
|
||||
}
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(normalized);
|
||||
} catch (_) {
|
||||
return { ok: false, code: 'BAD_URL', message: 'Server URL is not valid.' };
|
||||
}
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
return { ok: false, code: 'BAD_URL', message: 'Server URL must start with http:// or https://.' };
|
||||
}
|
||||
|
||||
const plain = axios.create({
|
||||
baseURL: normalized,
|
||||
timeout: 10000,
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await plain.get('/api/v1/info');
|
||||
if (response.status !== 200) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'HTTP_' + response.status,
|
||||
message: `Server returned HTTP ${response.status}. Check the URL and port.`,
|
||||
};
|
||||
}
|
||||
const data = response.data;
|
||||
if (!isTimeTrackerInfoPayload(data)) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'NOT_TIMETRACKER',
|
||||
message:
|
||||
'This address did not return a TimeTracker API response. Check the URL (base URL only, no path) and port.',
|
||||
};
|
||||
}
|
||||
if (data.setup_required === true) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'SETUP_REQUIRED',
|
||||
message:
|
||||
'TimeTracker is not fully set up yet. Open this server URL in a browser, complete initial setup, then try again.',
|
||||
};
|
||||
}
|
||||
const appVersion = typeof data.app_version === 'string' ? data.app_version : null;
|
||||
return { ok: true, app_version: appVersion };
|
||||
} catch (error) {
|
||||
const { code, message } = classifyAxiosError(error);
|
||||
return { ok: false, code, message };
|
||||
}
|
||||
}
|
||||
|
||||
async setAuthToken(token) {
|
||||
await storeSet('api_token', token);
|
||||
}
|
||||
|
||||
async validateToken() {
|
||||
|
||||
/**
|
||||
* Authenticated session check: prefers GET /api/v1/users/me (read:users).
|
||||
* Falls back to GET /api/v1/timer/status (read:time_entries) for narrower tokens.
|
||||
* @returns {Promise<ValidationResult>}
|
||||
*/
|
||||
async validateSession() {
|
||||
try {
|
||||
const response = await this.client.get('/api/v1/info');
|
||||
return response.status === 200;
|
||||
const response = await this.client.get('/api/v1/users/me');
|
||||
if (response.status !== 200) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'HTTP_' + response.status,
|
||||
message: `Unexpected HTTP ${response.status} from the server.`,
|
||||
};
|
||||
}
|
||||
const data = response.data;
|
||||
if (!data || typeof data !== 'object' || !data.user) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'INVALID_RESPONSE',
|
||||
message: 'Server response was not a valid TimeTracker user payload.',
|
||||
};
|
||||
}
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
return false;
|
||||
const status = error.response && error.response.status;
|
||||
if (status === 401) {
|
||||
const { code, message } = classifyAxiosError(error);
|
||||
return { ok: false, code, message };
|
||||
}
|
||||
if (status === 403) {
|
||||
try {
|
||||
const res2 = await this.client.get('/api/v1/timer/status');
|
||||
if (res2.status === 200 && res2.data && typeof res2.data.active === 'boolean') {
|
||||
return { ok: true };
|
||||
}
|
||||
} catch (e2) {
|
||||
const { code, message } = classifyAxiosError(e2);
|
||||
return { ok: false, code, message };
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
code: 'FORBIDDEN',
|
||||
message:
|
||||
'This API token cannot access your profile or timer. Use a token with read:users or read:time_entries.',
|
||||
};
|
||||
}
|
||||
const { code, message } = classifyAxiosError(error);
|
||||
return { ok: false, code, message };
|
||||
}
|
||||
}
|
||||
|
||||
/** @deprecated Prefer validateSession() for correct auth + error detail */
|
||||
async validateToken() {
|
||||
const r = await this.validateSession();
|
||||
return r.ok;
|
||||
}
|
||||
|
||||
async getUsersMe() {
|
||||
const response = await this.client.get('/api/v1/users/me');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// Timer endpoints
|
||||
|
||||
async getTimerStatus() {
|
||||
return await this.client.get('/api/v1/timer/status');
|
||||
}
|
||||
|
||||
|
||||
async startTimer({ projectId, taskId, notes }) {
|
||||
return await this.client.post('/api/v1/timer/start', {
|
||||
project_id: projectId,
|
||||
@@ -90,12 +328,11 @@ class ApiClient {
|
||||
notes: notes,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
async stopTimer() {
|
||||
return await this.client.post('/api/v1/timer/stop');
|
||||
}
|
||||
|
||||
// Time entries endpoints
|
||||
|
||||
async getTimeEntries({ projectId, startDate, endDate, billable, page, perPage }) {
|
||||
const params = {};
|
||||
if (projectId) params.project_id = projectId;
|
||||
@@ -104,33 +341,32 @@ class ApiClient {
|
||||
if (billable !== undefined) params.billable = billable;
|
||||
if (page) params.page = page;
|
||||
if (perPage) params.per_page = perPage;
|
||||
|
||||
|
||||
return await this.client.get('/api/v1/time-entries', { params });
|
||||
}
|
||||
|
||||
|
||||
async createTimeEntry(data) {
|
||||
return await this.client.post('/api/v1/time-entries', data);
|
||||
}
|
||||
|
||||
|
||||
async updateTimeEntry(id, data) {
|
||||
return await this.client.put(`/api/v1/time-entries/${id}`, data);
|
||||
}
|
||||
|
||||
|
||||
async deleteTimeEntry(id) {
|
||||
return await this.client.delete(`/api/v1/time-entries/${id}`);
|
||||
}
|
||||
|
||||
// Projects endpoints
|
||||
|
||||
async getProjects({ status, clientId, page, perPage }) {
|
||||
const params = {};
|
||||
if (status) params.status = status;
|
||||
if (clientId) params.client_id = clientId;
|
||||
if (page) params.page = page;
|
||||
if (perPage) params.per_page = perPage;
|
||||
|
||||
|
||||
return await this.client.get('/api/v1/projects', { params });
|
||||
}
|
||||
|
||||
|
||||
async getProject(id) {
|
||||
return await this.client.get(`/api/v1/projects/${id}`);
|
||||
}
|
||||
@@ -142,28 +378,25 @@ class ApiClient {
|
||||
if (perPage) params.per_page = perPage;
|
||||
return await this.client.get('/api/v1/clients', { params });
|
||||
}
|
||||
|
||||
// Tasks endpoints
|
||||
|
||||
async getTasks({ projectId, status, page, perPage }) {
|
||||
const params = {};
|
||||
if (projectId) params.project_id = projectId;
|
||||
if (status) params.status = status;
|
||||
if (page) params.page = page;
|
||||
if (perPage) params.per_page = perPage;
|
||||
|
||||
|
||||
return await this.client.get('/api/v1/tasks', { params });
|
||||
}
|
||||
|
||||
|
||||
async getTask(id) {
|
||||
return await this.client.get(`/api/v1/tasks/${id}`);
|
||||
}
|
||||
|
||||
// Get time entry by ID
|
||||
|
||||
async getTimeEntry(id) {
|
||||
return await this.client.get(`/api/v1/time-entries/${id}`);
|
||||
}
|
||||
|
||||
// Invoices endpoints
|
||||
async getInvoices({ status, clientId, projectId, page, perPage }) {
|
||||
const params = {};
|
||||
if (status) params.status = status;
|
||||
@@ -187,7 +420,6 @@ class ApiClient {
|
||||
return await this.client.put(`/api/v1/invoices/${id}`, data);
|
||||
}
|
||||
|
||||
// Expenses endpoints
|
||||
async getExpenses({ projectId, category, startDate, endDate, page, perPage }) {
|
||||
const params = {};
|
||||
if (projectId) params.project_id = projectId;
|
||||
@@ -285,7 +517,8 @@ class ApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
// Export for use in other files
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = ApiClient;
|
||||
module.exports.classifyAxiosError = classifyAxiosError;
|
||||
module.exports.isTimeTrackerInfoPayload = isTimeTrackerInfoPayload;
|
||||
}
|
||||
|
||||
+487
-148
@@ -1,38 +1,85 @@
|
||||
// Main application logic
|
||||
// First-run depends on ../shared/config.js exposing window.config before this bundle (see index.html).
|
||||
require('./utils/helpers');
|
||||
const { storeGet, storeSet, storeDelete, storeClear } = window.config || {};
|
||||
const ApiClient = require('./api/client');
|
||||
const { createConnectionManager } = require('./connection/connection_manager');
|
||||
const { CONNECTION_STATE } = require('./connection/connection_state');
|
||||
const { startTimerWithReconcile, stopTimerWithReconcile } = require('./connection/timer_operations');
|
||||
const { classifyAxiosError } = require('./api/client');
|
||||
const StorageService = require('./storage/storage');
|
||||
const { showError, showSuccess } = require('./ui/notifications');
|
||||
const state = require('./state');
|
||||
|
||||
/** @type {ReturnType<typeof createConnectionManager> | null} */
|
||||
let connectionManager = null;
|
||||
|
||||
/** @type {'welcome'|'server'|'token'} */
|
||||
let loginWizardStep = 'welcome';
|
||||
|
||||
function truncateUrl(url, maxLen) {
|
||||
const s = String(url || '');
|
||||
const m = maxLen || 42;
|
||||
if (s.length <= m) return s;
|
||||
return s.slice(0, m - 1) + '…';
|
||||
}
|
||||
|
||||
// Initialize app
|
||||
async function initApp() {
|
||||
// Check if already logged in
|
||||
const serverUrl = await storeGet('server_url');
|
||||
const apiToken = await storeGet('api_token');
|
||||
|
||||
if (serverUrl && apiToken) {
|
||||
// Initialize API client
|
||||
state.apiClient = new ApiClient(serverUrl);
|
||||
await state.apiClient.setAuthToken(apiToken);
|
||||
|
||||
// Validate token
|
||||
const isValid = await state.apiClient.validateToken();
|
||||
if (isValid) {
|
||||
await loadCurrentUserProfile();
|
||||
showMainScreen();
|
||||
loadDashboard();
|
||||
} else {
|
||||
showLoginScreen();
|
||||
}
|
||||
connectionManager = createConnectionManager({
|
||||
storeGet,
|
||||
storeSet,
|
||||
storeDelete,
|
||||
storeClear,
|
||||
onCacheClear: () => {
|
||||
if (typeof state.clearViewCaches === 'function') state.clearViewCaches();
|
||||
},
|
||||
});
|
||||
|
||||
connectionManager.subscribe(() => {
|
||||
state.apiClient = connectionManager.getClient();
|
||||
updateConnectionFromManager();
|
||||
});
|
||||
|
||||
const boot = await connectionManager.bootstrapFromStore();
|
||||
|
||||
if (boot.ok) {
|
||||
state.authFailureStreak = 0;
|
||||
await loadCurrentUserProfile();
|
||||
showMainScreen();
|
||||
loadDashboard();
|
||||
} else if (boot.reason === 'offline' && boot.hadCredentials) {
|
||||
showLoginScreen({
|
||||
prefillServerUrl: connectionManager.getSnapshot().serverUrl || '',
|
||||
openTokenStep: true,
|
||||
bannerMessage: 'You appear to be offline. Reconnect to the network, then use Log in.',
|
||||
});
|
||||
} else if (boot.reason === 'session' && boot.session) {
|
||||
showLoginScreen({ prefillServerUrl: connectionManager.getSnapshot().serverUrl || '', sessionError: boot.session });
|
||||
} else if (boot.reason === 'token_server_mismatch') {
|
||||
showLoginScreen({
|
||||
prefillServerUrl: connectionManager.getSnapshot().serverUrl || '',
|
||||
bannerMessage: connectionManager.getSnapshot().lastError || 'Please sign in again.',
|
||||
});
|
||||
} else {
|
||||
showLoginScreen();
|
||||
showLoginScreen({ prefillServerUrl: connectionManager.getSnapshot().serverUrl || '' });
|
||||
}
|
||||
|
||||
|
||||
setupEventListeners();
|
||||
startConnectionCheck();
|
||||
setupTrayListeners();
|
||||
|
||||
window.addEventListener('online', async () => {
|
||||
if (!connectionManager.getClient()) {
|
||||
const retry = await connectionManager.bootstrapFromStore();
|
||||
if (retry.ok && document.getElementById('main-screen')?.classList.contains('active')) {
|
||||
state.authFailureStreak = 0;
|
||||
await loadCurrentUserProfile();
|
||||
loadDashboard();
|
||||
}
|
||||
}
|
||||
await checkConnection();
|
||||
});
|
||||
}
|
||||
|
||||
function setupTrayListeners() {
|
||||
@@ -61,16 +108,37 @@ function startConnectionCheck() {
|
||||
}
|
||||
|
||||
async function checkConnection() {
|
||||
if (typeof navigator !== 'undefined' && navigator.onLine && !connectionManager.getClient()) {
|
||||
const snap = connectionManager.getSnapshot();
|
||||
if (snap.serverUrl && (await storeGet('api_token'))) {
|
||||
const boot = await connectionManager.bootstrapFromStore();
|
||||
if (boot.ok && document.getElementById('main-screen')?.classList.contains('active')) {
|
||||
state.authFailureStreak = 0;
|
||||
await loadCurrentUserProfile();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!state.apiClient) {
|
||||
updateConnectionStatus('disconnected');
|
||||
updateConnectionFromManager();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const isValid = await state.apiClient.validateToken();
|
||||
updateConnectionStatus(isValid ? 'connected' : 'error');
|
||||
} catch (error) {
|
||||
updateConnectionStatus('error');
|
||||
|
||||
const session = await connectionManager.validateSessionRefresh();
|
||||
if (session.ok) {
|
||||
state.authFailureStreak = 0;
|
||||
updateConnectionFromManager();
|
||||
return;
|
||||
}
|
||||
|
||||
updateConnectionFromManager();
|
||||
if (session.code === 'UNAUTHORIZED') {
|
||||
state.authFailureStreak = (state.authFailureStreak || 0) + 1;
|
||||
if (state.authFailureStreak >= 2 && document.getElementById('main-screen')?.classList.contains('active')) {
|
||||
await forceRelogin(session.message || 'Your session is no longer valid. Please sign in again.');
|
||||
}
|
||||
} else {
|
||||
state.authFailureStreak = 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,37 +154,127 @@ async function loadCurrentUserProfile() {
|
||||
is_admin: Boolean(user.is_admin),
|
||||
can_approve: Boolean(user.is_admin) || roleCanApprove,
|
||||
};
|
||||
} catch (_) {
|
||||
} catch (err) {
|
||||
console.error('loadCurrentUserProfile failed:', err);
|
||||
if (err && err.stack) console.error(err.stack);
|
||||
state.currentUserProfile = { id: null, is_admin: false, can_approve: false };
|
||||
const { message } = classifyAxiosError(err);
|
||||
showError(message || 'Could not load your user profile. Some actions may be unavailable until the connection improves.');
|
||||
}
|
||||
}
|
||||
|
||||
function updateConnectionStatus(status) {
|
||||
function updateConnectionFromManager() {
|
||||
if (!connectionManager) return;
|
||||
const snap = connectionManager.getSnapshot();
|
||||
const statusEl = document.getElementById('connection-status');
|
||||
const urlEl = document.getElementById('connection-url-label');
|
||||
const timeEl = document.getElementById('connection-last-ok');
|
||||
if (!statusEl) return;
|
||||
|
||||
statusEl.className = 'connection-status connection-' + status;
|
||||
var label = 'Connection status: ';
|
||||
switch (status) {
|
||||
case 'connected':
|
||||
statusEl.textContent = '●';
|
||||
statusEl.title = 'Connected';
|
||||
|
||||
let cssSuffix = 'disconnected';
|
||||
let title = '';
|
||||
let label = 'Connection status: ';
|
||||
|
||||
switch (snap.state) {
|
||||
case CONNECTION_STATE.CONNECTED:
|
||||
cssSuffix = 'connected';
|
||||
title = snap.serverUrl || 'Connected';
|
||||
label += 'Connected';
|
||||
break;
|
||||
case 'error':
|
||||
statusEl.textContent = '●';
|
||||
statusEl.title = 'Connection error';
|
||||
label += 'Error';
|
||||
break;
|
||||
case 'disconnected':
|
||||
statusEl.textContent = '○';
|
||||
statusEl.title = 'Disconnected';
|
||||
label += 'Disconnected';
|
||||
case CONNECTION_STATE.OFFLINE:
|
||||
cssSuffix = 'offline';
|
||||
title = snap.lastError || 'Offline';
|
||||
label += 'Offline';
|
||||
statusEl.textContent = '●';
|
||||
break;
|
||||
case CONNECTION_STATE.CONNECTING:
|
||||
cssSuffix = 'connecting';
|
||||
title = snap.lastError || 'Connecting…';
|
||||
label += 'Connecting';
|
||||
statusEl.textContent = '◐';
|
||||
break;
|
||||
case CONNECTION_STATE.ERROR:
|
||||
cssSuffix = 'error';
|
||||
title = snap.lastError || 'Connection error';
|
||||
label += 'Error';
|
||||
statusEl.textContent = '●';
|
||||
break;
|
||||
default:
|
||||
label += 'Unknown';
|
||||
title = snap.serverUrl || 'Not configured';
|
||||
label += 'Not configured';
|
||||
statusEl.textContent = '○';
|
||||
}
|
||||
|
||||
statusEl.className = 'connection-status connection-' + cssSuffix;
|
||||
statusEl.title = title;
|
||||
statusEl.setAttribute('aria-label', label);
|
||||
|
||||
if (urlEl) {
|
||||
urlEl.textContent = snap.serverUrl ? truncateUrl(snap.serverUrl) : '—';
|
||||
urlEl.title = snap.serverUrl || '';
|
||||
}
|
||||
if (timeEl) {
|
||||
timeEl.textContent = snap.lastConnectedAt ? formatDateTime(new Date(snap.lastConnectedAt)) : '—';
|
||||
}
|
||||
}
|
||||
|
||||
async function forceRelogin(message) {
|
||||
state.authFailureStreak = 0;
|
||||
const url = await storeGet('server_url');
|
||||
if (state.isTimerRunning) {
|
||||
state.isTimerRunning = false;
|
||||
stopTimerPolling();
|
||||
}
|
||||
await connectionManager.logoutKeepServer();
|
||||
showLoginScreen({
|
||||
prefillServerUrl: url ? ApiClient.normalizeBaseUrl(String(url)) : '',
|
||||
openTokenStep: true,
|
||||
bannerMessage: message,
|
||||
});
|
||||
}
|
||||
|
||||
function showWizardWelcomeStep() {
|
||||
loginWizardStep = 'welcome';
|
||||
const w = document.getElementById('wizard-step-welcome');
|
||||
const s1 = document.getElementById('wizard-step-server');
|
||||
const s2 = document.getElementById('wizard-step-token');
|
||||
if (w) w.style.display = '';
|
||||
if (s1) s1.style.display = 'none';
|
||||
if (s2) s2.style.display = 'none';
|
||||
}
|
||||
|
||||
function showWizardServerStep() {
|
||||
loginWizardStep = 'server';
|
||||
const w = document.getElementById('wizard-step-welcome');
|
||||
const s1 = document.getElementById('wizard-step-server');
|
||||
const s2 = document.getElementById('wizard-step-token');
|
||||
if (w) w.style.display = 'none';
|
||||
if (s1) s1.style.display = '';
|
||||
if (s2) s2.style.display = 'none';
|
||||
}
|
||||
|
||||
function showWizardTokenStep() {
|
||||
loginWizardStep = 'token';
|
||||
const w = document.getElementById('wizard-step-welcome');
|
||||
const s1 = document.getElementById('wizard-step-server');
|
||||
const s2 = document.getElementById('wizard-step-token');
|
||||
if (w) w.style.display = 'none';
|
||||
if (s1) s1.style.display = 'none';
|
||||
if (s2) s2.style.display = '';
|
||||
}
|
||||
|
||||
function resetLoginWizard() {
|
||||
showWizardWelcomeStep();
|
||||
const contServer = document.getElementById('login-wizard-continue-server');
|
||||
if (contServer) contServer.disabled = true;
|
||||
const testBtn = document.getElementById('login-test-server-btn');
|
||||
if (testBtn) testBtn.disabled = false;
|
||||
clearLoginError();
|
||||
}
|
||||
|
||||
function clearLoginError() {
|
||||
showLoginError('');
|
||||
}
|
||||
|
||||
function setupEventListeners() {
|
||||
@@ -125,6 +283,14 @@ function setupEventListeners() {
|
||||
if (loginForm) {
|
||||
loginForm.addEventListener('submit', handleLogin);
|
||||
}
|
||||
const loginTestServerBtn = document.getElementById('login-test-server-btn');
|
||||
const loginWizardContinue = document.getElementById('login-wizard-continue');
|
||||
const loginWizardContinueServer = document.getElementById('login-wizard-continue-server');
|
||||
const loginWizardBack = document.getElementById('login-wizard-back');
|
||||
if (loginTestServerBtn) loginTestServerBtn.addEventListener('click', handleLoginTestServer);
|
||||
if (loginWizardContinue) loginWizardContinue.addEventListener('click', handleLoginWizardContinue);
|
||||
if (loginWizardContinueServer) loginWizardContinueServer.addEventListener('click', handleLoginWizardContinue);
|
||||
if (loginWizardBack) loginWizardBack.addEventListener('click', handleLoginWizardBack);
|
||||
|
||||
// Navigation
|
||||
document.querySelectorAll('.nav-btn').forEach(btn => {
|
||||
@@ -160,6 +326,8 @@ function setupEventListeners() {
|
||||
const autoSyncInput = document.getElementById('auto-sync');
|
||||
if (saveSettingsBtn) saveSettingsBtn.addEventListener('click', handleSaveSettings);
|
||||
if (testConnectionBtn) testConnectionBtn.addEventListener('click', handleTestConnection);
|
||||
const resetConfigBtn = document.getElementById('reset-configuration-btn');
|
||||
if (resetConfigBtn) resetConfigBtn.addEventListener('click', handleResetConfiguration);
|
||||
if (autoSyncInput) {
|
||||
autoSyncInput.addEventListener('change', () => updateSyncIntervalState());
|
||||
}
|
||||
@@ -213,58 +381,164 @@ function setupEventListeners() {
|
||||
if (expenseNextPageBtn) expenseNextPageBtn.addEventListener('click', () => changeExpensePage(1));
|
||||
}
|
||||
|
||||
async function handleLoginTestServer() {
|
||||
clearLoginError();
|
||||
const raw = document.getElementById('server-url')?.value.trim() || '';
|
||||
const normalizedInput = normalizeServerUrlInput(raw);
|
||||
if (!normalizedInput || !isValidUrl(normalizedInput)) {
|
||||
showLoginError('Enter a valid server URL (e.g. https://your-server.com or http://192.168.1.10:5000)');
|
||||
return;
|
||||
}
|
||||
const serverUrl = ApiClient.normalizeBaseUrl(normalizedInput);
|
||||
const testBtn = document.getElementById('login-test-server-btn');
|
||||
const contServer = document.getElementById('login-wizard-continue-server');
|
||||
if (testBtn) testBtn.disabled = true;
|
||||
if (contServer) contServer.disabled = true;
|
||||
const pub = await connectionManager.testServer(serverUrl);
|
||||
if (testBtn) testBtn.disabled = false;
|
||||
if (contServer) contServer.disabled = true;
|
||||
if (!pub.ok) {
|
||||
showLoginError(pub.message);
|
||||
return;
|
||||
}
|
||||
const ver = pub.app_version ? ` (server version ${pub.app_version})` : '';
|
||||
showSuccess(`TimeTracker server detected${ver}. Continue to enter your API token.`);
|
||||
if (contServer) contServer.disabled = false;
|
||||
}
|
||||
|
||||
async function handleLoginWizardContinue() {
|
||||
clearLoginError();
|
||||
if (loginWizardStep === 'welcome') {
|
||||
showWizardServerStep();
|
||||
return;
|
||||
}
|
||||
|
||||
const raw = document.getElementById('server-url')?.value.trim() || '';
|
||||
const normalizedInput = normalizeServerUrlInput(raw);
|
||||
if (!normalizedInput || !isValidUrl(normalizedInput)) {
|
||||
showLoginError('Enter a valid server URL');
|
||||
return;
|
||||
}
|
||||
const serverUrl = ApiClient.normalizeBaseUrl(normalizedInput);
|
||||
const contServer = document.getElementById('login-wizard-continue-server');
|
||||
if (contServer) contServer.disabled = true;
|
||||
const pub = await connectionManager.testServer(serverUrl);
|
||||
if (!pub.ok) {
|
||||
if (contServer) contServer.disabled = true;
|
||||
showLoginError(pub.message);
|
||||
return;
|
||||
}
|
||||
if (contServer) contServer.disabled = false;
|
||||
showWizardTokenStep();
|
||||
}
|
||||
|
||||
function handleLoginWizardBack() {
|
||||
clearLoginError();
|
||||
if (loginWizardStep === 'token') {
|
||||
showWizardServerStep();
|
||||
return;
|
||||
}
|
||||
if (loginWizardStep === 'server') {
|
||||
showWizardWelcomeStep();
|
||||
return;
|
||||
}
|
||||
showWizardWelcomeStep();
|
||||
}
|
||||
|
||||
async function handleLogin(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const serverUrl = document.getElementById('server-url').value.trim();
|
||||
const apiToken = document.getElementById('api-token').value.trim();
|
||||
const errorDiv = document.getElementById('login-error');
|
||||
|
||||
// Validate
|
||||
if (!serverUrl || !isValidUrl(serverUrl)) {
|
||||
|
||||
const raw = document.getElementById('server-url')?.value.trim() || '';
|
||||
const normalizedInput = normalizeServerUrlInput(raw);
|
||||
if (!normalizedInput || !isValidUrl(normalizedInput)) {
|
||||
showLoginError('Please enter a valid server URL');
|
||||
return;
|
||||
}
|
||||
|
||||
const serverUrl = ApiClient.normalizeBaseUrl(normalizedInput);
|
||||
|
||||
const apiToken = document.getElementById('api-token')?.value.trim() || '';
|
||||
if (!apiToken || !apiToken.startsWith('tt_')) {
|
||||
showError('Please enter a valid API token (must start with tt_)');
|
||||
showLoginError('Please enter a valid API token (must start with tt_)');
|
||||
return;
|
||||
}
|
||||
|
||||
// Store credentials
|
||||
await storeSet('server_url', serverUrl);
|
||||
await storeSet('api_token', apiToken);
|
||||
|
||||
// Initialize API client
|
||||
state.apiClient = new ApiClient(serverUrl);
|
||||
await state.apiClient.setAuthToken(apiToken);
|
||||
|
||||
// Validate token
|
||||
const isValid = await state.apiClient.validateToken();
|
||||
if (isValid) {
|
||||
await loadCurrentUserProfile();
|
||||
updateConnectionStatus('connected');
|
||||
showMainScreen();
|
||||
loadDashboard();
|
||||
|
||||
const result = await connectionManager.login(serverUrl, apiToken);
|
||||
|
||||
if (result.ok) {
|
||||
state.authFailureStreak = 0;
|
||||
await loadCurrentUserProfile();
|
||||
showMainScreen();
|
||||
loadDashboard();
|
||||
} else {
|
||||
const msg = result.session?.message || result.message || 'Login failed';
|
||||
showLoginError(msg);
|
||||
if (result.step === 'auth' && (result.session?.code === 'UNAUTHORIZED' || result.session?.code === 'FORBIDDEN')) {
|
||||
const contServer = document.getElementById('login-wizard-continue-server');
|
||||
if (contServer) contServer.disabled = false;
|
||||
showWizardTokenStep();
|
||||
} else if (result.step === 'server') {
|
||||
showWizardServerStep();
|
||||
} else {
|
||||
updateConnectionStatus('error');
|
||||
showLoginError('Invalid API token. Please check your token.');
|
||||
await storeDelete('api_token');
|
||||
showWizardServerStep();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function showLoginError(message) {
|
||||
const errorDiv = document.getElementById('login-error');
|
||||
if (errorDiv) {
|
||||
errorDiv.textContent = message;
|
||||
if (!errorDiv) return;
|
||||
errorDiv.textContent = message || '';
|
||||
if (message) {
|
||||
errorDiv.classList.add('show');
|
||||
} else {
|
||||
errorDiv.classList.remove('show');
|
||||
}
|
||||
}
|
||||
|
||||
function showLoginScreen() {
|
||||
function showLoginScreen(options = {}) {
|
||||
document.getElementById('loading-screen').classList.remove('active');
|
||||
document.getElementById('login-screen').classList.add('active');
|
||||
document.getElementById('main-screen').classList.remove('active');
|
||||
state.authFailureStreak = 0;
|
||||
|
||||
const su = document.getElementById('server-url');
|
||||
if (su && options.prefillServerUrl !== undefined && options.prefillServerUrl !== null) {
|
||||
su.value = String(options.prefillServerUrl || '');
|
||||
}
|
||||
|
||||
if (options.openTokenStep) {
|
||||
const contServer = document.getElementById('login-wizard-continue-server');
|
||||
if (contServer) contServer.disabled = false;
|
||||
showWizardTokenStep();
|
||||
if (options.bannerMessage) {
|
||||
showLoginError(options.bannerMessage);
|
||||
} else {
|
||||
clearLoginError();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.bannerMessage && !options.sessionError) {
|
||||
resetLoginWizard();
|
||||
showLoginError(options.bannerMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.sessionError) {
|
||||
const se = options.sessionError;
|
||||
if (se.code === 'UNAUTHORIZED' || se.code === 'FORBIDDEN') {
|
||||
const contServer = document.getElementById('login-wizard-continue-server');
|
||||
if (contServer) contServer.disabled = false;
|
||||
showWizardTokenStep();
|
||||
showLoginError(se.message || 'Authentication failed');
|
||||
return;
|
||||
}
|
||||
resetLoginWizard();
|
||||
showLoginError(se.message || 'Could not reach the server');
|
||||
return;
|
||||
}
|
||||
|
||||
resetLoginWizard();
|
||||
}
|
||||
|
||||
function showMainScreen() {
|
||||
@@ -332,6 +606,9 @@ async function loadDashboard() {
|
||||
loadRecentEntries();
|
||||
} catch (error) {
|
||||
console.error('Error loading dashboard:', error);
|
||||
if (error && error.stack) console.error(error.stack);
|
||||
const { message } = classifyAxiosError(error);
|
||||
showError(message || 'Could not load the dashboard.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -359,6 +636,9 @@ async function loadRecentEntries() {
|
||||
`).join('');
|
||||
} catch (error) {
|
||||
console.error('Error loading recent entries:', error);
|
||||
if (error && error.stack) console.error(error.stack);
|
||||
const { message } = classifyAxiosError(error);
|
||||
showError(message || 'Could not load recent entries.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -383,6 +663,9 @@ async function loadProjects() {
|
||||
`).join('');
|
||||
} catch (error) {
|
||||
console.error('Error loading projects:', error);
|
||||
if (error && error.stack) console.error(error.stack);
|
||||
const { message } = classifyAxiosError(error);
|
||||
showError(message || 'Could not load projects.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -471,7 +754,7 @@ async function handleStartTimer() {
|
||||
if (!result) return; // User cancelled
|
||||
|
||||
try {
|
||||
const response = await state.apiClient.startTimer({
|
||||
const response = await startTimerWithReconcile(state.apiClient, {
|
||||
projectId: result.projectId,
|
||||
taskId: result.taskId,
|
||||
notes: result.notes,
|
||||
@@ -484,7 +767,10 @@ async function handleStartTimer() {
|
||||
document.getElementById('stop-timer-btn').style.display = 'block';
|
||||
}
|
||||
} catch (error) {
|
||||
showError('Failed to start timer: ' + (error.response?.data?.error || error.message));
|
||||
console.error('Failed to start timer:', error);
|
||||
if (error && error.stack) console.error(error.stack);
|
||||
const { message } = classifyAxiosError(error);
|
||||
showError(message || 'Failed to start timer: ' + (error.response?.data?.error || error.message));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -494,16 +780,24 @@ async function showStartTimerDialog() {
|
||||
let projects = [];
|
||||
let requirements = { require_task: false, require_description: false, description_min_length: 20 };
|
||||
try {
|
||||
const [projectsResponse, usersMeResponse] = await Promise.all([
|
||||
state.apiClient.getProjects({ status: 'active' }),
|
||||
state.apiClient.getUsersMe().catch(() => ({})),
|
||||
]);
|
||||
const projectsResponse = await state.apiClient.getProjects({ status: 'active' });
|
||||
projects = projectsResponse.data.projects || [];
|
||||
if (usersMeResponse.time_entry_requirements) {
|
||||
requirements = usersMeResponse.time_entry_requirements;
|
||||
try {
|
||||
const usersMeResponse = await state.apiClient.getUsersMe();
|
||||
if (usersMeResponse && usersMeResponse.time_entry_requirements) {
|
||||
requirements = usersMeResponse.time_entry_requirements;
|
||||
}
|
||||
} catch (meErr) {
|
||||
console.error('getUsersMe for timer dialog:', meErr);
|
||||
if (meErr && meErr.stack) console.error(meErr.stack);
|
||||
const { message } = classifyAxiosError(meErr);
|
||||
showError(message || 'Could not load time entry rules; using defaults.');
|
||||
}
|
||||
} catch (error) {
|
||||
showError('Failed to load projects');
|
||||
console.error('Failed to load projects for timer dialog:', error);
|
||||
if (error && error.stack) console.error(error.stack);
|
||||
const { message } = classifyAxiosError(error);
|
||||
showError(message || 'Failed to load projects');
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
@@ -619,7 +913,7 @@ async function handleStopTimer() {
|
||||
if (!state.apiClient) return;
|
||||
|
||||
try {
|
||||
await state.apiClient.stopTimer();
|
||||
await stopTimerWithReconcile(state.apiClient);
|
||||
state.isTimerRunning = false;
|
||||
stopTimerPolling();
|
||||
document.getElementById('timer-display').textContent = '00:00:00';
|
||||
@@ -635,7 +929,9 @@ async function handleStopTimer() {
|
||||
loadRecentEntries();
|
||||
} catch (error) {
|
||||
console.error('Error stopping timer:', error);
|
||||
showError('Failed to stop timer: ' + (error.response?.data?.error || error.message));
|
||||
if (error && error.stack) console.error(error.stack);
|
||||
const { message } = classifyAxiosError(error);
|
||||
showError(message || 'Failed to stop timer: ' + (error.response?.data?.error || error.message));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -655,6 +951,17 @@ function startTimerPolling() {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error polling timer:', error);
|
||||
if (error && error.stack) console.error(error.stack);
|
||||
const { message } = classifyAxiosError(error);
|
||||
connectionManager.signalError(message || 'Lost connection while syncing the active timer.');
|
||||
updateConnectionFromManager();
|
||||
const now = Date.now();
|
||||
if (!state.lastTimerPollUserMessageAt || now - state.lastTimerPollUserMessageAt > 60000) {
|
||||
state.lastTimerPollUserMessageAt = now;
|
||||
showError(
|
||||
'Lost connection while syncing the active timer. Check the connection indicator; polling will retry.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}, 5000); // Poll every 5 seconds
|
||||
}
|
||||
@@ -1393,7 +1700,7 @@ async function loadSettings() {
|
||||
const syncIntervalInput = document.getElementById('sync-interval');
|
||||
|
||||
if (serverUrlInput) {
|
||||
serverUrlInput.value = serverUrl;
|
||||
serverUrlInput.value = serverUrl ? ApiClient.normalizeBaseUrl(String(serverUrl)) : '';
|
||||
}
|
||||
if (apiTokenInput) {
|
||||
// Only show if token exists, otherwise leave empty
|
||||
@@ -1425,16 +1732,17 @@ async function handleSaveSettings() {
|
||||
|
||||
if (!serverUrlInput || !apiTokenInput || !autoSyncInput || !syncIntervalInput) return;
|
||||
|
||||
const serverUrl = serverUrlInput.value.trim();
|
||||
const rawServer = serverUrlInput.value.trim();
|
||||
const normalizedInput = normalizeServerUrlInput(rawServer);
|
||||
const apiToken = apiTokenInput.value.trim();
|
||||
const autoSync = autoSyncInput.checked;
|
||||
const syncInterval = parseInt(syncIntervalInput.value, 10);
|
||||
|
||||
// Validate server URL
|
||||
if (!serverUrl || !isValidUrl(serverUrl)) {
|
||||
|
||||
if (!normalizedInput || !isValidUrl(normalizedInput)) {
|
||||
showSettingsMessage('Please enter a valid server URL', 'error');
|
||||
return;
|
||||
}
|
||||
const serverUrl = ApiClient.normalizeBaseUrl(normalizedInput);
|
||||
|
||||
// Check if API token was changed (if it's not the masked value)
|
||||
const hasExistingToken = apiTokenInput.dataset.hasToken === 'true';
|
||||
@@ -1452,34 +1760,28 @@ async function handleSaveSettings() {
|
||||
showSettingsMessage('Sync interval must be at least 10 seconds', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Save settings
|
||||
|
||||
try {
|
||||
await storeSet('server_url', serverUrl);
|
||||
await storeSet('api_token', finalApiToken);
|
||||
await storeSet('auto_sync', autoSync);
|
||||
await storeSet('sync_interval', syncInterval);
|
||||
|
||||
// Reinitialize API client with new settings
|
||||
state.apiClient = new ApiClient(serverUrl);
|
||||
await state.apiClient.setAuthToken(finalApiToken);
|
||||
|
||||
// Validate connection
|
||||
const isValid = await state.apiClient.validateToken();
|
||||
if (isValid) {
|
||||
await loadCurrentUserProfile();
|
||||
updateConnectionStatus('connected');
|
||||
showSettingsMessage('Settings saved successfully!', 'success');
|
||||
// Update token input to show masked value
|
||||
apiTokenInput.value = '••••••••';
|
||||
apiTokenInput.dataset.hasToken = 'true';
|
||||
} else {
|
||||
updateConnectionStatus('error');
|
||||
showSettingsMessage('Settings saved, but connection test failed. Please check your API token.', 'warning');
|
||||
const saved = await connectionManager.saveServerAndToken(serverUrl, finalApiToken, {
|
||||
auto_sync: autoSync,
|
||||
sync_interval: syncInterval,
|
||||
});
|
||||
if (!saved.ok) {
|
||||
showSettingsMessage(saved.message || saved.session?.message || 'Could not save settings.', 'error');
|
||||
updateConnectionFromManager();
|
||||
return;
|
||||
}
|
||||
state.authFailureStreak = 0;
|
||||
await loadCurrentUserProfile();
|
||||
updateConnectionFromManager();
|
||||
showSettingsMessage('Settings saved successfully!', 'success');
|
||||
apiTokenInput.value = '••••••••';
|
||||
apiTokenInput.dataset.hasToken = 'true';
|
||||
serverUrlInput.value = serverUrl;
|
||||
} catch (error) {
|
||||
console.error('Error saving settings:', error);
|
||||
showSettingsMessage('Error saving settings: ' + error.message, 'error');
|
||||
if (error && error.stack) console.error(error.stack);
|
||||
showSettingsMessage('Error saving settings: ' + (error.message || String(error)), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1490,43 +1792,46 @@ async function handleTestConnection() {
|
||||
|
||||
if (!serverUrlInput || !apiTokenInput) return;
|
||||
|
||||
const serverUrl = serverUrlInput.value.trim();
|
||||
const rawServer = serverUrlInput.value.trim();
|
||||
const normalizedInput = normalizeServerUrlInput(rawServer);
|
||||
let apiToken = apiTokenInput.value.trim();
|
||||
|
||||
// Validate server URL
|
||||
if (!serverUrl || !isValidUrl(serverUrl)) {
|
||||
|
||||
if (!normalizedInput || !isValidUrl(normalizedInput)) {
|
||||
showSettingsMessage('Please enter a valid server URL', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get actual token if masked
|
||||
const serverUrl = ApiClient.normalizeBaseUrl(normalizedInput);
|
||||
|
||||
const hasExistingToken = apiTokenInput.dataset.hasToken === 'true';
|
||||
if (hasExistingToken && apiToken === '••••••••') {
|
||||
apiToken = await storeGet('api_token');
|
||||
}
|
||||
|
||||
|
||||
if (!apiToken || !apiToken.startsWith('tt_')) {
|
||||
showSettingsMessage('Please enter a valid API token (must start with tt_)', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Test connection
|
||||
|
||||
try {
|
||||
showSettingsMessage('Testing connection...', 'info');
|
||||
const testClient = new ApiClient(serverUrl);
|
||||
await testClient.setAuthToken(apiToken);
|
||||
const isValid = await testClient.validateToken();
|
||||
|
||||
if (isValid) {
|
||||
updateConnectionStatus('connected');
|
||||
showSettingsMessage('Connection successful!', 'success');
|
||||
} else {
|
||||
updateConnectionStatus('error');
|
||||
showSettingsMessage('Connection failed. Please check your server URL and API token.', 'error');
|
||||
const r = await connectionManager.testServerAndSession(serverUrl, apiToken);
|
||||
if (!r.ok) {
|
||||
showSettingsMessage(r.message || 'Connection test failed.', 'error');
|
||||
updateConnectionFromManager();
|
||||
return;
|
||||
}
|
||||
const snap = connectionManager.getSnapshot();
|
||||
if (snap.serverUrl === serverUrl && connectionManager.getClient()) {
|
||||
await connectionManager.validateSessionRefresh();
|
||||
}
|
||||
updateConnectionFromManager();
|
||||
const ver = r.app_version ? ` (${r.app_version})` : '';
|
||||
showSettingsMessage(`Connection successful: server and API token are valid${ver}.`, 'success');
|
||||
} catch (error) {
|
||||
console.error('Error testing connection:', error);
|
||||
showSettingsMessage('Connection error: ' + error.message, 'error');
|
||||
if (error && error.stack) console.error(error.stack);
|
||||
const { message } = classifyAxiosError(error);
|
||||
showSettingsMessage(message || 'Connection error: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1547,13 +1852,29 @@ function showSettingsMessage(message, type = 'info') {
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
if (confirm('Are you sure you want to logout?')) {
|
||||
await storeClear();
|
||||
state.apiClient = null;
|
||||
if (!confirm('Sign out and remove the API token? Your server URL will be kept.')) return;
|
||||
if (state.isTimerRunning) {
|
||||
state.isTimerRunning = false;
|
||||
stopTimerPolling();
|
||||
showLoginScreen();
|
||||
}
|
||||
await connectionManager.logoutKeepServer();
|
||||
showLoginScreen({ prefillServerUrl: connectionManager.getSnapshot().serverUrl || '' });
|
||||
}
|
||||
|
||||
async function handleResetConfiguration() {
|
||||
if (
|
||||
!confirm(
|
||||
'Reset all app configuration (server URL, token, sync settings)? This cannot be undone.',
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (state.isTimerRunning) {
|
||||
state.isTimerRunning = false;
|
||||
stopTimerPolling();
|
||||
}
|
||||
await connectionManager.fullStoreReset();
|
||||
showLoginScreen({ prefillServerUrl: '' });
|
||||
}
|
||||
|
||||
// Initialize when DOM is ready
|
||||
@@ -1564,7 +1885,13 @@ if (document.readyState === 'loading') {
|
||||
}
|
||||
|
||||
// Use helper functions from helpers.js
|
||||
const { formatDuration, formatDurationLong, formatDateTime, isValidUrl } = window.Helpers || {};
|
||||
const {
|
||||
formatDuration,
|
||||
formatDurationLong,
|
||||
formatDateTime,
|
||||
isValidUrl,
|
||||
normalizeServerUrlInput,
|
||||
} = window.Helpers || {};
|
||||
|
||||
// Filter functions
|
||||
function toggleFilters() {
|
||||
@@ -1610,25 +1937,37 @@ async function loadProjectsForFilter() {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading projects for filter:', error);
|
||||
if (error && error.stack) console.error(error.stack);
|
||||
const { message } = classifyAxiosError(error);
|
||||
showError(message || 'Could not load projects for filter.');
|
||||
}
|
||||
}
|
||||
|
||||
// Time entry form
|
||||
async function showTimeEntryForm(entryId = null) {
|
||||
if (!state.apiClient) return;
|
||||
// Load projects and time entry requirements
|
||||
let projects = [];
|
||||
let requirements = { require_task: false, require_description: false, description_min_length: 20 };
|
||||
try {
|
||||
const [projectsResponse, usersMeResponse] = await Promise.all([
|
||||
state.apiClient.getProjects({ status: 'active' }),
|
||||
state.apiClient.getUsersMe().catch(() => ({})),
|
||||
]);
|
||||
const projectsResponse = await state.apiClient.getProjects({ status: 'active' });
|
||||
projects = projectsResponse.data.projects || [];
|
||||
if (usersMeResponse.time_entry_requirements) {
|
||||
requirements = usersMeResponse.time_entry_requirements;
|
||||
try {
|
||||
const usersMeResponse = await state.apiClient.getUsersMe();
|
||||
if (usersMeResponse && usersMeResponse.time_entry_requirements) {
|
||||
requirements = usersMeResponse.time_entry_requirements;
|
||||
}
|
||||
} catch (meErr) {
|
||||
console.error('getUsersMe for time entry form:', meErr);
|
||||
if (meErr && meErr.stack) console.error(meErr.stack);
|
||||
const { message } = classifyAxiosError(meErr);
|
||||
showError(message || 'Could not load time entry rules; using defaults.');
|
||||
}
|
||||
} catch (error) {
|
||||
showError('Failed to load projects');
|
||||
console.error('Failed to load projects for time entry form:', error);
|
||||
if (error && error.stack) console.error(error.stack);
|
||||
const { message } = classifyAxiosError(error);
|
||||
showError(message || 'Failed to load projects');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
+1649
-475
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,437 @@
|
||||
const ApiClient = require('../api/client');
|
||||
const { CONNECTION_STATE } = require('./connection_state');
|
||||
|
||||
const STORE_SERVER = 'server_url';
|
||||
const STORE_TOKEN = 'api_token';
|
||||
const STORE_TOKEN_SERVER = 'api_token_server_url';
|
||||
|
||||
/**
|
||||
* Single source of truth for server URL, API client lifecycle, and connection state.
|
||||
* @param {{
|
||||
* storeGet: (k: string) => Promise<unknown>,
|
||||
* storeSet: (k: string, v: unknown) => Promise<void>,
|
||||
* storeDelete: (k: string) => Promise<void>,
|
||||
* storeClear: () => Promise<void>,
|
||||
* onCacheClear?: () => void,
|
||||
* }} deps
|
||||
*/
|
||||
function createConnectionManager(deps) {
|
||||
const storeGet = deps.storeGet;
|
||||
const storeSet = deps.storeSet;
|
||||
const storeDelete = deps.storeDelete;
|
||||
const storeClear = deps.storeClear;
|
||||
const onCacheClear = deps.onCacheClear || (() => {});
|
||||
|
||||
/** @type {import('../api/client') | null} */
|
||||
let apiClient = null;
|
||||
|
||||
/** @type {Set<(s: ReturnType<typeof getSnapshot>) => void>} */
|
||||
const listeners = new Set();
|
||||
|
||||
let offlineListenerBound = false;
|
||||
|
||||
/** @type {{ state: string, serverUrl: string|null, lastError: string|null, lastConnectedAt: number|null, serverVersion: string|null }} */
|
||||
let snapshot = {
|
||||
state: CONNECTION_STATE.NOT_CONFIGURED,
|
||||
serverUrl: null,
|
||||
lastError: null,
|
||||
lastConnectedAt: null,
|
||||
serverVersion: null,
|
||||
};
|
||||
|
||||
function getSnapshot() {
|
||||
return { ...snapshot };
|
||||
}
|
||||
|
||||
function getClient() {
|
||||
return apiClient;
|
||||
}
|
||||
|
||||
function notify() {
|
||||
const s = getSnapshot();
|
||||
for (const fn of listeners) {
|
||||
try {
|
||||
fn(s);
|
||||
} catch (e) {
|
||||
console.error('ConnectionManager listener error:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setSnap(partial) {
|
||||
snapshot = { ...snapshot, ...partial };
|
||||
notify();
|
||||
}
|
||||
|
||||
function tearDownClient() {
|
||||
apiClient = null;
|
||||
}
|
||||
|
||||
function attachWindowListeners() {
|
||||
if (typeof window === 'undefined' || offlineListenerBound) return;
|
||||
offlineListenerBound = true;
|
||||
window.addEventListener('online', () => {
|
||||
if (snapshot.state === CONNECTION_STATE.OFFLINE && apiClient) {
|
||||
setSnap({ state: CONNECTION_STATE.CONNECTING, lastError: null });
|
||||
}
|
||||
});
|
||||
window.addEventListener('offline', () => {
|
||||
setSnap({
|
||||
state: CONNECTION_STATE.OFFLINE,
|
||||
lastError: 'Network offline.',
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} baseUrl
|
||||
* @returns {Promise<import('../api/client').ValidationResult & { app_version?: string|null }>}
|
||||
*/
|
||||
async function testServer(baseUrl) {
|
||||
const normalized = ApiClient.normalizeBaseUrl(String(baseUrl || '').trim());
|
||||
if (!normalized) {
|
||||
return { ok: false, code: 'NO_URL', message: 'Please enter a server URL.' };
|
||||
}
|
||||
return ApiClient.testPublicServerInfo(normalized);
|
||||
}
|
||||
|
||||
async function bootstrapFromStore() {
|
||||
attachWindowListeners();
|
||||
|
||||
const serverRaw = await storeGet(STORE_SERVER);
|
||||
const token = await storeGet(STORE_TOKEN);
|
||||
const serverUrlEarly = serverRaw ? ApiClient.normalizeBaseUrl(String(serverRaw)) : null;
|
||||
|
||||
if (typeof navigator !== 'undefined' && navigator.onLine === false) {
|
||||
tearDownClient();
|
||||
setSnap({
|
||||
state: CONNECTION_STATE.OFFLINE,
|
||||
serverUrl: serverUrlEarly,
|
||||
lastError: 'Network offline.',
|
||||
});
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'offline',
|
||||
hadCredentials: Boolean(serverUrlEarly && token),
|
||||
};
|
||||
}
|
||||
const tokenServer = await storeGet(STORE_TOKEN_SERVER);
|
||||
|
||||
let serverUrl = serverRaw ? ApiClient.normalizeBaseUrl(String(serverRaw)) : null;
|
||||
if (serverUrl && serverRaw && serverUrl !== String(serverRaw).trim()) {
|
||||
await storeSet(STORE_SERVER, serverUrl);
|
||||
}
|
||||
|
||||
if (!serverUrl) {
|
||||
tearDownClient();
|
||||
setSnap({
|
||||
state: CONNECTION_STATE.NOT_CONFIGURED,
|
||||
serverUrl: null,
|
||||
lastError: null,
|
||||
serverVersion: null,
|
||||
});
|
||||
return { ok: false, reason: 'no_server' };
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
tearDownClient();
|
||||
setSnap({
|
||||
state: CONNECTION_STATE.NOT_CONFIGURED,
|
||||
serverUrl,
|
||||
lastError: null,
|
||||
serverVersion: null,
|
||||
});
|
||||
return { ok: false, reason: 'no_token' };
|
||||
}
|
||||
|
||||
const tokenNorm = tokenServer ? ApiClient.normalizeBaseUrl(String(tokenServer)) : null;
|
||||
if (tokenNorm && tokenNorm !== serverUrl) {
|
||||
await storeDelete(STORE_TOKEN);
|
||||
await storeDelete(STORE_TOKEN_SERVER);
|
||||
tearDownClient();
|
||||
onCacheClear();
|
||||
setSnap({
|
||||
state: CONNECTION_STATE.NOT_CONFIGURED,
|
||||
serverUrl,
|
||||
lastError: 'This API token was saved for a different server. Please sign in again.',
|
||||
serverVersion: null,
|
||||
});
|
||||
return { ok: false, reason: 'token_server_mismatch' };
|
||||
}
|
||||
|
||||
apiClient = new ApiClient(serverUrl);
|
||||
await apiClient.setAuthToken(String(token));
|
||||
|
||||
setSnap({
|
||||
state: CONNECTION_STATE.CONNECTING,
|
||||
serverUrl,
|
||||
lastError: null,
|
||||
});
|
||||
|
||||
const session = await apiClient.validateSession();
|
||||
if (session.ok) {
|
||||
if (!tokenNorm) {
|
||||
await storeSet(STORE_TOKEN_SERVER, serverUrl);
|
||||
}
|
||||
const now = Date.now();
|
||||
setSnap({
|
||||
state: CONNECTION_STATE.CONNECTED,
|
||||
serverUrl,
|
||||
lastError: null,
|
||||
lastConnectedAt: now,
|
||||
serverVersion: null,
|
||||
});
|
||||
return { ok: true, session };
|
||||
}
|
||||
|
||||
tearDownClient();
|
||||
await storeDelete(STORE_TOKEN);
|
||||
await storeDelete(STORE_TOKEN_SERVER);
|
||||
onCacheClear();
|
||||
setSnap({
|
||||
state: CONNECTION_STATE.ERROR,
|
||||
serverUrl,
|
||||
lastError: session.message || 'Session invalid',
|
||||
serverVersion: null,
|
||||
});
|
||||
return { ok: false, reason: 'session', session };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate server + token, then persist. No partial token writes on auth failure.
|
||||
* @param {string} serverUrl
|
||||
* @param {string} token
|
||||
*/
|
||||
async function login(serverUrl, token) {
|
||||
const normalized = ApiClient.normalizeBaseUrl(String(serverUrl || '').trim());
|
||||
const pub = await ApiClient.testPublicServerInfo(normalized);
|
||||
if (!pub.ok) {
|
||||
setSnap({
|
||||
state: CONNECTION_STATE.ERROR,
|
||||
serverUrl: normalized,
|
||||
lastError: pub.message,
|
||||
});
|
||||
return { ok: false, step: 'server', ...pub };
|
||||
}
|
||||
|
||||
const probe = new ApiClient(normalized);
|
||||
await probe.setAuthToken(token);
|
||||
const session = await probe.validateSession();
|
||||
if (!session.ok) {
|
||||
setSnap({
|
||||
state: CONNECTION_STATE.ERROR,
|
||||
serverUrl: normalized,
|
||||
lastError: session.message || 'Login failed',
|
||||
serverVersion: null,
|
||||
});
|
||||
return { ok: false, step: 'auth', session };
|
||||
}
|
||||
|
||||
await storeSet(STORE_SERVER, normalized);
|
||||
await storeSet(STORE_TOKEN, token);
|
||||
await storeSet(STORE_TOKEN_SERVER, normalized);
|
||||
|
||||
apiClient = probe;
|
||||
const now = Date.now();
|
||||
setSnap({
|
||||
state: CONNECTION_STATE.CONNECTED,
|
||||
serverUrl: normalized,
|
||||
lastError: null,
|
||||
lastConnectedAt: now,
|
||||
serverVersion: pub.app_version || null,
|
||||
});
|
||||
return { ok: true, session, app_version: pub.app_version || null };
|
||||
}
|
||||
|
||||
async function logoutKeepServer() {
|
||||
await storeDelete(STORE_TOKEN);
|
||||
await storeDelete(STORE_TOKEN_SERVER);
|
||||
tearDownClient();
|
||||
onCacheClear();
|
||||
const serverRaw = await storeGet(STORE_SERVER);
|
||||
const serverUrl = serverRaw ? ApiClient.normalizeBaseUrl(String(serverRaw)) : null;
|
||||
setSnap({
|
||||
state: CONNECTION_STATE.NOT_CONFIGURED,
|
||||
serverUrl,
|
||||
lastError: null,
|
||||
serverVersion: null,
|
||||
});
|
||||
}
|
||||
|
||||
async function fullStoreReset() {
|
||||
await storeClear();
|
||||
tearDownClient();
|
||||
onCacheClear();
|
||||
snapshot = {
|
||||
state: CONNECTION_STATE.NOT_CONFIGURED,
|
||||
serverUrl: null,
|
||||
lastError: null,
|
||||
lastConnectedAt: null,
|
||||
serverVersion: null,
|
||||
};
|
||||
notify();
|
||||
}
|
||||
|
||||
/** @returns {Promise<import('../api/client').ValidationResult>} */
|
||||
async function validateSessionRefresh() {
|
||||
if (typeof navigator !== 'undefined' && navigator.onLine === false) {
|
||||
setSnap({
|
||||
state: CONNECTION_STATE.OFFLINE,
|
||||
lastError: 'Network offline.',
|
||||
});
|
||||
return { ok: false, code: 'OFFLINE', message: 'Network offline.' };
|
||||
}
|
||||
|
||||
if (!apiClient) {
|
||||
setSnap({
|
||||
state: CONNECTION_STATE.NOT_CONFIGURED,
|
||||
lastError: null,
|
||||
});
|
||||
return { ok: false, code: 'NO_CLIENT', message: 'Not connected.' };
|
||||
}
|
||||
|
||||
const session = await apiClient.validateSession();
|
||||
if (session.ok) {
|
||||
const now = Date.now();
|
||||
setSnap({
|
||||
state: CONNECTION_STATE.CONNECTED,
|
||||
lastError: null,
|
||||
lastConnectedAt: now,
|
||||
});
|
||||
return session;
|
||||
}
|
||||
|
||||
if (session.code === 'UNAUTHORIZED') {
|
||||
setSnap({
|
||||
state: CONNECTION_STATE.ERROR,
|
||||
lastError: session.message || 'Unauthorized',
|
||||
});
|
||||
return session;
|
||||
}
|
||||
|
||||
const transportish =
|
||||
session.code === 'TIMEOUT' ||
|
||||
session.code === 'REFUSED' ||
|
||||
session.code === 'UNREACHABLE' ||
|
||||
session.code === 'DNS' ||
|
||||
session.code === 'TLS' ||
|
||||
session.code === 'UNKNOWN';
|
||||
|
||||
if (transportish) {
|
||||
setSnap({
|
||||
state: CONNECTION_STATE.ERROR,
|
||||
lastError: session.message || 'Server not reachable',
|
||||
});
|
||||
return session;
|
||||
}
|
||||
|
||||
setSnap({
|
||||
state: CONNECTION_STATE.ERROR,
|
||||
lastError: session.message || 'Connection error',
|
||||
});
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate server + token, then persist (including optional sync prefs). No partial writes on failure.
|
||||
* @param {string} serverUrl
|
||||
* @param {string} token
|
||||
* @param {{ auto_sync?: boolean, sync_interval?: number }|null} syncExtras
|
||||
*/
|
||||
async function saveServerAndToken(serverUrl, token, syncExtras) {
|
||||
const normalized = ApiClient.normalizeBaseUrl(String(serverUrl || '').trim());
|
||||
const pub = await ApiClient.testPublicServerInfo(normalized);
|
||||
if (!pub.ok) {
|
||||
setSnap({
|
||||
state: CONNECTION_STATE.ERROR,
|
||||
lastError: pub.message,
|
||||
});
|
||||
return { ok: false, step: 'server', ...pub };
|
||||
}
|
||||
|
||||
const probe = new ApiClient(normalized);
|
||||
await probe.setAuthToken(token);
|
||||
const session = await probe.validateSession();
|
||||
if (!session.ok) {
|
||||
setSnap({
|
||||
state: CONNECTION_STATE.ERROR,
|
||||
lastError: session.message || 'Session check failed. Settings were not saved.',
|
||||
});
|
||||
return { ok: false, step: 'auth', session };
|
||||
}
|
||||
|
||||
if (syncExtras) {
|
||||
if (syncExtras.auto_sync !== undefined) await storeSet('auto_sync', syncExtras.auto_sync);
|
||||
if (syncExtras.sync_interval !== undefined) await storeSet('sync_interval', syncExtras.sync_interval);
|
||||
}
|
||||
|
||||
await storeSet(STORE_SERVER, normalized);
|
||||
await storeSet(STORE_TOKEN, token);
|
||||
await storeSet(STORE_TOKEN_SERVER, normalized);
|
||||
|
||||
apiClient = probe;
|
||||
const now = Date.now();
|
||||
setSnap({
|
||||
state: CONNECTION_STATE.CONNECTED,
|
||||
serverUrl: normalized,
|
||||
lastError: null,
|
||||
lastConnectedAt: now,
|
||||
serverVersion: pub.app_version || null,
|
||||
});
|
||||
return { ok: true, session };
|
||||
}
|
||||
|
||||
/**
|
||||
* Test server + token without persisting.
|
||||
*/
|
||||
async function testServerAndSession(serverUrl, token) {
|
||||
const normalized = ApiClient.normalizeBaseUrl(String(serverUrl || '').trim());
|
||||
const pub = await ApiClient.testPublicServerInfo(normalized);
|
||||
if (!pub.ok) return pub;
|
||||
const probe = new ApiClient(normalized);
|
||||
await probe.setAuthToken(token);
|
||||
const session = await probe.validateSession();
|
||||
if (!session.ok) return session;
|
||||
return { ok: true, app_version: pub.app_version || null };
|
||||
}
|
||||
|
||||
function subscribe(fn) {
|
||||
listeners.add(fn);
|
||||
try {
|
||||
fn(getSnapshot());
|
||||
} catch (e) {
|
||||
console.error('ConnectionManager subscribe initial error:', e);
|
||||
}
|
||||
return () => listeners.delete(fn);
|
||||
}
|
||||
|
||||
/** Mark connection error while keeping client (e.g. timer poll failed). */
|
||||
function signalError(message) {
|
||||
if (!apiClient) return;
|
||||
setSnap({
|
||||
state: CONNECTION_STATE.ERROR,
|
||||
lastError: message || 'Connection error',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
CONNECTION_STATE,
|
||||
getSnapshot,
|
||||
getClient,
|
||||
subscribe,
|
||||
testServer,
|
||||
testServerAndSession,
|
||||
bootstrapFromStore,
|
||||
login,
|
||||
logoutKeepServer,
|
||||
fullStoreReset,
|
||||
validateSessionRefresh,
|
||||
saveServerAndToken,
|
||||
tearDownClient,
|
||||
signalError,
|
||||
/** Expose for tests */
|
||||
_setSnapForTest: setSnap,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createConnectionManager, STORE_SERVER, STORE_TOKEN, STORE_TOKEN_SERVER };
|
||||
@@ -0,0 +1,10 @@
|
||||
/** Global connection lifecycle for the desktop renderer. */
|
||||
const CONNECTION_STATE = {
|
||||
NOT_CONFIGURED: 'NOT_CONFIGURED',
|
||||
CONNECTING: 'CONNECTING',
|
||||
CONNECTED: 'CONNECTED',
|
||||
ERROR: 'ERROR',
|
||||
OFFLINE: 'OFFLINE',
|
||||
};
|
||||
|
||||
module.exports = { CONNECTION_STATE };
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Bounded retries with jitter for idempotent requests only (GET/HEAD).
|
||||
* Prevents duplicate writes (e.g. timer start) from automatic retry loops.
|
||||
*/
|
||||
|
||||
const SAFE_METHODS = new Set(['get', 'head']);
|
||||
|
||||
function isRetryableTransportOrServer(error) {
|
||||
if (!error || !error.config) return false;
|
||||
if (error.code === 'ECONNABORTED') return true;
|
||||
if (error.code === 'ECONNRESET' || error.code === 'ETIMEDOUT') return true;
|
||||
if (!error.response) return true;
|
||||
const s = error.response.status;
|
||||
return s === 502 || s === 503 || s === 504;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('axios').AxiosInstance} axiosInstance
|
||||
* @param {{ maxRetries?: number, baseDelayMs?: number }} [options]
|
||||
*/
|
||||
function attachIdempotentRetryInterceptors(axiosInstance, options = {}) {
|
||||
const maxRetries = options.maxRetries ?? 3;
|
||||
const baseDelayMs = options.baseDelayMs ?? 400;
|
||||
|
||||
axiosInstance.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
const config = error.config;
|
||||
if (!config) return Promise.reject(error);
|
||||
|
||||
const method = String(config.method || 'get').toLowerCase();
|
||||
if (!SAFE_METHODS.has(method)) return Promise.reject(error);
|
||||
|
||||
const count = config.__retryCount || 0;
|
||||
if (count >= maxRetries) return Promise.reject(error);
|
||||
if (!isRetryableTransportOrServer(error)) return Promise.reject(error);
|
||||
|
||||
config.__retryCount = count + 1;
|
||||
const backoff = baseDelayMs * 2 ** (config.__retryCount - 1);
|
||||
const jitter = Math.random() * 250;
|
||||
await new Promise((r) => setTimeout(r, backoff + jitter));
|
||||
return axiosInstance(config);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = { attachIdempotentRetryInterceptors, isRetryableTransportOrServer, SAFE_METHODS };
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Single-flight timer mutations + reconcile after ambiguous failures
|
||||
* (response lost but server may have applied the action).
|
||||
*/
|
||||
|
||||
let _startFlight = null;
|
||||
let _stopFlight = null;
|
||||
|
||||
/**
|
||||
* @param {import('../api/client')} apiClient
|
||||
* @param {{ projectId: number, taskId: number|null, notes: string|null }} payload
|
||||
*/
|
||||
async function startTimerWithReconcile(apiClient, payload) {
|
||||
if (_startFlight) return _startFlight;
|
||||
|
||||
_startFlight = (async () => {
|
||||
try {
|
||||
return await apiClient.startTimer(payload);
|
||||
} catch (err) {
|
||||
const ambiguous =
|
||||
!err.response ||
|
||||
err.code === 'ECONNABORTED' ||
|
||||
err.code === 'ECONNRESET' ||
|
||||
err.code === 'ETIMEDOUT';
|
||||
if (!ambiguous) throw err;
|
||||
try {
|
||||
const status = await apiClient.getTimerStatus();
|
||||
if (status.data && status.data.active && status.data.timer) {
|
||||
return { data: { message: 'Timer already running', timer: status.data.timer }, _reconciled: true };
|
||||
}
|
||||
} catch (reconcileErr) {
|
||||
console.error('startTimer reconcile failed:', reconcileErr);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
})();
|
||||
|
||||
try {
|
||||
return await _startFlight;
|
||||
} finally {
|
||||
_startFlight = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {import('../api/client')} apiClient */
|
||||
async function stopTimerWithReconcile(apiClient) {
|
||||
if (_stopFlight) return _stopFlight;
|
||||
|
||||
_stopFlight = (async () => {
|
||||
try {
|
||||
return await apiClient.stopTimer();
|
||||
} catch (err) {
|
||||
const statusCode = err.response && err.response.status;
|
||||
if (statusCode === 400 && err.response.data && err.response.data.error_code === 'no_active_timer') {
|
||||
return { data: err.response.data, _reconciled: true };
|
||||
}
|
||||
const ambiguous =
|
||||
!err.response ||
|
||||
err.code === 'ECONNABORTED' ||
|
||||
err.code === 'ECONNRESET' ||
|
||||
err.code === 'ETIMEDOUT';
|
||||
if (!ambiguous) throw err;
|
||||
try {
|
||||
const status = await apiClient.getTimerStatus();
|
||||
if (status.data && !status.data.active) {
|
||||
return { data: { message: 'Timer already stopped' }, _reconciled: true };
|
||||
}
|
||||
} catch (reconcileErr) {
|
||||
console.error('stopTimer reconcile failed:', reconcileErr);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
})();
|
||||
|
||||
try {
|
||||
return await _stopFlight;
|
||||
} finally {
|
||||
_stopFlight = null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { startTimerWithReconcile, stopTimerWithReconcile };
|
||||
@@ -1,9 +1,14 @@
|
||||
/**
|
||||
* Application state - single source of truth for view, timer, cache, and filters.
|
||||
* Used by app.js to avoid scattered globals.
|
||||
* API client lifecycle is owned by ConnectionManager; this field is synced for legacy call sites.
|
||||
*/
|
||||
module.exports = {
|
||||
const state = {
|
||||
apiClient: null,
|
||||
/** Count consecutive background checks that failed with auth (401) while on main UI */
|
||||
authFailureStreak: 0,
|
||||
/** Last timer poll error shown to user (avoid spam) */
|
||||
lastTimerPollUserMessageAt: 0,
|
||||
currentView: 'dashboard',
|
||||
timerInterval: null,
|
||||
isTimerRunning: false,
|
||||
@@ -19,3 +24,12 @@ module.exports = {
|
||||
expenses: { page: 1, perPage: 20, totalPages: 1, total: 0 },
|
||||
},
|
||||
};
|
||||
|
||||
function clearViewCaches() {
|
||||
state.cachedInvoices = [];
|
||||
state.cachedExpenses = [];
|
||||
state.cachedWorkforce = { periods: [], capacity: [], timeOffRequests: [], balances: [] };
|
||||
}
|
||||
|
||||
state.clearViewCaches = clearViewCaches;
|
||||
module.exports = state;
|
||||
|
||||
@@ -46,6 +46,14 @@ function isValidUrl(string) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Add https:// when user entered host:port or hostname only */
|
||||
function normalizeServerUrlInput(input) {
|
||||
const trimmed = String(input || '').trim();
|
||||
if (!trimmed) return trimmed;
|
||||
if (/^https?:\/\//i.test(trimmed)) return trimmed;
|
||||
return 'https://' + trimmed;
|
||||
}
|
||||
|
||||
function debounce(func, wait) {
|
||||
let timeout;
|
||||
return function executedFunction(...args) {
|
||||
@@ -67,6 +75,7 @@ if (typeof module !== 'undefined' && module.exports) {
|
||||
formatDateTime,
|
||||
parseISODate,
|
||||
isValidUrl,
|
||||
normalizeServerUrlInput,
|
||||
debounce,
|
||||
};
|
||||
}
|
||||
@@ -79,6 +88,7 @@ if (typeof window !== 'undefined') {
|
||||
formatDateTime,
|
||||
parseISODate,
|
||||
isValidUrl,
|
||||
normalizeServerUrlInput,
|
||||
debounce,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,15 +1,61 @@
|
||||
// Basic API client tests
|
||||
// Note: These would require mocking axios and electron APIs
|
||||
// For now, this is a placeholder structure
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
|
||||
describe('ApiClient', () => {
|
||||
test('should initialize with base URL', () => {
|
||||
// Test implementation would go here
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
const ApiClient = require('../src/renderer/js/api/client');
|
||||
|
||||
test('should handle token authentication', () => {
|
||||
// Test implementation would go here
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
test('normalizeBaseUrl trims trailing slashes', () => {
|
||||
assert.strictEqual(ApiClient.normalizeBaseUrl('https://example.com/'), 'https://example.com');
|
||||
assert.strictEqual(ApiClient.normalizeBaseUrl('http://10.0.0.1:5000///'), 'http://10.0.0.1:5000');
|
||||
});
|
||||
|
||||
test('normalizeBaseUrl leaves empty string', () => {
|
||||
assert.strictEqual(ApiClient.normalizeBaseUrl(''), '');
|
||||
assert.strictEqual(ApiClient.normalizeBaseUrl(' '), '');
|
||||
});
|
||||
|
||||
test('isTimeTrackerInfoPayload accepts v1 info shape', () => {
|
||||
const ok = {
|
||||
api_version: 'v1',
|
||||
app_version: '1.0.0',
|
||||
endpoints: { projects: '/api/v1/projects' },
|
||||
};
|
||||
assert.strictEqual(ApiClient.isTimeTrackerInfoPayload(ok), true);
|
||||
});
|
||||
|
||||
test('isTimeTrackerInfoPayload rejects wrong api_version', () => {
|
||||
assert.strictEqual(
|
||||
ApiClient.isTimeTrackerInfoPayload({ api_version: 'v2', endpoints: {} }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('isTimeTrackerInfoPayload rejects missing endpoints object', () => {
|
||||
assert.strictEqual(ApiClient.isTimeTrackerInfoPayload({ api_version: 'v1' }), false);
|
||||
});
|
||||
|
||||
test('classifyAxiosError maps 401', () => {
|
||||
const err = { response: { status: 401, data: {} } };
|
||||
const r = ApiClient.classifyAxiosError(err);
|
||||
assert.strictEqual(r.code, 'UNAUTHORIZED');
|
||||
assert.ok(r.message.includes('token'));
|
||||
});
|
||||
|
||||
test('classifyAxiosError maps TLS-ish code', () => {
|
||||
const err = { code: 'DEPTH_ZERO_SELF_SIGNED_CERT', message: 'self signed certificate' };
|
||||
const r = ApiClient.classifyAxiosError(err);
|
||||
assert.strictEqual(r.code, 'TLS');
|
||||
assert.ok(r.message.toLowerCase().includes('cert'));
|
||||
});
|
||||
|
||||
test('classifyAxiosError maps ENOTFOUND', () => {
|
||||
const err = { code: 'ENOTFOUND', message: 'getaddrinfo' };
|
||||
const r = ApiClient.classifyAxiosError(err);
|
||||
assert.strictEqual(r.code, 'DNS');
|
||||
});
|
||||
|
||||
test('classifyAxiosError maps unknown transport without response', () => {
|
||||
const err = { message: 'Network Error' };
|
||||
const r = ApiClient.classifyAxiosError(err);
|
||||
assert.strictEqual(r.code, 'UNKNOWN');
|
||||
assert.ok(r.message.toLowerCase().includes('reachable'));
|
||||
});
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const { createConnectionManager } = require('../src/renderer/js/connection/connection_manager');
|
||||
|
||||
function memoryStore() {
|
||||
/** @type {Record<string, unknown>} */
|
||||
const data = {};
|
||||
return {
|
||||
storeGet: async (k) => (Object.prototype.hasOwnProperty.call(data, k) ? data[k] : null),
|
||||
storeSet: async (k, v) => {
|
||||
data[k] = v;
|
||||
},
|
||||
storeDelete: async (k) => {
|
||||
delete data[k];
|
||||
},
|
||||
storeClear: async () => {
|
||||
for (const k of Object.keys(data)) delete data[k];
|
||||
},
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
test('logoutKeepServer removes token keys but keeps server_url', async () => {
|
||||
const { storeGet, storeSet, storeDelete, storeClear, data } = memoryStore();
|
||||
await storeSet('server_url', 'https://example.com');
|
||||
await storeSet('api_token', 'tt_test');
|
||||
await storeSet('api_token_server_url', 'https://example.com');
|
||||
|
||||
const mgr = createConnectionManager({
|
||||
storeGet,
|
||||
storeSet,
|
||||
storeDelete,
|
||||
storeClear,
|
||||
onCacheClear: () => {},
|
||||
});
|
||||
|
||||
await mgr.logoutKeepServer();
|
||||
|
||||
assert.strictEqual(data.server_url, 'https://example.com');
|
||||
assert.strictEqual(data.api_token, undefined);
|
||||
assert.strictEqual(data.api_token_server_url, undefined);
|
||||
const snap = mgr.getSnapshot();
|
||||
assert.strictEqual(snap.state, mgr.CONNECTION_STATE.NOT_CONFIGURED);
|
||||
assert.strictEqual(snap.serverUrl, 'https://example.com');
|
||||
});
|
||||
|
||||
test('fullStoreReset clears store and snapshot', async () => {
|
||||
const { storeGet, storeSet, storeDelete, storeClear, data } = memoryStore();
|
||||
await storeSet('server_url', 'https://a.com');
|
||||
await storeSet('api_token', 'tt_x');
|
||||
|
||||
const cleared = [];
|
||||
const mgr = createConnectionManager({
|
||||
storeGet,
|
||||
storeSet,
|
||||
storeDelete,
|
||||
storeClear,
|
||||
onCacheClear: () => cleared.push(1),
|
||||
});
|
||||
|
||||
await mgr.fullStoreReset();
|
||||
|
||||
assert.strictEqual(Object.keys(data).length, 0);
|
||||
assert.strictEqual(cleared.length, 1);
|
||||
assert.strictEqual(mgr.getSnapshot().serverUrl, null);
|
||||
});
|
||||
|
||||
test('subscribe is called with initial snapshot', async () => {
|
||||
const { storeGet, storeSet, storeDelete, storeClear } = memoryStore();
|
||||
const calls = [];
|
||||
const mgr = createConnectionManager({
|
||||
storeGet,
|
||||
storeSet,
|
||||
storeDelete,
|
||||
storeClear,
|
||||
onCacheClear: () => {},
|
||||
});
|
||||
mgr.subscribe((s) => calls.push(s.state));
|
||||
assert.ok(calls.length >= 1);
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const http = require('http');
|
||||
const ApiClient = require('../src/renderer/js/api/client');
|
||||
|
||||
test('GET /api/v1/info against local mock matches TimeTracker payload', async () => {
|
||||
const infoBody = {
|
||||
api_version: 'v1',
|
||||
app_version: '9.9.9-test',
|
||||
endpoints: { projects: '/api/v1/projects' },
|
||||
setup_required: false,
|
||||
};
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.url === '/api/v1/info' && req.method === 'GET') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(infoBody));
|
||||
return;
|
||||
}
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
});
|
||||
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const addr = /** @type {import('net').AddressInfo} */ (server.address());
|
||||
const baseUrl = `http://127.0.0.1:${addr.port}`;
|
||||
|
||||
try {
|
||||
const r = await ApiClient.testPublicServerInfo(baseUrl);
|
||||
assert.strictEqual(r.ok, true);
|
||||
assert.strictEqual(r.app_version, '9.9.9-test');
|
||||
} finally {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const {
|
||||
startTimerWithReconcile,
|
||||
stopTimerWithReconcile,
|
||||
} = require('../src/renderer/js/connection/timer_operations');
|
||||
|
||||
test('startTimerWithReconcile reconciles when start times out but timer is active', async () => {
|
||||
const timer = {
|
||||
id: 1,
|
||||
start_time: new Date().toISOString(),
|
||||
project: { name: 'Proj' },
|
||||
};
|
||||
const api = {
|
||||
async startTimer() {
|
||||
const e = new Error('aborted');
|
||||
e.code = 'ECONNABORTED';
|
||||
throw e;
|
||||
},
|
||||
async getTimerStatus() {
|
||||
return { data: { active: true, timer } };
|
||||
},
|
||||
};
|
||||
|
||||
const r = await startTimerWithReconcile(api, { projectId: 1, taskId: null, notes: null });
|
||||
assert.strictEqual(r._reconciled, true);
|
||||
assert.strictEqual(r.data.timer.id, 1);
|
||||
});
|
||||
|
||||
test('stopTimerWithReconcile treats no_active_timer as reconciled', async () => {
|
||||
const api = {
|
||||
async stopTimer() {
|
||||
const e = new Error('bad');
|
||||
e.response = { status: 400, data: { error_code: 'no_active_timer' } };
|
||||
throw e;
|
||||
},
|
||||
};
|
||||
const r = await stopTimerWithReconcile(api);
|
||||
assert.strictEqual(r._reconciled, true);
|
||||
});
|
||||
|
||||
test('stopTimerWithReconcile reconciles when stop ambiguous and timer already stopped', async () => {
|
||||
const api = {
|
||||
async stopTimer() {
|
||||
const e = new Error('timeout');
|
||||
e.code = 'ECONNABORTED';
|
||||
throw e;
|
||||
},
|
||||
async getTimerStatus() {
|
||||
return { data: { active: false } };
|
||||
},
|
||||
};
|
||||
const r = await stopTimerWithReconcile(api);
|
||||
assert.strictEqual(r._reconciled, true);
|
||||
});
|
||||
+3
-1
@@ -46,8 +46,10 @@ curl -H "X-API-Key: YOUR_API_TOKEN" \
|
||||
| **Contacts** | `/api/v1/clients/<id>/contacts` | Client contacts |
|
||||
| **Search** | `/api/v1/search` | Global search across projects, tasks, clients |
|
||||
| **Time approvals** | `/api/v1/time-entry-approvals` | Approve, reject, request approval for time entries |
|
||||
| **Admin version check** | `/api/version/check`, `/api/version/dismiss` | Compare install to latest GitHub release; dismiss per version (admin only; session or API token; not under `/api/v1`) |
|
||||
| **Dashboard (session)** | `/api/stats/value-dashboard`, `/api/dashboard/stats`, … | JSON used by the logged-in web UI (session cookie); see [REST API reference](api/REST_API.md) for `value-dashboard` fields and caching |
|
||||
|
||||
Access is controlled by **scopes** (e.g. `read:projects`, `write:time_entries`). Create a token with the scopes you need; see [API Token Scopes](api/API_TOKEN_SCOPES.md).
|
||||
Access is controlled by **scopes** (e.g. `read:projects`, `write:time_entries`) on **`/api/v1`** routes. Create a token with the scopes you need; see [API Token Scopes](api/API_TOKEN_SCOPES.md). The admin version endpoints do not require a specific scope but require an **administrator** user. Legacy **`/api/...`** dashboard JSON routes require a normal **logged-in session**, not API-token scopes.
|
||||
|
||||
## Quick Examples
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ flowchart LR
|
||||
| Layer | Location | Role |
|
||||
|-------|----------|------|
|
||||
| Entry point | `app.py` | Creates Flask app, loads config, registers blueprints via `blueprint_registry`, starts server (and optional SocketIO/scheduler). |
|
||||
| Blueprint registry | `app/blueprint_registry.py` | Single place that imports and registers all route blueprints so `app/__init__.py` stays manageable. |
|
||||
| Blueprint registry | `app/blueprint_registry.py` | Single place that imports and registers all route blueprints so `app/__init__.py` stays manageable. Optional blueprints and the optional `audit_logs` module log failures at **ERROR** with a full traceback (`logger.exception`); in **`FLASK_ENV=development`** or **`DEBUG`**, registration failures **re-raise** so misconfiguration fails fast. In **production** and **testing**, optional blueprint import failures are logged and skipped so the app still starts. |
|
||||
| Routes | `app/routes/` | HTTP handlers: auth, main (dashboard), projects, timer, reports, admin, api, api_v1 (plus api_v1_* sub-blueprints), tasks, issues, invoices, clients, etc. |
|
||||
| Services | `app/services/` | Business logic; routes call services instead of putting logic in view code. |
|
||||
| Repositories | `app/repositories/` | Data access layer; services and routes use repositories for queries and eager loading. |
|
||||
@@ -71,8 +71,8 @@ API endpoints are versioned under `/api/v1/`. Authentication is session-based fo
|
||||
|
||||
## API Structure
|
||||
|
||||
- **Base URL:** `/api/v1/`
|
||||
- **Auth:** API token in header `Authorization: Bearer <token>` or `X-API-Key: <token>`. Tokens are created in Admin → Api-tokens and have scopes (e.g. `read:projects`, `write:time_entries`).
|
||||
- **Integrations (primary):** **`/api/v1/`** — Versioned REST API for desktop, mobile, and automation. **API token** auth (`Authorization: Bearer <token>` or `X-API-Key: <token>`). Tokens are created in Admin → Api-tokens and have scopes (e.g. `read:projects`, `write:time_entries`). Documented in OpenAPI at `/api/docs` (spec: `/api/openapi.json`).
|
||||
- **Web UI JSON (session):** **`/api/*`** (see [`app/routes/api.py`](../app/routes/api.py)) — Same-origin JSON used by the logged-in browser (Flask-Login session cookie): command-palette search, timer helpers, notifications, dashboard fragments, calendar helpers, uploads, and similar. **Not** the integration contract; paths may evolve with the UI. Where a v1 equivalent exists, responses may include **`X-API-Deprecated: true`** and a **`Link: <.../api/v1/...>; rel="successor-version"`** header.
|
||||
- **Sub-blueprints (all under `/api/v1/`):** `api_v1` (info, health, auth/login), `api_v1_time_entries`, `api_v1_projects`, `api_v1_tasks`, `api_v1_clients`, `api_v1_invoices`, `api_v1_expenses`, `api_v1_payments`, `api_v1_mileage`, `api_v1_deals`, `api_v1_leads`, `api_v1_contacts`, plus remaining routes in `api_v1` (time-entry-approvals, per-diems, budget-alerts, calendar, kanban, saved-filters, etc.).
|
||||
- **Full reference:** [REST API](api/REST_API.md).
|
||||
|
||||
@@ -88,7 +88,7 @@ API endpoints are versioned under `/api/v1/`. Authentication is session-based fo
|
||||
- **Service layer:** Business logic lives in `app/services/` so routes stay thin and logic is reusable and testable. See [Service Layer and Base CRUD](development/SERVICE_LAYER_AND_BASE_CRUD.md) and the [Architecture Migration Guide](implementation-notes/ARCHITECTURE_MIGRATION_GUIDE.md).
|
||||
- **API v1 split:** Core resources (projects, tasks, clients, invoices, expenses, payments, mileage, deals, leads, contacts) are in separate sub-blueprints (`api_v1_*.py`) under `/api/v1/` for maintainability; the main `api_v1` module keeps info, health, auth, and remaining endpoints.
|
||||
- **Bootstrap:** Logging is configured in `app/utils/setup_logging.py`; legacy migration helpers (task management, issues tables) are in `app/utils/legacy_migrations.py`. `app/__init__.py` creates the app and wires extensions.
|
||||
- **Blueprint registry:** All blueprints are registered from `app/blueprint_registry.py` to keep registration in one place and simplify adding new modules.
|
||||
- **Blueprint registry:** All blueprints are registered from `app/blueprint_registry.py` to keep registration in one place and simplify adding new modules. Optional modules log registration errors with tracebacks; development mode re-raises to surface broken optional routes early.
|
||||
- **Database:** **PostgreSQL** is recommended for production; **SQLite** is supported for development and testing (e.g. `docker/docker-compose.local-test.yml`).
|
||||
- **API auth:** The REST API uses API tokens (created in Admin → Api-tokens) with scopes; no session cookies for API access.
|
||||
- **Single codebase for web UI:** No separate frontend repo; templates and static assets live in the main repo under `app/templates/` and `app/static/`.
|
||||
|
||||
@@ -80,7 +80,7 @@ The mobile app supports server URL configuration through:
|
||||
3. **Runtime Configuration:**
|
||||
- Users can configure the server URL in the app settings
|
||||
- Stored in SharedPreferences
|
||||
- Configuration class: `mobile/lib/core/config.dart`
|
||||
- Configuration class: `mobile/lib/core/config/app_config.dart`
|
||||
|
||||
## Icon and Favicon Configuration
|
||||
|
||||
|
||||
@@ -32,11 +32,13 @@ Use when contributors cannot or will not use GitHub:
|
||||
|
||||
Examples: [Weblate](https://weblate.org/) (open source, can be self-hosted), [Crowdin](https://crowdin.com/), [POEditor](https://poeditor.com/), [Transifex](https://www.transifex.com/). Translators work in the browser; integration or export/import keeps `.po` in sync with the codebase. Setup is maintainer-owned.
|
||||
|
||||
**TimeTracker on Crowdin:** [https://crowdin.com/project/drytrix-timetracker](https://crowdin.com/project/drytrix-timetracker)
|
||||
|
||||
#### Crowdin setup (maintainers)
|
||||
|
||||
This repo includes a root [`crowdin.yml`](../crowdin.yml) that maps **source** `translations/en/LC_MESSAGES/messages.po` to **translations** under `translations/<locale>/LC_MESSAGES/messages.po`, with **`nb` → `no`** so Norwegian matches `app/config.py` (`no`, not `nb`). You may still have a legacy `translations/nb/` tree locally; prefer **`no`** in Crowdin and in config so you do not maintain two Norwegian copies.
|
||||
|
||||
1. **Create a Crowdin account and project** at [crowdin.com](https://crowdin.com/) → **Create project**.
|
||||
1. **Crowdin account and project** — [Sign up at Crowdin](https://crowdin.com/) if needed. Translators work in **[Drytrix TimeTracker](https://crowdin.com/project/drytrix-timetracker)** (ask a maintainer for access if the project is private). Maintainers configure API tokens and GitHub integration against that same project unless you intentionally use a separate test project.
|
||||
2. **Source language:** English. Treat the resource as **Gettext PO** (`.po`).
|
||||
3. **Target languages:** Add every locale you ship: `nl`, `de`, `fr`, `it`, `fi`, `es`, `no`, `ar`, `he` (match `LANGUAGES` in `app/config.py`). For Norwegian, add Norwegian (Bokmål) in Crowdin; the `crowdin.yml` mapping writes files into `translations/no/`.
|
||||
4. **Sync with this repository (pick one):**
|
||||
@@ -48,6 +50,20 @@ This repo includes a root [`crowdin.yml`](../crowdin.yml) that maps **source** `
|
||||
|
||||
Translators only need a Crowdin account; they do not use git.
|
||||
|
||||
#### Further Crowdin integration (optional)
|
||||
|
||||
Pick what reduces manual work without duplicating automation (avoid running **both** the Crowdin GitHub app and the **Crowdin sync** Action on the same events unless you coordinate branches, or you may get competing PRs).
|
||||
|
||||
1. **Crowdin → Integrations → GitHub** — Connect the repository and default branch (e.g. `main` or `develop`). Crowdin can open PRs when translations are updated and can watch the repo for changes to configured source files. Use the same [`crowdin.yml`](../crowdin.yml) path the integration expects (usually repo root). This can replace manual Action runs for “download translations” if you prefer Crowdin-driven PRs.
|
||||
2. **Automate the existing Action** — Extend [.github/workflows/crowdin-sync.yml](../.github/workflows/crowdin-sync.yml) with triggers such as `schedule` (e.g. weekly), or `push` limited to `translations/en/**` and `messages.pot` so new English sources upload shortly after merge. Keep `workflow_dispatch` for on-demand full sync.
|
||||
3. **Pre-translate and QA** — In the [Drytrix TimeTracker](https://crowdin.com/project/drytrix-timetracker) project, enable **Translation Memory**, **Machine translation** (as a suggestion layer only), and **QA checks** (variables, HTML tags, duplicate translations). Add a **Glossary** for product names and fixed terminology.
|
||||
4. **Context for translators** — Upload **screenshots** or use Crowdin’s in-context / overlay tools where supported so ambiguous short strings (e.g. “Save”, “Project”) get the right meaning.
|
||||
5. **Review before merge** — Turn on **proofreading** / “Export only approved” in Crowdin if you want the GitHub Action or integration to pull only reviewed strings (match the Action’s `export_only_approved`-style options to your Crowdin workflow).
|
||||
6. **CLI in release process** — Add `crowdin upload sources` after `pybabel extract` / `update` in a maintainer script or release checklist so Crowdin always matches the latest POT-derived English catalog.
|
||||
7. **Notifications** — Slack, email, or webhooks in Crowdin when a language reaches 100% or when there are new strings to translate.
|
||||
|
||||
Official references: [Crowdin + GitHub](https://support.crowdin.com/github-integration/), [GitHub Action](https://github.com/crowdin/github-action), [Crowdin CLI](https://crowdin.github.io/crowdin-cli/).
|
||||
|
||||
### Other options (reference)
|
||||
|
||||
- **Poedit:** Maintainer can zip `translations/<lang>/LC_MESSAGES/messages.po` for a trusted translator; they edit in [Poedit](https://poedit.net/) and send the file back. Avoid two people editing the same locale in parallel without coordination.
|
||||
|
||||
+37
-16
@@ -2,6 +2,18 @@
|
||||
|
||||
The TimeTracker desktop app includes a comprehensive settings system that allows users to configure the server URL and API token.
|
||||
|
||||
## First sign-in (connection wizard)
|
||||
|
||||
On first launch (or whenever credentials are missing), the app shows a **two-step** flow:
|
||||
|
||||
1. **Step 1 — Server**
|
||||
Enter the base URL of your TimeTracker server (protocol and port as needed, e.g. `https://timetracker.example.com` or `http://192.168.1.50:5000`). If you omit the scheme, `https://` is assumed when validating. Use **Test server** to confirm the host speaks the TimeTracker API (`GET /api/v1/info` must return JSON with `api_version: "v1"`). **Continue to token** is enabled only after a successful test.
|
||||
|
||||
2. **Step 2 — API token**
|
||||
Paste an API token from the web app (**Admin → Security & Access → API tokens**). **Log in** verifies the token against the server (see **Connection testing** below).
|
||||
|
||||
Command-line `--server-url` / `TIMETRACKER_SERVER_URL` can pre-fill the stored server URL and skip typing it in step 1; you still complete token entry unless the token is already saved.
|
||||
|
||||
## Settings Location
|
||||
|
||||
Settings are stored using Electron's secure storage (`electron-store`), which saves data in a JSON file in the user's application data directory:
|
||||
@@ -54,7 +66,7 @@ export TIMETRACKER_SERVER_URL=https://your-server.com
|
||||
|
||||
### Server URL Configuration
|
||||
|
||||
- **Validation**: The app validates that the URL is properly formatted (must start with `http://` or `https://`)
|
||||
- **Validation**: URLs are normalized (trailing slashes removed). If you type a host without a scheme (e.g. `internal.company.com:8443`), `https://` is prepended for validation.
|
||||
- **Persistence**: Server URL is saved to secure storage and persists across app restarts
|
||||
- **Change Detection**: The app automatically reinitializes the API client when the server URL changes
|
||||
|
||||
@@ -67,11 +79,16 @@ export TIMETRACKER_SERVER_URL=https://your-server.com
|
||||
|
||||
### Connection Testing
|
||||
|
||||
The settings screen includes a "Test Connection" button that:
|
||||
- Validates the server URL format
|
||||
- Tests the API token against the server
|
||||
- Provides immediate feedback on connection status
|
||||
- Shows success/error messages
|
||||
The settings screen includes a **Test Connection** button (and **Save Settings** runs the same checks). The flow is:
|
||||
|
||||
1. **Public check** — `GET /api/v1/info` without credentials. The response must be JSON with `api_version: "v1"` and an `endpoints` object. If the server returns `setup_required: true`, finish initial web setup in a browser first.
|
||||
2. **Authenticated check** — With your token, the app calls `GET /api/v1/users/me`. If the token does not include the `read:users` scope, it falls back to `GET /api/v1/timer/status` (requires `read:time_entries`). One of these must succeed for the session to be considered valid.
|
||||
|
||||
Errors are shown with specific causes when possible (DNS, connection refused, timeout, TLS/certificate issues, HTTP status, wrong app).
|
||||
|
||||
### Session loss and background checks
|
||||
|
||||
While you are signed in, the app re-validates the session about every **30 seconds**. If the server repeatedly rejects the token (**401**), the app signs you out to the login wizard (step 2) and shows a short message so you can fix the token or server URL.
|
||||
|
||||
## Settings File Structure
|
||||
|
||||
@@ -97,18 +114,18 @@ When the settings view is opened:
|
||||
### Settings Saving
|
||||
|
||||
When "Save Settings" is clicked:
|
||||
1. Server URL is validated
|
||||
1. Server URL is validated and normalized
|
||||
2. API token is validated (if changed)
|
||||
3. Settings are saved to secure storage
|
||||
4. API client is reinitialized with new settings
|
||||
5. Connection is automatically tested
|
||||
6. Success/error message is displayed
|
||||
3. Values are written to secure storage (URL, token, sync options)
|
||||
4. API client is reinitialized with the new URL and token
|
||||
5. The same **public + authenticated** checks as **Test Connection** are run
|
||||
6. On full success, a success message is shown. If the **public** check fails, an error message is shown (values were already saved—correct them and save again). If only the **session** check fails, a **warning** is shown with the server message.
|
||||
|
||||
### Settings Validation
|
||||
|
||||
- **Server URL**: Must be a valid HTTP/HTTPS URL
|
||||
- **Server URL**: Must resolve to a valid HTTP/HTTPS URL after normalization
|
||||
- **API Token**: Must start with `tt_` and be non-empty
|
||||
- **Connection**: Server must be reachable and token must be valid
|
||||
- **Connection**: Server must expose TimeTracker `GET /api/v1/info`, and the token must pass the authenticated check described above
|
||||
|
||||
## Security Considerations
|
||||
|
||||
@@ -153,7 +170,11 @@ To manually edit or backup settings:
|
||||
|
||||
## Code References
|
||||
|
||||
- Settings UI: `desktop/src/renderer/index.html` (settings view)
|
||||
- Settings Logic: `desktop/src/renderer/js/app.js` (loadSettings, handleSaveSettings, handleTestConnection)
|
||||
- Login wizard and settings UI: `desktop/src/renderer/index.html`
|
||||
- Connection and settings logic: `desktop/src/renderer/js/app.js` (initApp, wizard handlers, loadSettings, handleSaveSettings, handleTestConnection, checkConnection)
|
||||
- HTTP client: `desktop/src/renderer/js/api/client.js` (`testPublicServerInfo`, `validateSession`, URL normalization, error classification)
|
||||
- Unit tests: `desktop/test/api-client.test.js` (run `npm test` from `desktop/`)
|
||||
- Storage: `desktop/src/shared/config.js` (storeGet, storeSet, storeDelete, storeClear)
|
||||
- Main Process: `desktop/src/main/main.js` (command line argument parsing)
|
||||
- Main process: `desktop/src/main/main.js` (command line argument parsing)
|
||||
|
||||
`npm run build` and `npm start` run **`prebuild` / `prestart`**, which rebuild the renderer bundle (`bundle.js`) via esbuild so packaged builds do not ship a stale UI.
|
||||
|
||||
+1
-1
@@ -27,7 +27,7 @@ This audit summarizes the state of TimeTracker documentation as of the audit dat
|
||||
| Item | Location | Fix |
|
||||
|------|----------|-----|
|
||||
| Version numbers | README "What's New" / "Current version" | Point to setup.py + CHANGELOG only; remove or generalize hardcoded v4.14.0, v4.6.0. |
|
||||
| Hardcoded version | docs/development/PROJECT_STRUCTURE.md | Replace "version='4.20.9'" with "version in setup.py (single source of truth)". |
|
||||
| Hardcoded version | docs/development/PROJECT_STRUCTURE.md | **Resolved:** OpenAPI `info.version` uses `get_version_from_setup()` + env overrides; see PROJECT_STRUCTURE versioning section. |
|
||||
| Hardcoded version | docs/FEATURES_COMPLETE.md | Remove "Version: 4.20.6" or replace with "See setup.py". |
|
||||
| Docker access URL | docs/development/CONTRIBUTING.md | Default `docker-compose up --build` → https://localhost. For http://localhost:8080 use docker-compose.example.yml or docker-compose.local-test.yml. |
|
||||
| Compose role | docs/development/PROJECT_STRUCTURE.md | Describe docker-compose.yml as "Default stack (HTTPS via nginx)"; add docker-compose.example.yml (HTTP 8080) and docker-compose.local-test.yml (SQLite). |
|
||||
|
||||
@@ -115,14 +115,9 @@ This document provides a comprehensive analysis of incomplete implementations, m
|
||||
#### 2.2 Telemetry (`app/utils/telemetry.py`)
|
||||
- **Status:** Implementation appears complete.
|
||||
|
||||
#### 2.3 PostHog Features (`app/utils/posthog_features.py`)
|
||||
- **Line 202-205**: Placeholder implementations
|
||||
```python
|
||||
# This is a placeholder - implement based on your needs
|
||||
pass
|
||||
```
|
||||
**Impact:** PostHog feature flags may not be fully functional.
|
||||
**Priority:** Low
|
||||
#### 2.3 PostHog server-side feature flags (removed)
|
||||
|
||||
The dedicated PostHog feature-flag helper under `app/utils/` was **removed**. Remote PostHog feature-flag evaluation is not part of this application; deployment behavior is controlled with **environment variables** and `app/config.py` instead.
|
||||
|
||||
#### 2.4 Environment Validation (`app/utils/env_validation.py`)
|
||||
- **Line 14**: `pass` statement
|
||||
@@ -456,10 +451,6 @@ This document provides a comprehensive analysis of incomplete implementations, m
|
||||
- Implement info toast notifications
|
||||
- **Estimated Effort:** 1-2 hours
|
||||
|
||||
4. **PostHog Feature Flags** (`app/utils/posthog_features.py:202`)
|
||||
- Complete PostHog feature flag implementation
|
||||
- **Estimated Effort:** 4-6 hours
|
||||
|
||||
---
|
||||
|
||||
## Testing Recommendations
|
||||
|
||||
@@ -44,6 +44,7 @@ make test-models # Test models only
|
||||
make test-unit # Test unit tests only
|
||||
make test-integration # Test integration only
|
||||
make test-api # Test API endpoints
|
||||
pytest tests/test_api_route_contract.py -v # Curated URL map + OpenAPI version vs setup.py
|
||||
```
|
||||
|
||||
### Coverage Analysis
|
||||
|
||||
@@ -114,6 +114,8 @@ Display a toast notification.
|
||||
- `options.type` (string, optional): Type of notification - 'success', 'error', 'warning', 'info' (default: 'info')
|
||||
- `options.duration` (number, optional): Duration in milliseconds (default: 5000, 0 = no auto-dismiss)
|
||||
- `options.dismissible` (boolean, optional): Show close button (default: true)
|
||||
- `options.actionLink` / `options.actionLabel` (string, optional): In-toast link (e.g. “View time entries”)
|
||||
- `options.onDismiss` (function, optional): Called when the toast is removed; receives a reason string such as `'close'` (user clicked the close button) or `'timeout'` (auto-dismiss). Use for syncing dismiss state with the server (for example smart notifications calling `POST /api/notifications/dismiss`).
|
||||
|
||||
**Returns:** Toast ID (can be used to dismiss programmatically)
|
||||
|
||||
|
||||
@@ -255,7 +255,7 @@ Potential improvements:
|
||||
|
||||
1. Add more languages (Spanish, Portuguese, Japanese, etc.)
|
||||
2. Right-to-left (RTL) language support (Arabic, Hebrew)
|
||||
3. User-contributed translations via [CONTRIBUTING_TRANSLATIONS.md](CONTRIBUTING_TRANSLATIONS.md) (issues, spreadsheet, or a hosted platform such as Weblate/Crowdin)
|
||||
3. User-contributed translations via [CONTRIBUTING_TRANSLATIONS.md](CONTRIBUTING_TRANSLATIONS.md) (issues, spreadsheet, [Crowdin — Drytrix TimeTracker](https://crowdin.com/project/drytrix-timetracker), or Weblate)
|
||||
4. Automatic language detection improvement
|
||||
5. Translation coverage reporting
|
||||
|
||||
@@ -264,7 +264,7 @@ Potential improvements:
|
||||
For questions or issues with translations:
|
||||
|
||||
1. Check this documentation
|
||||
2. **Contributors without Git:** see [CONTRIBUTING_TRANSLATIONS.md](CONTRIBUTING_TRANSLATIONS.md) (issue template, spreadsheet option, maintainer workflow, and optional [Crowdin](https://crowdin.com/) using root [`crowdin.yml`](../crowdin.yml) and the **Crowdin sync** GitHub Action)
|
||||
2. **Contributors without Git:** see [CONTRIBUTING_TRANSLATIONS.md](CONTRIBUTING_TRANSLATIONS.md) (issue template, spreadsheet option, maintainer workflow, and [Crowdin — Drytrix TimeTracker](https://crowdin.com/project/drytrix-timetracker) using root [`crowdin.yml`](../crowdin.yml) and the **Crowdin sync** GitHub Action)
|
||||
3. Review `app/__init__.py` locale selector
|
||||
4. Inspect browser network requests to `/i18n/set-language`
|
||||
5. Check application logs for translation compilation errors
|
||||
|
||||
@@ -4,6 +4,8 @@ This document describes the comprehensive version management system for TimeTrac
|
||||
|
||||
**For contributors:** Application version is defined only in **setup.py**. Do not duplicate it in README or other docs. Desktop and mobile builds may use their own version numbers; see [BUILD.md](../../build/BUILD.md) and repo scripts.
|
||||
|
||||
**OpenAPI (`/api/openapi.json`):** The `info.version` field uses the same resolution as the in-app version helpers: environment variables **`TIMETRACKER_VERSION`** or **`APP_VERSION`** override the value read from **`setup.py`**; see `get_version_from_setup()` in `app/config/analytics_defaults.py` and `openapi_spec()` in `app/routes/api_docs.py`.
|
||||
|
||||
## Overview
|
||||
|
||||
The version management system provides multiple ways to set version tags:
|
||||
@@ -365,6 +367,30 @@ For external CI/CD systems, use the version manager scripts:
|
||||
git push origin --tags
|
||||
```
|
||||
|
||||
## Admin in-app update notification (GitHub releases) {#admin-github-update-notification}
|
||||
|
||||
Administrators can be notified in the web UI when a **newer semantic version** exists on GitHub compared to this installation. The feature is server-driven, does not affect non-admin users, and uses caching so routine page loads do not hammer the GitHub API.
|
||||
|
||||
### Behavior
|
||||
|
||||
- **Source of truth for “latest”:** GitHub’s API for the configured repository (default `DRYTRIX/TimeTracker`), either `releases/latest` or, when pre-releases are enabled, the newest non-draft release from the releases list.
|
||||
- **Installed version:** `APP_VERSION` / `GITHUB_TAG` from the environment (via Flask config) if it parses as a semantic version; otherwise the version read from **`setup.py`** at runtime (see the note at the top of this document). Non-semver installs (for example `dev-123`) do not show an upgrade prompt.
|
||||
- **UI:** A small, non-blocking card on authenticated admin pages (templates using `base.html`). **Dismiss** hides until the next load; **Don’t show again for this version** persists per user in the database (migration `148_add_user_dismissed_release_version`) and mirrors to browser `localStorage` as a fallback.
|
||||
- **API:** `GET /api/version/check` and `POST /api/version/dismiss` on the legacy `/api` JSON routes; session or API token; admin-only. See [REST API — Admin version check](../../api/REST_API.md#admin-version-check-web-json-under-api).
|
||||
|
||||
### Environment variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `VERSION_CHECK_GITHUB_REPO` | `DRYTRIX/TimeTracker` | `owner/repo` for `api.github.com/repos/{repo}/releases/…` |
|
||||
| `VERSION_CHECK_GITHUB_CACHE_TTL` | `43200` (12h) | TTL in seconds for the successful GitHub response cache |
|
||||
| `VERSION_CHECK_GITHUB_STALE_TTL` | `604800` (7d) | TTL for the last successful payload used when GitHub returns errors (for example `403` rate limit) |
|
||||
| `VERSION_CHECK_HTTP_TIMEOUT` | `10` | HTTP timeout in seconds for GitHub requests |
|
||||
| `GITHUB_RELEASES_TOKEN` | _(empty)_ | Optional GitHub personal access token (fine-scoped classic token with `public_repo` is enough for public repos); raises authenticated rate limits. **Do not commit tokens;** set only via environment or secrets manager. |
|
||||
| `ENABLE_PRE_RELEASE_NOTIFICATIONS` | `false` | When `true`, consider the newest non-draft release from the paginated releases list (including pre-releases). When `false`, use `releases/latest` (stable only per GitHub’s definition). |
|
||||
|
||||
Optional: set `APP_VERSION` (or `GITHUB_TAG`) at deploy time to a semver string so Docker images and CI builds compare correctly against release tags.
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- **Version database** for tracking all versions
|
||||
|
||||
@@ -4,14 +4,15 @@ This guide explains how to leverage PostHog's advanced features in TimeTracker f
|
||||
|
||||
## 📊 What's Included
|
||||
|
||||
TimeTracker now uses these PostHog features:
|
||||
TimeTracker uses these PostHog-related analytics capabilities where configured:
|
||||
|
||||
1. **Person Properties** - Track user and installation characteristics
|
||||
2. **Group Analytics** - Segment by version, platform, etc.
|
||||
3. **Feature Flags** - Gradual rollouts and A/B testing
|
||||
4. **Identify Calls** - Rich user profiles in PostHog
|
||||
5. **Enhanced Event Properties** - Contextual data for better analysis
|
||||
6. **Group Identification** - Cohort analysis by installation type
|
||||
3. **Identify Calls** - Rich user profiles in PostHog
|
||||
4. **Enhanced Event Properties** - Contextual data for better analysis
|
||||
5. **Group Identification** - Cohort analysis by installation type
|
||||
|
||||
**Server-side feature gates** (rollouts, kill switches, route guards) are **not** implemented via PostHog in this codebase. Use environment variables and [`app/config.py`](../../../app/config.py) instead.
|
||||
|
||||
## 🎯 Person Properties
|
||||
|
||||
@@ -102,109 +103,19 @@ Installations are automatically grouped by:
|
||||
- "How many Linux installations are active?"
|
||||
- "Which Python versions are most common on Windows?"
|
||||
|
||||
## 🚀 Feature Flags
|
||||
## 🧪 Experiments and measurement
|
||||
|
||||
### Basic Usage
|
||||
There is no in-app PostHog feature-flag or variant API. To compare behaviors, implement variants in your own code (for example driven by `app.config`) and **distinguish them in analytics** with explicit properties on `track_event` calls (see below).
|
||||
|
||||
Check if a feature is enabled:
|
||||
### Track meaningful actions
|
||||
|
||||
```python
|
||||
from app.utils.posthog_features import get_feature_flag
|
||||
from app import track_event
|
||||
|
||||
if get_feature_flag(user.id, "new-dashboard"):
|
||||
return render_template("dashboard_v2.html")
|
||||
else:
|
||||
return render_template("dashboard.html")
|
||||
```
|
||||
|
||||
### Route Protection
|
||||
|
||||
Require a feature flag for entire routes:
|
||||
|
||||
```python
|
||||
from app.utils.posthog_features import feature_flag_required
|
||||
|
||||
@app.route('/beta/advanced-analytics')
|
||||
@feature_flag_required('beta-features')
|
||||
def advanced_analytics():
|
||||
return render_template("analytics_beta.html")
|
||||
```
|
||||
|
||||
### Remote Configuration
|
||||
|
||||
Use feature flag payloads for configuration:
|
||||
|
||||
```python
|
||||
from app.utils.posthog_features import get_feature_flag_payload
|
||||
|
||||
config = get_feature_flag_payload(user.id, "dashboard-config")
|
||||
if config:
|
||||
theme = config.get("theme", "light")
|
||||
widgets = config.get("enabled_widgets", [])
|
||||
```
|
||||
|
||||
### Frontend Feature Flags
|
||||
|
||||
Inject flags into JavaScript:
|
||||
|
||||
```python
|
||||
# In your view function
|
||||
from app.utils.posthog_features import inject_feature_flags_to_frontend
|
||||
|
||||
@app.route('/dashboard')
|
||||
def dashboard():
|
||||
feature_flags = inject_feature_flags_to_frontend(current_user.id)
|
||||
return render_template("dashboard.html", feature_flags=feature_flags)
|
||||
```
|
||||
|
||||
```html
|
||||
<!-- In your template -->
|
||||
<script>
|
||||
window.featureFlags = {{ feature_flags|tojson }};
|
||||
|
||||
if (window.featureFlags['new-timer-ui']) {
|
||||
// Load new timer UI
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
### Predefined Feature Flags
|
||||
|
||||
Use the `FeatureFlags` class to avoid typos:
|
||||
|
||||
```python
|
||||
from app.utils.posthog_features import FeatureFlags
|
||||
|
||||
if get_feature_flag(user.id, FeatureFlags.ADVANCED_REPORTS):
|
||||
# Enable advanced reports
|
||||
pass
|
||||
```
|
||||
|
||||
## 🧪 A/B Testing & Experiments
|
||||
|
||||
### Track Experiment Variants
|
||||
|
||||
```python
|
||||
from app.utils.posthog_features import get_active_experiments
|
||||
|
||||
experiments = get_active_experiments(user.id)
|
||||
# {"timer-ui-experiment": "variant-b"}
|
||||
|
||||
if experiments.get("timer-ui-experiment") == "variant-b":
|
||||
# Show variant B
|
||||
pass
|
||||
```
|
||||
|
||||
### Track Feature Interactions
|
||||
|
||||
```python
|
||||
from app.utils.posthog_features import track_feature_flag_interaction
|
||||
|
||||
track_feature_flag_interaction(
|
||||
track_event(
|
||||
user.id,
|
||||
"new-dashboard",
|
||||
"clicked_export_button",
|
||||
{"export_type": "csv", "rows": 100}
|
||||
"export.completed",
|
||||
{"export_type": "csv", "rows": 100, "experiment_variant": "b"},
|
||||
)
|
||||
```
|
||||
|
||||
@@ -269,41 +180,6 @@ Event: timer.started
|
||||
Breakdown: Hour of day
|
||||
```
|
||||
|
||||
## 🎨 Setting Up Feature Flags in PostHog
|
||||
|
||||
### 1. Create a Feature Flag
|
||||
|
||||
1. Go to PostHog → Feature Flags
|
||||
2. Click "New feature flag"
|
||||
3. Set key (e.g., `new-dashboard`)
|
||||
4. Configure rollout:
|
||||
- **Boolean**: On/off for everyone
|
||||
- **Percentage**: Gradual rollout (e.g., 10% of users)
|
||||
- **Person properties**: Target specific users
|
||||
- **Groups**: Target specific platforms/versions
|
||||
|
||||
### 2. Target Specific Users
|
||||
|
||||
**Example: Enable for admins only**
|
||||
```
|
||||
Match person properties:
|
||||
is_admin = true
|
||||
```
|
||||
|
||||
**Example: Enable for Docker installations**
|
||||
```
|
||||
Match group properties:
|
||||
deployment_method = "docker"
|
||||
```
|
||||
|
||||
### 3. Gradual Rollout
|
||||
|
||||
1. Start at 0% (disabled)
|
||||
2. Roll out to 10% (testing)
|
||||
3. Increase to 50% (beta)
|
||||
4. Increase to 100% (full release)
|
||||
5. Remove flag from code
|
||||
|
||||
## 🔐 Person Properties for Segmentation
|
||||
|
||||
### Available Person Properties
|
||||
@@ -360,81 +236,40 @@ Person properties:
|
||||
4. **Timer Usage** - `timer.started` events over time
|
||||
5. **Export Activity** - `export.csv` events by user cohort
|
||||
|
||||
## 🚨 Kill Switches
|
||||
## 🚨 Kill switches and rollouts (application)
|
||||
|
||||
Use feature flags as emergency kill switches:
|
||||
To disable or limit behavior for **all users** of an installation, use **configuration**: environment variables and [`app/config.py`](../../../app/config.py). That requires a deploy or config change, which is the supported model for this codebase.
|
||||
|
||||
```python
|
||||
from app.utils.posthog_features import get_feature_flag, FeatureFlags
|
||||
## 🧑💻 Development best practices
|
||||
|
||||
@app.route('/api/export')
|
||||
def api_export():
|
||||
if not get_feature_flag(current_user.id, FeatureFlags.ENABLE_EXPORTS, default=True):
|
||||
abort(503, "Exports temporarily disabled")
|
||||
|
||||
# Proceed with export
|
||||
```
|
||||
### 1. Centralize deployment toggles
|
||||
|
||||
**Benefits:**
|
||||
- Instantly disable problematic features
|
||||
- No deployment needed
|
||||
- Can target specific user segments
|
||||
- Helps during incidents
|
||||
Add booleans or strings to `Config` in `app/config.py` and read them from the environment with safe defaults.
|
||||
|
||||
## 🧑💻 Development Best Practices
|
||||
### 2. Default to safe values
|
||||
|
||||
### 1. Define Flags Centrally
|
||||
Prefer secure or conservative defaults for production (for example registration off unless explicitly enabled).
|
||||
|
||||
```python
|
||||
# In app/utils/posthog_features.py
|
||||
class FeatureFlags:
|
||||
MY_NEW_FEATURE = "my-new-feature"
|
||||
```
|
||||
### 3. Document env vars
|
||||
|
||||
### 2. Default to Safe Values
|
||||
When you add a new toggle, document the variable in deployment or admin docs so operators know how to set it.
|
||||
|
||||
```python
|
||||
# Default to False for new features
|
||||
if get_feature_flag(user.id, "risky-feature", default=False):
|
||||
# Enable risky feature
|
||||
```
|
||||
### 4. Test behavior
|
||||
|
||||
### 3. Clean Up Old Flags
|
||||
Test both branches of a toggle in unit tests by patching `current_app.config` or the setting your view reads.
|
||||
|
||||
Once a feature is fully rolled out:
|
||||
1. Remove the flag check from code
|
||||
2. Delete the flag in PostHog
|
||||
3. Document in release notes
|
||||
|
||||
### 4. Test Flag Behavior
|
||||
|
||||
```python
|
||||
def test_feature_flag():
|
||||
with mock.patch('app.utils.posthog_features.get_feature_flag') as mock_flag:
|
||||
mock_flag.return_value = True
|
||||
# Test with flag enabled
|
||||
|
||||
mock_flag.return_value = False
|
||||
# Test with flag disabled
|
||||
```
|
||||
|
||||
## 📚 Additional Resources
|
||||
## 📚 Additional resources
|
||||
|
||||
- **PostHog Docs**: https://posthog.com/docs
|
||||
- **Feature Flags**: https://posthog.com/docs/feature-flags
|
||||
- **Group Analytics**: https://posthog.com/docs/data/group-analytics
|
||||
- **Person Properties**: https://posthog.com/docs/data/persons
|
||||
- **Experiments**: https://posthog.com/docs/experiments
|
||||
|
||||
## 🎉 Benefits Summary
|
||||
## 🎉 Benefits summary
|
||||
|
||||
Using these PostHog features, you can now:
|
||||
With the analytics integration, you can:
|
||||
|
||||
✅ **Segment users** by role, auth method, platform, version
|
||||
✅ **Gradually roll out** features to test with small groups
|
||||
✅ **A/B test** different UI variations
|
||||
✅ **Kill switches** for emergency feature disabling
|
||||
✅ **Remote config** without deploying code changes
|
||||
✅ **Cohort analysis** to understand user behavior
|
||||
✅ **Track updates** and version adoption patterns
|
||||
✅ **Monitor health** of different installation types
|
||||
|
||||
@@ -111,48 +111,7 @@ posthog.group_identify(
|
||||
- ✅ Understand deployment patterns
|
||||
- ✅ Correlate issues with specific configurations
|
||||
|
||||
### 4. **Feature Flags System** 🚩
|
||||
|
||||
**What:** Complete feature flag utilities for gradual rollouts and A/B testing.
|
||||
|
||||
**New File:** `app/utils/posthog_features.py`
|
||||
|
||||
**Features:**
|
||||
- `get_feature_flag()` - Check if feature is enabled
|
||||
- `get_feature_flag_payload()` - Remote configuration
|
||||
- `get_all_feature_flags()` - Get all flags for a user
|
||||
- `feature_flag_required()` - Decorator for route protection
|
||||
- `inject_feature_flags_to_frontend()` - Frontend integration
|
||||
- `track_feature_flag_interaction()` - Track feature usage
|
||||
- `FeatureFlags` class - Centralized flag definitions
|
||||
|
||||
**Example Usage:**
|
||||
```python
|
||||
from app.utils.posthog_features import get_feature_flag, feature_flag_required
|
||||
|
||||
# Simple check
|
||||
if get_feature_flag(user.id, "new-dashboard"):
|
||||
return render_template("dashboard_v2.html")
|
||||
|
||||
# Route protection
|
||||
@app.route('/beta/feature')
|
||||
@feature_flag_required('beta-features')
|
||||
def beta_feature():
|
||||
return "Beta!"
|
||||
|
||||
# Frontend injection
|
||||
feature_flags = inject_feature_flags_to_frontend(user.id)
|
||||
return render_template("app.html", feature_flags=feature_flags)
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- ✅ Gradual feature rollouts (0% → 10% → 50% → 100%)
|
||||
- ✅ A/B testing different UI variations
|
||||
- ✅ Emergency kill switches
|
||||
- ✅ Target features to specific user segments
|
||||
- ✅ Remote configuration without deployment
|
||||
|
||||
### 5. **Automatic User Identification on Login** 🔐
|
||||
### 4. **Automatic User Identification on Login** 🔐
|
||||
|
||||
**What:** Users are automatically identified when they log in (both local and OIDC).
|
||||
|
||||
@@ -191,23 +150,17 @@ return render_template("app.html", feature_flags=feature_flags)
|
||||
- Added `identify_user()` calls on OIDC login
|
||||
- Set person properties on every login
|
||||
|
||||
### New Files
|
||||
4. **`app/utils/posthog_features.py`** (NEW)
|
||||
- Complete feature flag system
|
||||
- Predefined flag constants
|
||||
- Helper functions and decorators
|
||||
|
||||
### Documentation
|
||||
5. **`POSTHOG_ADVANCED_FEATURES.md`** (NEW)
|
||||
4. **`POSTHOG_ADVANCED_FEATURES.md`** (NEW)
|
||||
- Complete guide to all features
|
||||
- Usage examples and best practices
|
||||
- PostHog query examples
|
||||
|
||||
6. **`POSTHOG_ENHANCEMENTS_SUMMARY.md`** (THIS FILE)
|
||||
5. **`POSTHOG_ENHANCEMENTS_SUMMARY.md`** (THIS FILE)
|
||||
- Summary of all changes
|
||||
|
||||
### Tests
|
||||
7. **`tests/test_telemetry.py`**
|
||||
6. **`tests/test_telemetry.py`**
|
||||
- Updated to match enhanced property names
|
||||
|
||||
## 🚀 What You Can Do Now
|
||||
@@ -217,61 +170,26 @@ return render_template("app.html", feature_flags=feature_flags)
|
||||
- Group installations by version, platform, deployment method
|
||||
- Build cohorts for targeted analysis
|
||||
|
||||
### 2. **Gradual Rollouts**
|
||||
```python
|
||||
# In PostHog: Create flag "new-timer-ui" at 10%
|
||||
if get_feature_flag(user.id, "new-timer-ui"):
|
||||
# Show new UI to 10% of users
|
||||
pass
|
||||
```
|
||||
### 2. **Deployment toggles (not PostHog)**
|
||||
|
||||
### 3. **A/B Testing**
|
||||
```python
|
||||
experiments = get_active_experiments(user.id)
|
||||
if experiments.get("onboarding-flow") == "variant-b":
|
||||
# Show variant B
|
||||
pass
|
||||
```
|
||||
Rollouts, kill switches, and route-level gates use **environment variables** and [`app/config.py`](../../../app/config.py), not a PostHog feature-flag module.
|
||||
|
||||
### 4. **Emergency Kill Switches**
|
||||
```python
|
||||
if not get_feature_flag(user.id, "enable-exports", default=True):
|
||||
abort(503, "Exports temporarily disabled")
|
||||
```
|
||||
|
||||
### 5. **Remote Configuration**
|
||||
```python
|
||||
config = get_feature_flag_payload(user.id, "dashboard-config")
|
||||
theme = config.get("theme", "light")
|
||||
widgets = config.get("enabled_widgets", [])
|
||||
```
|
||||
|
||||
### 6. **Frontend Feature Flags**
|
||||
```html
|
||||
<script>
|
||||
window.featureFlags = {{ feature_flags|tojson }};
|
||||
if (window.featureFlags['new-ui']) {
|
||||
// Enable new UI
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
### 7. **Version Analytics**
|
||||
### 3. **Version Analytics**
|
||||
- Track how many installations are on each version
|
||||
- Identify installations that need updates
|
||||
- Measure update adoption speed
|
||||
|
||||
### 8. **Platform Analytics**
|
||||
### 4. **Platform Analytics**
|
||||
- Compare behavior across Linux, Windows, macOS
|
||||
- Identify platform-specific issues
|
||||
- Optimize for most common platforms
|
||||
|
||||
### 9. **User Behavior Analysis**
|
||||
### 5. **User Behavior Analysis**
|
||||
- Filter events by user role
|
||||
- Analyze admin vs regular user behavior
|
||||
- Track feature adoption by user segment
|
||||
|
||||
### 10. **Installation Health**
|
||||
### 6. **Installation Health**
|
||||
- Monitor active installations (telemetry.health events)
|
||||
- Track deployment methods (Docker vs native)
|
||||
- Geographic distribution via timezone
|
||||
@@ -317,24 +235,7 @@ Compare to: All users
|
||||
|
||||
## 🎨 Setting Up in PostHog
|
||||
|
||||
### 1. **Create Feature Flags**
|
||||
|
||||
Go to PostHog → Feature Flags → New feature flag
|
||||
|
||||
**Example: Gradual Rollout**
|
||||
- Key: `new-dashboard`
|
||||
- Rollout: 10% of users
|
||||
- Increase over time: 10% → 50% → 100%
|
||||
|
||||
**Example: Admin Only**
|
||||
- Key: `admin-tools`
|
||||
- Condition: Person property `is_admin` = `true`
|
||||
|
||||
**Example: Docker Users**
|
||||
- Key: `docker-optimizations`
|
||||
- Condition: Person property `deployment_method` = `docker`
|
||||
|
||||
### 2. **Create Cohorts**
|
||||
### 1. **Create Cohorts**
|
||||
|
||||
**Docker Admins:**
|
||||
```
|
||||
@@ -351,7 +252,7 @@ Events:
|
||||
telemetry.install within last 30 days
|
||||
```
|
||||
|
||||
### 3. **Build Dashboards**
|
||||
### 2. **Build Dashboards**
|
||||
|
||||
**Installation Health:**
|
||||
- Active installations (last 24h)
|
||||
@@ -388,7 +289,7 @@ pytest tests/test_telemetry.py -v
|
||||
|
||||
No linter errors:
|
||||
```bash
|
||||
pylint app/utils/telemetry.py app/utils/posthog_features.py
|
||||
pylint app/utils/telemetry.py
|
||||
# ✅ No errors
|
||||
```
|
||||
|
||||
@@ -405,14 +306,10 @@ With these enhancements, you now have:
|
||||
|
||||
✅ **World-class product analytics** with person properties
|
||||
✅ **Group analytics** for cohort analysis
|
||||
✅ **Feature flags** for gradual rollouts & A/B testing
|
||||
✅ **Kill switches** for emergency feature control
|
||||
✅ **Remote configuration** without deployments
|
||||
✅ **Rich context** on every event
|
||||
✅ **Installation tracking** with version/platform groups
|
||||
✅ **User segmentation** by role, auth, platform
|
||||
✅ **Automatic identification** on login
|
||||
✅ **Frontend integration** for client-side flags
|
||||
✅ **Comprehensive docs** and examples
|
||||
✅ **Production-ready** with tests passing
|
||||
|
||||
@@ -424,20 +321,11 @@ With these enhancements, you now have:
|
||||
POSTHOG_HOST=https://app.posthog.com
|
||||
```
|
||||
|
||||
2. **Create Feature Flags** in PostHog dashboard
|
||||
2. **Build Dashboards** for your metrics
|
||||
|
||||
3. **Build Dashboards** for your metrics
|
||||
3. **Analyze Data** in PostHog to make data-driven decisions
|
||||
|
||||
4. **Start Using Flags** in your code:
|
||||
```python
|
||||
from app.utils.posthog_features import FeatureFlags, get_feature_flag
|
||||
|
||||
if get_feature_flag(user.id, FeatureFlags.NEW_DASHBOARD):
|
||||
# New feature!
|
||||
pass
|
||||
```
|
||||
|
||||
5. **Analyze Data** in PostHog to make data-driven decisions
|
||||
4. **Gate features in the app** using `app/config.py` and environment variables when you need deploy-time toggles
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -38,49 +38,9 @@ identify_user(user.id, {
|
||||
})
|
||||
```
|
||||
|
||||
### Check Feature Flag
|
||||
```python
|
||||
from app.utils.posthog_features import get_feature_flag
|
||||
### Application toggles (server-side)
|
||||
|
||||
if get_feature_flag(user.id, "new-feature"):
|
||||
# Enable feature
|
||||
pass
|
||||
```
|
||||
|
||||
### Protect Route with Flag
|
||||
```python
|
||||
from app.utils.posthog_features import feature_flag_required
|
||||
|
||||
@app.route('/beta/feature')
|
||||
@feature_flag_required('beta-access')
|
||||
def beta_feature():
|
||||
return "Beta!"
|
||||
```
|
||||
|
||||
### Get Flag Payload (Remote Config)
|
||||
```python
|
||||
from app.utils.posthog_features import get_feature_flag_payload
|
||||
|
||||
config = get_feature_flag_payload(user.id, "app-config")
|
||||
if config:
|
||||
theme = config.get("theme", "light")
|
||||
```
|
||||
|
||||
### Inject Flags to Frontend
|
||||
```python
|
||||
from app.utils.posthog_features import inject_feature_flags_to_frontend
|
||||
|
||||
@app.route('/dashboard')
|
||||
def dashboard():
|
||||
flags = inject_feature_flags_to_frontend(current_user.id)
|
||||
return render_template("dashboard.html", feature_flags=flags)
|
||||
```
|
||||
|
||||
```html
|
||||
<script>
|
||||
window.featureFlags = {{ feature_flags|tojson }};
|
||||
</script>
|
||||
```
|
||||
TimeTracker does **not** ship a PostHog-backed feature-flag API. Enable or restrict behavior with **environment variables** and [`app/config.py`](../../../app/config.py) (for example `DEMO_MODE`, `ALLOW_SELF_REGISTER`, `ENABLE_TELEMETRY`). Per-user UI options live on the user model in the database.
|
||||
|
||||
## 📊 Person Properties
|
||||
|
||||
@@ -100,39 +60,6 @@ def dashboard():
|
||||
- `timezone` - Installation timezone
|
||||
- `first_seen_version` - Original version (set once)
|
||||
|
||||
## 🎯 Feature Flag Examples
|
||||
|
||||
### Gradual Rollout
|
||||
```
|
||||
Key: new-ui
|
||||
Rollout: 10% → 25% → 50% → 100%
|
||||
```
|
||||
|
||||
### Target Admins Only
|
||||
```
|
||||
Key: admin-tools
|
||||
Condition: is_admin = true
|
||||
```
|
||||
|
||||
### Platform Specific
|
||||
```
|
||||
Key: linux-optimizations
|
||||
Condition: current_platform = "Linux"
|
||||
```
|
||||
|
||||
### Version Specific
|
||||
```
|
||||
Key: v3-features
|
||||
Condition: current_version >= "3.0.0"
|
||||
```
|
||||
|
||||
### Kill Switch
|
||||
```
|
||||
Key: enable-exports
|
||||
Default: true
|
||||
Use in code: default=True
|
||||
```
|
||||
|
||||
## 📈 Useful PostHog Queries
|
||||
|
||||
### Active Users by Role
|
||||
@@ -187,16 +114,6 @@ Compare: All platforms
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
### Mock Feature Flags
|
||||
```python
|
||||
from unittest.mock import patch
|
||||
|
||||
def test_with_feature_enabled():
|
||||
with patch('app.utils.posthog_features.get_feature_flag', return_value=True):
|
||||
# Test with feature enabled
|
||||
pass
|
||||
```
|
||||
|
||||
### Mock Track Events
|
||||
```python
|
||||
@patch('app.track_event')
|
||||
@@ -212,36 +129,7 @@ def test_event_tracking(mock_track):
|
||||
- **Analytics Docs**: [docs/analytics.md](docs/analytics.md)
|
||||
- **PostHog Docs**: https://posthog.com/docs
|
||||
|
||||
## 🎯 Predefined Feature Flags
|
||||
|
||||
```python
|
||||
from app.utils.posthog_features import FeatureFlags
|
||||
|
||||
# Beta features
|
||||
FeatureFlags.BETA_FEATURES
|
||||
FeatureFlags.NEW_DASHBOARD
|
||||
FeatureFlags.ADVANCED_REPORTS
|
||||
|
||||
# Experiments
|
||||
FeatureFlags.TIMER_UI_EXPERIMENT
|
||||
FeatureFlags.ONBOARDING_FLOW
|
||||
|
||||
# Rollouts
|
||||
FeatureFlags.NEW_ANALYTICS_PAGE
|
||||
FeatureFlags.BULK_OPERATIONS
|
||||
|
||||
# Kill switches
|
||||
FeatureFlags.ENABLE_EXPORTS
|
||||
FeatureFlags.ENABLE_API
|
||||
FeatureFlags.ENABLE_WEBSOCKETS
|
||||
|
||||
# Premium
|
||||
FeatureFlags.CUSTOM_REPORTS
|
||||
FeatureFlags.API_ACCESS
|
||||
FeatureFlags.INTEGRATIONS
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Quick Tip:** Start with small rollouts (10%) and gradually increase as you gain confidence!
|
||||
**Quick Tip:** Use person properties and cohorts in PostHog for analysis; gate behavior in the app with config and env vars.
|
||||
|
||||
|
||||
@@ -73,6 +73,16 @@ This document lists events tracked via PostHog and JSON logging.
|
||||
| `export.csv` | User exports data to CSV | `user_id`, `export_type`, `row_count` |
|
||||
| `export.pdf` | User exports data to PDF | `user_id`, `export_type` |
|
||||
|
||||
## Support & donation funnel (opt-in layer)
|
||||
|
||||
| Event Name | Description | Properties |
|
||||
|-----------|-------------|-------------|
|
||||
| `support.modal_opened` | User opened the support modal | `variant`, `source` |
|
||||
| `support.donation_clicked` | User chose a donation tier from the modal | `variant` (tier key), `source` |
|
||||
| `support.license_clicked` | User opened supporter checkout / license from the modal | `source` |
|
||||
| `support.prompt_shown` | Soft support toast or prompt was shown | `variant`, `source` |
|
||||
| `support.prompt_dismissed` | User dismissed a soft support prompt | `variant`, `source` |
|
||||
|
||||
## Comment Events
|
||||
|
||||
| Event Name | Description | Properties |
|
||||
|
||||
@@ -20,6 +20,7 @@ TimeTracker provides privacy-aware analytics and monitoring with Grafana Cloud O
|
||||
- Product events such as `timer.started`, `project.created`, `auth.login`
|
||||
- Sent only when admins enable detailed analytics in the app
|
||||
- PII-filtered before export
|
||||
- Support UI funnel events (`support.modal_opened`, `support.donation_clicked`, etc.) are emitted the same way when opt-in is enabled; see [all_tracked_events.md](all_tracked_events.md).
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -34,8 +35,22 @@ ENABLE_TELEMETRY=true
|
||||
# Optional error monitoring
|
||||
SENTRY_DSN=
|
||||
SENTRY_TRACES_RATE=0.1
|
||||
|
||||
# Support / checkout links (optional; defaults in app/config.py)
|
||||
SUPPORT_PURCHASE_URL=https://timetracker.drytrix.com/support.html
|
||||
SUPPORT_PORTAL_BASE=https://timetracker.drytrix.com
|
||||
# Optional one line shown in the support modal when set
|
||||
SUPPORT_SOCIAL_PROOF_TEXT=
|
||||
# Optional per-tier donate URLs (default to SUPPORT_PURCHASE_URL when unset)
|
||||
SUPPORT_DONATE_EUR5_URL=
|
||||
SUPPORT_DONATE_EUR10_URL=
|
||||
SUPPORT_DONATE_EUR25_URL=
|
||||
# Long-session soft prompt threshold in minutes (default 120)
|
||||
SUPPORT_LONG_SESSION_MINUTES=120
|
||||
```
|
||||
|
||||
Per-user **report generation counts** for the support modal are stored in `users.support_stats_reports_generated` (see migration `149_add_user_support_stats_reports_generated`).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- If no telemetry arrives, verify `GRAFANA_OTLP_ENDPOINT` and `GRAFANA_OTLP_TOKEN`
|
||||
|
||||
@@ -42,4 +42,4 @@ This document records the API consistency audit performed for the TimeTracker ba
|
||||
## 6. References
|
||||
|
||||
- **REST API reference**: [REST_API.md](REST_API.md) — endpoints, request/response formats, pagination, errors.
|
||||
- **OpenAPI**: `/api/openapi.json` and Swagger UI at `/api/docs` — aligned with this contract where updated.
|
||||
- **OpenAPI**: `/api/openapi.json` and Swagger UI at `/api/docs` — aligned with this contract where updated. **`info.version`** follows `get_version_from_setup()` (from `setup.py`, with optional **`TIMETRACKER_VERSION`** / **`APP_VERSION`** overrides); see `app/routes/api_docs.py`.
|
||||
|
||||
@@ -11,6 +11,30 @@ TimeTracker uses URL-based API versioning to ensure backward compatibility while
|
||||
/api/v2/* - Future version (when breaking changes are needed)
|
||||
```
|
||||
|
||||
## Session JSON under `/api/*` vs REST under `/api/v1`
|
||||
|
||||
| Surface | Auth | Audience | Spec |
|
||||
|--------|------|----------|------|
|
||||
| **`/api/v1/*`** | API token (Bearer or `X-API-Key`), scopes | Integrations, mobile, desktop | OpenAPI at `/api/openapi.json`, UI at `/api/docs` |
|
||||
| **`/api/*`** | Flask-Login **session** (browser cookie) | Logged-in web UI (`app/routes/api.py`) | Not fully documented in OpenAPI; may change with templates/JS |
|
||||
|
||||
**Hybrid (session or token):** `/api/version/check` and `/api/version/dismiss` accept either a logged-in admin session or a valid API token (see `app/routes/api.py`).
|
||||
|
||||
### Deprecated session routes (overlap with v1)
|
||||
|
||||
These **`/api/*`** routes have v1 successors. They remain for the web UI but may send **`X-API-Deprecated: true`** and **`Link: </api/v1/...>; rel="successor-version"`**:
|
||||
|
||||
- `GET /api/health` → `GET /api/v1/health`
|
||||
- `GET /api/search` → `GET /api/v1/search` (same JSON shape, including `partial` and `errors` on degraded per-domain search; see [REST_API.md](REST_API.md#search))
|
||||
- Timer: `GET /api/timer/status`, `POST /api/timer/start`, `POST /api/timer/stop`, `POST /api/timer/stop_at`, `POST /api/timer/resume` → `/api/v1/timer/*`
|
||||
- Time entries: `GET|POST /api/entries`, `POST /api/entries/bulk`, `GET|PUT|DELETE /api/entry/<id>` → `/api/v1/time-entries` (and related)
|
||||
- `GET /api/projects`, `GET /api/projects/<id>/tasks`, `GET /api/tasks` → `/api/v1/projects`, `/api/v1/tasks`
|
||||
- `GET /api/activities` → `GET /api/v1/activities` (v1 is a simpler list; legacy adds filters/pagination)
|
||||
|
||||
### Internal / UI-only (no v1 equivalent yet)
|
||||
|
||||
Examples: `GET /api/notifications`, dashboard stats (`/api/dashboard/*`, `/api/stats*`), editor uploads, smart notifications dismiss, many calendar helpers. Treat as **internal** to the web app unless documented otherwise.
|
||||
|
||||
## Versioning Policy
|
||||
|
||||
### When to Create a New Version
|
||||
@@ -64,10 +88,10 @@ Clients specify API version via:
|
||||
|
||||
## Deprecation Policy
|
||||
|
||||
1. **Deprecation Notice:** Deprecated endpoints return `X-API-Deprecated: true` header
|
||||
2. **Deprecation Period:** Minimum 6 months before removal
|
||||
3. **Migration Guide:** Documentation provided for migrating to new version
|
||||
4. **Removal:** Deprecated endpoints removed only in major version bumps
|
||||
1. **Deprecation notice:** Deprecated **session** JSON routes (see table above) return **`X-API-Deprecated: true`** and optionally **`Link: <path>; rel="successor-version"`** pointing at the **`/api/v1`** equivalent.
|
||||
2. **Deprecation period:** Minimum 6 months before removal (if removal is ever scheduled for a given route).
|
||||
3. **Migration guide:** Prefer [REST API](REST_API.md) and OpenAPI for v1 behavior.
|
||||
4. **Removal:** Deprecated endpoints removed only in coordinated major releases (v1 remains the default integration API).
|
||||
|
||||
## Migration Example
|
||||
|
||||
@@ -114,13 +138,15 @@ Clients specify API version via:
|
||||
|
||||
```
|
||||
app/routes/
|
||||
├── api.py # Legacy API (deprecated)
|
||||
├── api_v1.py # v1 API (current)
|
||||
├── api.py # Session JSON for web UI (/api/*); overlapping routes may be deprecated toward v1
|
||||
├── api_v1.py # v1 REST API (current)
|
||||
└── api/ # Future versioned structure
|
||||
└── v1/
|
||||
└── __init__.py
|
||||
```
|
||||
|
||||
Shared **global search** for `GET /api/search` and `GET /api/v1/search` lives in `app/services/global_search_service.py`. **`X-API-Deprecated`** / **`Link`** headers for overlapping session routes are applied with `app/utils/api_deprecation.py`.
|
||||
|
||||
### Future Structure
|
||||
|
||||
```
|
||||
@@ -169,7 +195,7 @@ def get_api_version():
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** 2025-01-27
|
||||
**Last Updated:** 2026-04-16
|
||||
**Current Version:** v1
|
||||
**Next Version:** v2 (when needed)
|
||||
|
||||
|
||||
+108
-4
@@ -4,6 +4,12 @@
|
||||
|
||||
The TimeTracker REST API provides programmatic access to all time tracking, project management, and reporting features. This API is designed for developers who want to integrate TimeTracker with other tools or build custom applications.
|
||||
|
||||
**Integrations should use `/api/v1` only** (this document). The web application also exposes same-origin session JSON under **`/api/*`** (for example search and timer helpers used by the browser). Those routes are not the stable integration surface; use tokens and `/api/v1` for scripts, mobile, and desktop clients.
|
||||
|
||||
### For maintainers
|
||||
|
||||
Ship new HTTP capabilities under **`/api/v1`** first, with OpenAPI updates in `app/routes/api_docs.py`. Add or change **`/api/*`** only for logged-in UI needs or short-lived shims; reuse services from `app/services/` rather than duplicating logic.
|
||||
|
||||
## Base URL
|
||||
|
||||
```
|
||||
@@ -179,11 +185,14 @@ GET /api/v1/info
|
||||
|
||||
Returns API version and available endpoints. No authentication required.
|
||||
|
||||
`setup_required` is a boolean: when `true`, the installation’s initial web setup is not complete; finish setup in the browser. Desktop and mobile apps use this (and JSON shape) to avoid treating arbitrary HTTP 200 pages as TimeTracker. During that phase, `GET /api/v1/info`, `GET /api/v1/health`, and `POST /api/v1/auth/login` are not redirected to the HTML setup wizard so clients still receive JSON.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"api_version": "v1",
|
||||
"app_version": "1.0.0",
|
||||
"setup_required": false,
|
||||
"documentation_url": "/api/docs",
|
||||
"endpoints": {
|
||||
"projects": "/api/v1/projects",
|
||||
@@ -201,6 +210,92 @@ GET /api/v1/health
|
||||
|
||||
Check if the API is operational. No authentication required.
|
||||
|
||||
### Admin version check (web JSON under `/api`)
|
||||
|
||||
These routes live on the **legacy session JSON blueprint** (same prefix style as `/api/health` in the app). They are **admin-only** (`User.is_admin`, including RBAC admin roles).
|
||||
|
||||
**Authentication:** browser **session cookie** (same-origin `fetch` after login) **or** an **API token** (`Authorization: Bearer tt_…` or `X-API-Key`). No dedicated scope is required; the server checks that the authenticated user is an administrator.
|
||||
|
||||
#### Check installed version against latest GitHub release
|
||||
|
||||
```
|
||||
GET /api/version/check
|
||||
```
|
||||
|
||||
Compares the running instance version to the latest published release on GitHub (see [Version management — admin update notification](../admin/deployment/VERSION_MANAGEMENT.md#admin-github-update-notification) for configuration and caching). Returns `update_available: false` when the current install is not a comparable semantic version (for example some `dev-*` tags), when GitHub cannot be reached and no stale cache exists, or when the user has dismissed this release version.
|
||||
|
||||
**Responses:** `401` if unauthenticated, `403` if not admin.
|
||||
|
||||
**Example (Bearer):**
|
||||
|
||||
```bash
|
||||
curl -s -H "Authorization: Bearer YOUR_API_TOKEN" \
|
||||
https://your-domain.com/api/version/check
|
||||
```
|
||||
|
||||
**Response (200):**
|
||||
|
||||
```json
|
||||
{
|
||||
"update_available": true,
|
||||
"current_version": "4.0.0",
|
||||
"latest_version": "4.1.0",
|
||||
"release_notes": "…",
|
||||
"published_at": "2026-04-01T10:00:00Z",
|
||||
"release_url": "https://github.com/DRYTRIX/TimeTracker/releases/tag/v4.1.0"
|
||||
}
|
||||
```
|
||||
|
||||
#### Dismiss update prompt for a release version
|
||||
|
||||
```
|
||||
POST /api/version/dismiss
|
||||
```
|
||||
|
||||
**Body (JSON):** `{ "latest_version": "4.1.0" }` (with or without a leading `v`; stored normalized).
|
||||
|
||||
Persists `dismissed_release_version` for the current user so `GET /api/version/check` returns `update_available: false` until a newer release appears. Returns `{ "ok": true }` on success, `400` if `latest_version` is missing or not a valid semantic version string.
|
||||
|
||||
The web UI also mirrors dismissal in `localStorage` (`tt_dismissed_release_version`) as a client-side fallback; the database remains authoritative for `update_available`.
|
||||
|
||||
### Dashboard productivity (web JSON under `/api`)
|
||||
|
||||
These routes are used by the **web dashboard** after login. They live on the same legacy JSON blueprint as `/api/health` (not under `/api/v1`). **Authentication:** browser **session cookie** (`@login_required`); unauthenticated requests receive **401**.
|
||||
|
||||
#### Value dashboard aggregates
|
||||
|
||||
```
|
||||
GET /api/stats/value-dashboard
|
||||
```
|
||||
|
||||
Returns productivity aggregates for the **current user** only (completed time entries: `end_time` is set). Used by the main dashboard “Value insights” widget.
|
||||
|
||||
**Caching:** responses may be cached for up to **10 minutes** per user when Redis is available (`REDIS_URL` and working connection). If Redis is unavailable, each request recomputes from the database.
|
||||
|
||||
**Response (200):**
|
||||
|
||||
```json
|
||||
{
|
||||
"total_hours": 132.5,
|
||||
"entries_count": 248,
|
||||
"active_days": 18,
|
||||
"avg_session_length": 1.4,
|
||||
"most_productive_day": "Tuesday",
|
||||
"this_week_hours": 24.5,
|
||||
"this_month_hours": 110.2,
|
||||
"last_7_days": [
|
||||
{ "date": "2026-04-09", "hours": 2.5 },
|
||||
{ "date": "2026-04-10", "hours": 0.0 }
|
||||
],
|
||||
"estimated_value_tracked": 1234.56,
|
||||
"estimated_value_currency": "EUR"
|
||||
}
|
||||
```
|
||||
|
||||
- **`most_productive_day`:** English weekday name (`Sunday`–`Saturday`) with the highest total tracked time across all history, or **`null`** when there is no qualifying data.
|
||||
- **`last_7_days`:** seven objects in chronological order for the last seven **local** calendar days (app timezone), including days with **0** hours.
|
||||
- **`estimated_value_tracked`:** `null` when the estimated billable total is zero or no rate applies; otherwise `hours ×` resolved rate using **`COALESCE(project.hourly_rate, entry client default, project client default)`** (see server implementation in `StatsService`). **`estimated_value_currency`** comes from **Settings → currency** with application default fallback.
|
||||
|
||||
### Search
|
||||
|
||||
#### Global Search
|
||||
@@ -229,7 +324,7 @@ curl -H "Authorization: Bearer YOUR_TOKEN" \
|
||||
"https://your-domain.com/api/v1/search?q=website&types=project,task"
|
||||
```
|
||||
|
||||
**Response:**
|
||||
**Response (200):**
|
||||
```json
|
||||
{
|
||||
"results": [
|
||||
@@ -253,10 +348,19 @@ curl -H "Authorization: Bearer YOUR_TOKEN" \
|
||||
}
|
||||
],
|
||||
"query": "website",
|
||||
"count": 2
|
||||
"count": 2,
|
||||
"partial": false,
|
||||
"errors": {}
|
||||
}
|
||||
```
|
||||
|
||||
**Partial results and per-domain errors**
|
||||
|
||||
Search runs independently for **projects**, **tasks**, **clients**, and **time entries** (see `app/services/global_search_service.py`). If one domain hits a database error (`SQLAlchemyError`), that domain is skipped, the others still return hits, and the response includes:
|
||||
|
||||
- **`partial`:** `true` when any domain failed; otherwise `false`.
|
||||
- **`errors`:** Object whose keys are `projects`, `tasks`, `clients`, or `entries` (only keys for failed domains are present), each mapping to a short error string. Intended for observability and UI messaging, not as a stable API error code.
|
||||
|
||||
**Search Behavior:**
|
||||
- **Projects**: Searches in name and description (active projects only)
|
||||
- **Tasks**: Searches in name and description (tasks from active projects only)
|
||||
@@ -264,11 +368,11 @@ curl -H "Authorization: Bearer YOUR_TOKEN" \
|
||||
- **Time Entries**: Searches in notes and tags (non-admin users see only their own entries)
|
||||
|
||||
**Error Responses:**
|
||||
- `400 Bad Request` - Query is too short (less than 2 characters)
|
||||
- `400 Bad Request` - Query is too short (less than 2 characters). Body includes `error`, `results` (empty array), `partial: false`, and `errors: {}`.
|
||||
- `401 Unauthorized` - Missing or invalid API token
|
||||
- `403 Forbidden` - Token lacks `read:projects` scope
|
||||
|
||||
**Note:** The legacy endpoint `/api/search` is also available for session-based authentication (requires login).
|
||||
**Note:** The legacy endpoint **`GET /api/search`** (session cookie, Flask-Login) uses the same search logic and the same **`results` / `query` / `count` / `partial` / `errors`** shape. For queries shorter than two characters it returns **200** with empty `results` and `partial: false`. Overlapping session routes may return **`X-API-Deprecated: true`** and a **`Link`** header pointing at this v1 path; integrations should call **`GET /api/v1/search`** only.
|
||||
|
||||
### Projects
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ This project and everyone participating in it is governed by our [Code of Conduc
|
||||
|
||||
### Translations (no Git required)
|
||||
|
||||
Contributors who only want to fix wording can use the **Translation improvement** GitHub issue template or follow [CONTRIBUTING_TRANSLATIONS.md](../CONTRIBUTING_TRANSLATIONS.md) (spreadsheet option, maintainer workflow, optional Crowdin using [`crowdin.yml`](../../crowdin.yml) and the **Crowdin sync** workflow). Developers adding new `_('...')` strings should run `pybabel extract` / `update` as described there.
|
||||
Contributors who only want to fix wording can use the **Translation improvement** GitHub issue template, work in **[Crowdin (Drytrix TimeTracker)](https://crowdin.com/project/drytrix-timetracker)**, or follow [CONTRIBUTING_TRANSLATIONS.md](../CONTRIBUTING_TRANSLATIONS.md) (spreadsheet option, maintainer workflow, [`crowdin.yml`](../../crowdin.yml), **Crowdin sync** workflow). Developers adding new `_('...')` strings should run `pybabel extract` / `update` as described there.
|
||||
|
||||
## Development Setup
|
||||
|
||||
@@ -154,6 +154,12 @@ Examples:
|
||||
- Use proper HTTP status codes
|
||||
- Implement proper error handling
|
||||
|
||||
### HTTP APIs (`/api/v1` vs `/api`)
|
||||
|
||||
- **New features for integrations** (mobile, desktop, scripts, webhooks): implement under **`/api/v1`** first, with scopes and updates to OpenAPI in `app/routes/api_docs.py`.
|
||||
- **`/api/*` session JSON** (`app/routes/api.py`): reserve for same-origin **web UI** needs (browser cookie auth). Reuse code from `app/services/` instead of duplicating v1 logic. If you add a session route that mirrors v1, document it and consider **`X-API-Deprecated`** plus a **`Link`** successor header (see `app/utils/api_deprecation.py` and `docs/api/API_VERSIONING.md`).
|
||||
- **Global search** (`GET /api/v1/search` and `GET /api/search`): shared implementation in **`app/services/global_search_service.py`** (`run_global_search`). Change behavior there and keep [REST_API.md](../api/REST_API.md) in sync.
|
||||
|
||||
### Database
|
||||
|
||||
- Use SQLAlchemy ORM for database operations
|
||||
|
||||
@@ -8,7 +8,7 @@ Single-page overview for contributors: architecture, local dev, testing, how to
|
||||
|
||||
- **Stack**: Flask app (server-rendered HTML + REST API), optional SocketIO/scheduler, Docker + Nginx + PostgreSQL.
|
||||
- **Layers**: Routes (`app/routes/`) → Services (`app/services/`) → Repositories (`app/repositories/`) / Models (`app/models/`). API under `/api/v1/`.
|
||||
- **Blueprint registration**: All route blueprints are registered in `app/blueprint_registry.py` (single place).
|
||||
- **Blueprint registration**: All route blueprints are registered in `app/blueprint_registry.py` (single place). Use the main registration list for required modules; optional feature blueprints go in `_register_optional_blueprints`—failures are logged with a full traceback, and the process re-raises in **development** only so optional routes are not silently missing locally.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
@@ -56,7 +56,7 @@ Run the full test suite before opening a PR. Add tests for new behavior (e.g. in
|
||||
## How to add a route
|
||||
|
||||
1. Add or extend a blueprint in `app/routes/` (e.g. new file or existing `api_v1_*.py`).
|
||||
2. **Register** the blueprint in `app/blueprint_registry.py` (import and add to the list passed to `register_blueprints`).
|
||||
2. **Register** the blueprint in `app/blueprint_registry.py` (import and `app.register_blueprint(...)` in `register_all_blueprints`, or add an optional `(module_path, bp_attr)` tuple if the module may be absent in some installs).
|
||||
3. Prefer calling a **service** for business logic; keep the route thin.
|
||||
4. Add tests in `tests/test_routes/` or `tests/test_api_*` as appropriate.
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ TimeTracker/
|
||||
## 🏗️ Core Components
|
||||
|
||||
### Application (`app/`)
|
||||
- **blueprint_registry.py**: Centralized registration of all route blueprints (reduces `__init__.py` size)
|
||||
- **blueprint_registry.py**: Centralized registration of all route blueprints (reduces `__init__.py` size). Optional blueprints log `logger.exception` on failure; in local development (`FLASK_ENV=development` or `DEBUG`) failures re-raise; production and tests continue without that blueprint.
|
||||
- **Models**: Database models for users, projects, time entries, tasks, and settings
|
||||
- **Routes**: API endpoints and web routes (auth, api, api_v1, tasks, admin, etc.)
|
||||
- **Templates**: Jinja2 HTML templates under `app/templates/` (task management, reports, timer, etc.)
|
||||
@@ -171,7 +171,7 @@ The Task Management feature is fully integrated into the application with automa
|
||||
- **Canonical app version**: Defined in `setup.py` (single source of truth). Do not duplicate the version in other docs.
|
||||
- **Desktop**: `desktop/package.json` version should align with the app version when the desktop client ships with that release.
|
||||
- **Frontend build**: Root `package.json` is for Tailwind/build tooling and may use a separate semver (e.g. 1.0.0).
|
||||
- **API docs**: OpenAPI info version in `app/routes/api_docs.py` can match the app version for consistency.
|
||||
- **API docs (OpenAPI)**: `GET /api/openapi.json` sets `info.version` from `get_version_from_setup()` in `app/config/analytics_defaults.py` (reads `setup.py` at runtime). **`TIMETRACKER_VERSION`** or **`APP_VERSION`** may override that for CI or containers; if still unknown, `app/routes/api_docs.py` falls back to Flask `APP_VERSION` config. Do not hardcode a version string in the spec.
|
||||
|
||||
## 🔍 File Purposes
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# Smart in-app notifications
|
||||
|
||||
Session-based reminders to improve daily tracking habits. Separate from **email** “Remind me to log time at end of day” (that flow is unchanged).
|
||||
|
||||
## Enabling for users
|
||||
|
||||
1. Open **Settings → Notifications**.
|
||||
2. Under **In-app reminders (toasts)**, turn on **Enable smart notifications on this device**.
|
||||
3. Choose which kinds to show (no-tracking nudge, long timer, daily summary) and optionally **browser notifications** (requires permission in the browser).
|
||||
|
||||
Optional **HH:MM** overrides apply to the **hour** used for time-window checks (same idea as the email reminder: the app uses the first `SMART_NOTIFY_SCHEDULER_SLOT_MINUTES` of that local hour). If left blank, server defaults from configuration apply.
|
||||
|
||||
## HTTP API (session auth)
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| `GET` | `/api/notifications` | Returns `{ "notifications": [...], "meta": { ... } }` when the feature is enabled for the user; empty list when disabled. |
|
||||
| `POST` | `/api/notifications/dismiss` | JSON body: `{ "kind": "<kind>", "local_date": "YYYY-MM-DD" }`. Omit `local_date` to use the server-derived “today” in the user’s timezone. |
|
||||
|
||||
Stable `kind` values: `no_tracking_today`, `timer_running_long`, `daily_summary`.
|
||||
|
||||
`GET /api/summary/today` uses the same **user-local calendar day** as the notification service (for totals of **completed** entries).
|
||||
|
||||
## Server configuration (environment)
|
||||
|
||||
All optional; defaults are defined on `Config` in [`app/config.py`](../../app/config.py).
|
||||
|
||||
| Variable | Role |
|
||||
|----------|------|
|
||||
| `SMART_NOTIFY_MAX_PER_DAY` | Max notifications returned per request (default 2). |
|
||||
| `SMART_NOTIFY_NO_TRACKING_AFTER` | Default `HH:MM` hour for the no-tracking nudge (default `16:00`). |
|
||||
| `SMART_NOTIFY_SUMMARY_AT` | Default `HH:MM` hour for the daily summary window (default `18:00`). |
|
||||
| `SMART_NOTIFY_LONG_TIMER_HOURS` | Hours after which an active timer triggers the long-timer alert (default `4`). |
|
||||
| `SMART_NOTIFY_SCHEDULER_SLOT_MINUTES` | Length of the firing window at the start of the configured hour (default `30`). |
|
||||
|
||||
## Database
|
||||
|
||||
- Migration **`150_add_smart_notifications`**: new columns on `users`, table `user_smart_notification_dismissals`.
|
||||
|
||||
## Frontend
|
||||
|
||||
[`app/static/smart-notifications.js`](../../app/static/smart-notifications.js) polls `/api/notifications` on an interval and shows results via `toastManager`. Dismissals are sent when the toast closes (including auto-dismiss). [`app/static/toast-notifications.js`](../../app/static/toast-notifications.js) implements the optional `onDismiss` hook on `toastManager.show`.
|
||||
@@ -57,20 +57,21 @@ Both apps use the TimeTracker REST API v1 (`/api/v1/`). See [API Documentation](
|
||||
|
||||
### Authentication
|
||||
|
||||
1. **Mobile app**: Sign in with your web username and password; the app obtains an API token in the background for the same basics access as the web app.
|
||||
2. **Desktop app**: Enter the server URL and either sign in with username/password (if supported) or use an API token from Admin > Security & Access > Api-tokens.
|
||||
3. The app will validate credentials and store the token securely.
|
||||
1. **Mobile app**: Sign in with your web username and password; the server returns an API token (`tt_…`) which is stored securely. Changing the **Server URL** in Settings probes the new host with your saved token before persisting the change.
|
||||
2. **Desktop app**: Use the two-step sign-in wizard (test server, then API token) or **Settings** with an API token from **Admin → Security & Access → API tokens** (no username/password flow in the Electron app).
|
||||
3. Clients validate the server with `GET /api/v1/info` (and respect `setup_required` when the installation is not finished) and validate the token with authenticated API calls.
|
||||
|
||||
### Required API Scopes
|
||||
|
||||
- `read:time_entries` - View time entries
|
||||
- `read:time_entries` - View time entries and timer status (desktop session check fallback; mobile login token includes this)
|
||||
- `write:time_entries` - Create/update time entries and control timer
|
||||
- `read:projects` - View projects
|
||||
- `read:tasks` - View tasks
|
||||
- `read:users` - Optional on desktop tokens; preferred so `GET /api/v1/users/me` can be used for session verification
|
||||
|
||||
### API Endpoints Used
|
||||
|
||||
- `GET /api/v1/info` - API version and health check
|
||||
- `GET /api/v1/info` - API metadata (includes `setup_required` when the server install is not complete); used for discovery without auth
|
||||
- `GET /api/v1/timer/status` - Get active timer status
|
||||
- `POST /api/v1/timer/start` - Start timer
|
||||
- `POST /api/v1/timer/stop` - Stop timer
|
||||
|
||||
@@ -23,7 +23,7 @@ This document describes the privacy-aware, two-layer telemetry system: **base te
|
||||
## Detailed Analytics (Opt-In Only)
|
||||
|
||||
- **Gated by:** `is_telemetry_enabled()` / `allow_analytics`. No product events sent without opt-in.
|
||||
- **Events:** Existing names (e.g. `auth.login`, `timer.started`, `project.created`). Optional prefix `analytics.*` in future.
|
||||
- **Events:** Existing names (e.g. `auth.login`, `timer.started`, `project.created`). Support funnel events use the `support.*` prefix (e.g. `support.modal_opened`); see [all_tracked_events.md](all_tracked_events.md). Optional prefix `analytics.*` in future.
|
||||
- **Properties:** Include `install_id`, app_version, deployment, request context (path, browser, device) only when opted in.
|
||||
- **Sink:** Grafana Cloud OTLP (`identity = user_id` for events).
|
||||
- **Retention:** Per Grafana retention policy. Document in privacy policy.
|
||||
|
||||
@@ -33,6 +33,7 @@ Tests must cover:
|
||||
|
||||
- **Root:** Single `tests/conftest.py` for app, client, DB, users, projects, time entries, auth fixtures, and shared API token/client.
|
||||
- **Grouping:** `test_models/`, `test_routes/`, `test_services/`, `test_utils/`, `test_repositories/`, `test_integration/` for clarity. Root-level `test_*.py` is allowed for legacy or cross-cutting tests.
|
||||
- **API contract:** `tests/test_api_route_contract.py` asserts a curated set of HTTP paths resolve on the Flask `url_map` and that OpenAPI `info.version` matches `get_version_from_setup()` (same rules as production). Extend the curated list when adding stable public endpoints covered by tests.
|
||||
- **Markers:** Use `smoke`, `unit`, `integration`, `api`, `routes`, `models`, `utils`, `security` consistently so CI can run subsets (e.g. `-m "unit and routes"`).
|
||||
|
||||
## Fixtures and factories
|
||||
@@ -62,4 +63,4 @@ These areas are under-covered or hard to test. Add or extend tests when touching
|
||||
- **PDF generation:** Real PDF code paths are partially excluded from coverage; many tests mock PDF. Add targeted tests when changing PDF logic.
|
||||
- **Client lock and workforce:** Timer and some routes depend on client lock; coverage is partial. Add tests when changing lock behavior.
|
||||
- **Scheduled jobs:** Recurring invoice run and report emails run in workers. Unit test service methods; full scheduler E2E can remain out of scope.
|
||||
- **test_api_v1.py duplicate app:** That module uses its own SQLite app. Prefer migrating to conftest app so scope_restricted_user and client_with_token can be reused there.
|
||||
- **test_api_v1.py isolated app:** That module still uses its own per-test SQLite file for isolation, but engine options (e.g. `NullPool`) and a default `Settings` row align with main conftest patterns. Prefer `client_with_token` from conftest for new API tests where the shared app is sufficient.
|
||||
|
||||
@@ -31,6 +31,13 @@ ROUNDING_MINUTES=1
|
||||
SINGLE_ACTIVE_TIMER=true
|
||||
IDLE_TIMEOUT_MINUTES=30
|
||||
|
||||
# Smart in-app notifications (session toasts; see docs/features/SMART_NOTIFICATIONS.md)
|
||||
# SMART_NOTIFY_MAX_PER_DAY=2
|
||||
# SMART_NOTIFY_NO_TRACKING_AFTER=16:00
|
||||
# SMART_NOTIFY_SUMMARY_AT=18:00
|
||||
# SMART_NOTIFY_LONG_TIMER_HOURS=4
|
||||
# SMART_NOTIFY_SCHEDULER_SLOT_MINUTES=30
|
||||
|
||||
# API token rate limits (per token; Redis recommended for multi-worker)
|
||||
# API_TOKEN_RATE_LIMIT_PER_MINUTE=100
|
||||
# API_TOKEN_RATE_LIMIT_PER_HOUR=1000
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Add users.dismissed_release_version for admin GitHub update popup.
|
||||
|
||||
Revision ID: 148_add_user_dismissed_release_version
|
||||
Revises: 147_add_quote_item_line_kind
|
||||
Create Date: 2026-04-15
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect
|
||||
|
||||
revision = "148_add_user_dismissed_release_version"
|
||||
down_revision = "147_add_quote_item_line_kind"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _has_column(inspector, table_name: str, column_name: str) -> bool:
|
||||
try:
|
||||
return column_name in {c["name"] for c in inspector.get_columns(table_name)}
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
if "users" not in inspector.get_table_names():
|
||||
return
|
||||
if not _has_column(inspector, "users", "dismissed_release_version"):
|
||||
op.add_column("users", sa.Column("dismissed_release_version", sa.String(length=64), nullable=True))
|
||||
|
||||
|
||||
def downgrade():
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
if "users" not in inspector.get_table_names():
|
||||
return
|
||||
if _has_column(inspector, "users", "dismissed_release_version"):
|
||||
op.drop_column("users", "dismissed_release_version")
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Add users.support_stats_reports_generated for support modal stats.
|
||||
|
||||
Revision ID: 149_add_user_support_stats_reports_generated
|
||||
Revises: 148_add_user_dismissed_release_version
|
||||
Create Date: 2026-04-15
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect
|
||||
|
||||
revision = "149_add_user_support_stats_reports_generated"
|
||||
down_revision = "148_add_user_dismissed_release_version"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _has_column(inspector, table_name: str, column_name: str) -> bool:
|
||||
try:
|
||||
return column_name in {c["name"] for c in inspector.get_columns(table_name)}
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
if "users" not in inspector.get_table_names():
|
||||
return
|
||||
if not _has_column(inspector, "users", "support_stats_reports_generated"):
|
||||
op.add_column(
|
||||
"users",
|
||||
sa.Column("support_stats_reports_generated", sa.Integer(), nullable=False, server_default="0"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
if "users" not in inspector.get_table_names():
|
||||
return
|
||||
if _has_column(inspector, "users", "support_stats_reports_generated"):
|
||||
op.drop_column("users", "support_stats_reports_generated")
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user