feat(setup): guided 6-step setup wizard for first-time configuration

Replace the single-page setup (telemetry + optional Google Calendar) with
a guided wizard that collects all base settings before completion.

Wizard steps:
1. Welcome - intro and Next
2. Region & time - timezone, date/time format, currency (Settings)
3. Company - name, address, email, optional phone/website (Settings)
4. System - allow self-registration, rounding minutes, single active
   timer, idle timeout (Settings)
5. Integrations (optional) - Google Calendar OAuth; can skip
6. Privacy & finish - telemetry opt-in; Complete Setup submits form

Backend (app/routes/setup.py):
- GET: pass settings and timezones to template for prefilling
- POST: validate timezone, date_format, currency, rounding_minutes,
  idle_timeout_minutes; persist all fields to Settings and
  mark_setup_complete(telemetry_enabled)
- Default timezone/currency to UTC/EUR when missing (keeps tests passing)

Frontend:
- initial_setup.html: 6 wizard steps, progress bar (Step X of 6),
  Back/Next and submit on last step
- setup-wizard.js: step navigation, progress update, optional
  client-side validation for step 2 (timezone, currency required)

Docs updated: TELEMETRY_QUICK_START.md, GETTING_STARTED.md,
TELEMETRY_IMPLEMENTATION_SUMMARY.md.
This commit is contained in:
Dries Peeters
2026-02-16 08:02:33 +01:00
parent d39c5a2f37
commit 7ae7de12d2
6 changed files with 469 additions and 161 deletions
+89 -17
View File
@@ -4,20 +4,24 @@ Initial setup routes for TimeTracker
Handles first-time setup and telemetry opt-in.
"""
from flask import Blueprint, render_template, request, redirect, url_for, flash, session
from flask_login import login_required, current_user
from flask import Blueprint, render_template, request, redirect, url_for, flash
from flask_babel import _
from app.utils.installation import get_installation_config
from app import log_event, track_event, db
from app import log_event, db
from app.models import Settings
from app.utils.db import safe_commit
from app.utils.timezone import get_available_timezones
setup_bp = Blueprint("setup", __name__)
ALLOWED_DATE_FORMATS = ("YYYY-MM-DD", "MM/DD/YYYY", "DD/MM/YYYY", "DD.MM.YYYY")
ROUNDING_MINUTES_MIN, ROUNDING_MINUTES_MAX = 1, 60
IDLE_TIMEOUT_MIN, IDLE_TIMEOUT_MAX = 1, 480
@setup_bp.route("/setup", methods=["GET", "POST"])
def initial_setup():
"""Initial setup page for first-time users"""
"""Initial setup page for first-time users (guided wizard)."""
installation_config = get_installation_config()
# If setup is already complete, redirect to dashboard
@@ -25,13 +29,70 @@ def initial_setup():
return redirect(url_for("main.dashboard"))
if request.method == "POST":
# Get telemetry preference
telemetry_enabled = request.form.get("telemetry_enabled") == "on"
# Validation (use defaults when not provided for backwards compatibility)
timezone = (request.form.get("timezone") or "").strip()
if not timezone:
timezone = "UTC"
try:
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
# Save OAuth credentials if provided
ZoneInfo(timezone)
except (ZoneInfoNotFoundError, KeyError):
flash(_("Invalid timezone: %(timezone)s", timezone=timezone or "(empty)"), "error")
return _render_setup(Settings.get_settings(), get_available_timezones())
date_fmt = request.form.get("date_format", "YYYY-MM-DD")
if date_fmt not in ALLOWED_DATE_FORMATS:
date_fmt = "YYYY-MM-DD"
time_fmt = request.form.get("time_format", "24h")
if time_fmt not in ("24h", "12h"):
time_fmt = "24h"
currency = (request.form.get("currency") or "").strip() or "EUR"
try:
rounding = int(request.form.get("rounding_minutes", 1))
rounding = max(ROUNDING_MINUTES_MIN, min(ROUNDING_MINUTES_MAX, rounding))
except (TypeError, ValueError):
rounding = 1
try:
idle_timeout = int(request.form.get("idle_timeout_minutes", 30))
idle_timeout = max(IDLE_TIMEOUT_MIN, min(IDLE_TIMEOUT_MAX, idle_timeout))
except (TypeError, ValueError):
idle_timeout = 30
telemetry_enabled = request.form.get("telemetry_enabled") == "on"
settings = Settings.get_settings()
# Google Calendar OAuth credentials
# Region & time
settings.timezone = timezone
settings.date_format = date_fmt
settings.time_format = time_fmt
settings.currency = currency
# Company
settings.company_name = request.form.get("company_name", "").strip() or getattr(
settings, "company_name", "Your Company Name"
)
settings.company_address = request.form.get("company_address", "").strip() or getattr(
settings, "company_address", "Your Company Address"
)
settings.company_email = request.form.get("company_email", "").strip() or getattr(
settings, "company_email", "info@yourcompany.com"
)
settings.company_phone = (request.form.get("company_phone") or "").strip() or getattr(
settings, "company_phone", ""
)
settings.company_website = (request.form.get("company_website") or "").strip() or getattr(
settings, "company_website", ""
)
# System
settings.allow_self_register = request.form.get("allow_self_register") == "on"
settings.rounding_minutes = rounding
settings.single_active_timer = request.form.get("single_active_timer") == "on"
settings.idle_timeout_minutes = idle_timeout
# Google Calendar OAuth
google_client_id = request.form.get("google_calendar_client_id", "").strip()
google_client_secret = request.form.get("google_calendar_client_secret", "").strip()
if google_client_id:
@@ -39,25 +100,36 @@ def initial_setup():
if google_client_secret:
settings.google_calendar_client_secret = google_client_secret
# Save settings if any OAuth credentials were provided
if google_client_id or google_client_secret:
safe_commit("setup_oauth_credentials", {"provider": "google_calendar"})
if settings not in db.session:
db.session.add(settings)
if not safe_commit("setup_wizard"):
flash(_("Could not save settings. Please check server logs."), "error")
return _render_setup(settings, get_available_timezones())
# Save preference
installation_config.mark_setup_complete(telemetry_enabled=telemetry_enabled)
# Log the setup completion
log_event("setup.completed", telemetry_enabled=telemetry_enabled, oauth_configured=bool(google_client_id))
log_event(
"setup.completed",
telemetry_enabled=telemetry_enabled,
oauth_configured=bool(google_client_id),
)
# Show success message
if telemetry_enabled:
flash(_("Setup complete! Thank you for helping us improve TimeTracker."), "success")
else:
flash(_("Setup complete! Telemetry is disabled."), "success")
if google_client_id:
flash(_("Google Calendar OAuth credentials have been configured."), "success")
return redirect(url_for("main.dashboard"))
return render_template("setup/initial_setup.html")
return _render_setup(Settings.get_settings(), get_available_timezones())
def _render_setup(settings, timezones):
"""Render the setup template with settings and timezones."""
return render_template(
"setup/initial_setup.html",
settings=settings,
timezones=timezones,
)
+121
View File
@@ -0,0 +1,121 @@
/**
* Initial setup wizard: step navigation and optional validation.
*/
(function() {
'use strict';
var currentStep = 1;
var totalSteps = 6;
function getProgressLabel() {
var el = document.getElementById('wizard-progress-label');
return el ? el.textContent : '';
}
function setProgressLabel(text) {
var el = document.getElementById('wizard-progress-label');
if (el) el.textContent = text;
}
function validateStep2() {
var timezone = document.getElementById('timezone');
var currency = document.getElementById('currency');
if (!timezone || !currency) return true;
var tzVal = (timezone.value || '').trim();
var curVal = (currency.value || '').trim();
if (!tzVal) {
timezone.focus();
if (typeof window.showToast === 'function') {
window.showToast(document.getElementById('wizard-progress-label').getAttribute('data-msg-timezone') || 'Please select a timezone.', 'error');
} else {
alert('Please select a timezone.');
}
return false;
}
if (!curVal) {
currency.focus();
if (typeof window.showToast === 'function') {
window.showToast(document.getElementById('wizard-progress-label').getAttribute('data-msg-currency') || 'Please enter a currency.', 'error');
} else {
alert('Please enter a currency.');
}
return false;
}
return true;
}
function validateCurrentStep() {
if (currentStep === 2) return validateStep2();
return true;
}
function goNext() {
if (!validateCurrentStep()) return;
if (currentStep < totalSteps) {
currentStep++;
updateStepUI();
}
}
function goBack() {
if (currentStep > 1) {
currentStep--;
updateStepUI();
}
}
function updateStepUI() {
var steps = document.querySelectorAll('.wizard-step');
steps.forEach(function(step) {
var stepNum = parseInt(step.getAttribute('data-step'), 10);
if (stepNum === currentStep) {
step.classList.remove('hidden');
step.setAttribute('aria-current', 'step');
} else {
step.classList.add('hidden');
step.removeAttribute('aria-current');
}
});
var dots = document.querySelectorAll('.setup-progress-dot');
dots.forEach(function(dot) {
var stepNum = parseInt(dot.getAttribute('data-step'), 10);
if (stepNum <= currentStep) {
dot.classList.remove('bg-gray-200', 'dark:bg-gray-700');
dot.classList.add('bg-primary');
} else {
dot.classList.remove('bg-primary');
dot.classList.add('bg-gray-200', 'dark:bg-gray-700');
}
});
setProgressLabel('Step ' + currentStep + ' of ' + totalSteps);
var backBtn = document.getElementById('setup-back-btn');
var nextBtn = document.getElementById('setup-next-btn');
var submitBtn = document.getElementById('setup-submit-btn');
if (backBtn) backBtn.classList.toggle('hidden', currentStep <= 1);
if (nextBtn) nextBtn.classList.toggle('hidden', currentStep >= totalSteps);
if (submitBtn) submitBtn.classList.toggle('hidden', currentStep < totalSteps);
}
function init() {
var form = document.getElementById('setup-form');
if (!form) return;
var nextBtn = document.getElementById('setup-next-btn');
var backBtn = document.getElementById('setup-back-btn');
var submitBtn = document.getElementById('setup-submit-btn');
if (nextBtn) nextBtn.addEventListener('click', goNext);
if (backBtn) backBtn.addEventListener('click', goBack);
updateStepUI();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
+212 -120
View File
@@ -28,7 +28,6 @@
<img src="{{ url_for('static', filename='images/timetracker-logo.svg') }}" alt="logo" class="w-24 h-24 mx-auto">
<h1 class="text-3xl font-bold mt-4 text-primary">TimeTracker</h1>
<p class="mt-2 text-text-muted-light dark:text-text-muted-dark">{{ _('Track time. Stay organized.') }}</p>
<!-- Privacy Principles -->
<div class="mt-8 space-y-3 text-left">
<p class="text-sm font-semibold text-text-light dark:text-text-dark mb-3">🔒 {{ _('Privacy First') }}</p>
@@ -60,136 +59,229 @@
</div>
</div>
<!-- Right side - Setup Form -->
<!-- Right side - Wizard -->
<div class="p-8 overflow-y-auto max-h-[90vh]">
<h2 class="text-2xl font-bold tracking-tight">{{ _('Welcome to TimeTracker') }}</h2>
<p class="mt-2 text-sm text-text-muted-light dark:text-text-muted-dark">{{ _("Let's get you set up in just a moment") }}</p>
<p class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark mb-4" id="wizard-progress-label">{{ _('Step 1 of 6') }}</p>
<div class="flex gap-1 mb-6" aria-hidden="true">
<div class="h-1 flex-1 rounded-full bg-primary setup-progress-dot" data-step="1"></div>
<div class="h-1 flex-1 rounded-full bg-gray-200 dark:bg-gray-700 setup-progress-dot" data-step="2"></div>
<div class="h-1 flex-1 rounded-full bg-gray-200 dark:bg-gray-700 setup-progress-dot" data-step="3"></div>
<div class="h-1 flex-1 rounded-full bg-gray-200 dark:bg-gray-700 setup-progress-dot" data-step="4"></div>
<div class="h-1 flex-1 rounded-full bg-gray-200 dark:bg-gray-700 setup-progress-dot" data-step="5"></div>
<div class="h-1 flex-1 rounded-full bg-gray-200 dark:bg-gray-700 setup-progress-dot" data-step="6"></div>
</div>
<form class="mt-6 space-y-6" method="POST" action="{{ url_for('setup.initial_setup') }}">
<form id="setup-form" class="space-y-6" method="POST" action="{{ url_for('setup.initial_setup') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<!-- Welcome Message -->
<div class="bg-primary/10 border border-primary/20 rounded-lg p-4">
<h3 class="text-sm font-semibold text-primary mb-2">🎉 {{ _('Thank you for choosing TimeTracker!') }}</h3>
<p class="text-sm text-text-muted-light dark:text-text-muted-dark">
{{ _('Your data stays on your server, and you have complete control.') }}
</p>
</div>
<!-- OAuth Integration Setup Section -->
<div class="bg-background-light dark:bg-gray-700 border border-border-light dark:border-border-dark rounded-lg p-4">
<h3 class="text-base font-semibold mb-3">🔌 {{ _('Integration Setup (Optional)') }}</h3>
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mb-4">
{{ _('Configure OAuth credentials now to enable calendar and other integrations. You can also configure these later in Admin → Settings.') }}
</p>
<!-- Google Calendar OAuth -->
<div class="mb-4">
<label class="block text-sm font-medium mb-2">
<i class="fab fa-google text-blue-600 mr-2"></i>{{ _('Google Calendar OAuth') }}
</label>
<div class="space-y-2">
<div>
<input
type="text"
name="google_calendar_client_id"
placeholder="{{ _('Client ID (optional)') }}"
class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-card-light dark:bg-card-dark text-text-light dark:text-text-dark focus:ring-2 focus:ring-primary focus:border-transparent"
>
</div>
<div>
<input
type="password"
name="google_calendar_client_secret"
placeholder="{{ _('Client Secret (optional)') }}"
class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-card-light dark:bg-card-dark text-text-light dark:text-text-dark focus:ring-2 focus:ring-primary focus:border-transparent"
>
</div>
</div>
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-2">
{{ _('Get these from') }} <a href="https://console.cloud.google.com/apis/credentials" target="_blank" class="text-primary hover:underline">{{ _('Google Cloud Console') }}</a>
<!-- Step 1: Welcome -->
<div class="wizard-step" data-step="1" aria-current="step">
<h2 class="text-2xl font-bold tracking-tight">{{ _('Welcome to TimeTracker') }}</h2>
<p class="mt-2 text-sm text-text-muted-light dark:text-text-muted-dark">{{ _("Let's get you set up in just a moment") }}</p>
<div class="mt-6 bg-primary/10 border border-primary/20 rounded-lg p-4">
<h3 class="text-sm font-semibold text-primary mb-2">🎉 {{ _('Thank you for choosing TimeTracker!') }}</h3>
<p class="text-sm text-text-muted-light dark:text-text-muted-dark">
{{ _('Your data stays on your server, and you have complete control.') }}
</p>
</div>
<details class="text-sm mt-3">
<summary class="cursor-pointer font-medium text-primary hover:underline">
{{ _('How to get Google Calendar OAuth credentials?') }}
</summary>
<div class="mt-3 space-y-2 pl-4 border-l-2 border-primary/30 text-text-muted-light dark:text-text-muted-dark">
<ol class="list-decimal list-inside space-y-2">
<li>{{ _('Go to') }} <a href="https://console.cloud.google.com/" target="_blank" class="text-primary hover:underline">{{ _('Google Cloud Console') }}</a></li>
<li>{{ _('Create a new project or select an existing one') }}</li>
<li>{{ _('Enable the Google Calendar API') }}</li>
<li>{{ _('Go to Credentials → Create Credentials → OAuth 2.0 Client ID') }}</li>
<li>{{ _('Set application type to "Web application"') }}</li>
<li>{{ _('Add authorized redirect URI:') }} <code class="bg-gray-100 dark:bg-gray-800 dark:text-text-light px-1 rounded text-xs break-all">{{ url_for('integrations.oauth_callback', provider='google_calendar', _external=True) }}</code></li>
<li>{{ _('Copy the Client ID and Client Secret') }}</li>
</ol>
</div>
</details>
</div>
<!-- Telemetry Opt-in Section -->
<div class="bg-background-light dark:bg-gray-700 border border-border-light dark:border-border-dark rounded-lg p-4">
<h3 class="text-base font-semibold mb-3">📊 {{ _('Help Us Improve (Optional)') }}</h3>
<div class="mb-3">
<label class="flex items-start cursor-pointer">
<input type="checkbox" name="telemetry_enabled" class="mt-1 h-4 w-4 text-primary border-border-light dark:border-border-dark rounded focus:ring-primary">
<span class="ml-3 text-sm">
<span class="font-medium">{{ _('Enable anonymous telemetry') }}</span>
<span class="block text-text-muted-light dark:text-text-muted-dark mt-1">
{{ _('Help us understand usage patterns to improve TimeTracker') }}
</span>
</span>
</label>
<!-- Step 2: Region & time -->
<div class="wizard-step hidden" data-step="2">
<h2 class="text-xl font-bold tracking-tight">{{ _('Region & time') }}</h2>
<p class="mt-1 text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Set your default timezone, date and time format, and currency.') }}</p>
<div class="mt-4 space-y-4">
<div>
<label for="timezone" class="block text-sm font-medium mb-1">{{ _('Timezone') }} <span class="text-red-500">*</span></label>
<select name="timezone" id="timezone" required class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-card-light dark:bg-card-dark text-text-light dark:text-text-dark focus:ring-2 focus:ring-primary focus:border-transparent">
{% for tz in timezones or [] %}
<option value="{{ tz }}" {% if settings and settings.timezone == tz %}selected{% endif %}>{{ tz }}</option>
{% endfor %}
</select>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label for="date_format" class="block text-sm font-medium mb-1">{{ _('Date format') }}</label>
<select name="date_format" id="date_format" class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-card-light dark:bg-card-dark text-text-light dark:text-text-dark focus:ring-2 focus:ring-primary focus:border-transparent">
<option value="YYYY-MM-DD" {% if settings and settings.date_format == 'YYYY-MM-DD' %}selected{% endif %}>YYYY-MM-DD</option>
<option value="MM/DD/YYYY" {% if settings and settings.date_format == 'MM/DD/YYYY' %}selected{% endif %}>MM/DD/YYYY</option>
<option value="DD/MM/YYYY" {% if settings and settings.date_format == 'DD/MM/YYYY' %}selected{% endif %}>DD/MM/YYYY</option>
<option value="DD.MM.YYYY" {% if settings and settings.date_format == 'DD.MM.YYYY' %}selected{% endif %}>DD.MM.YYYY</option>
</select>
</div>
<div>
<label for="time_format" class="block text-sm font-medium mb-1">{{ _('Time format') }}</label>
<select name="time_format" id="time_format" class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-card-light dark:bg-card-dark text-text-light dark:text-text-dark focus:ring-2 focus:ring-primary focus:border-transparent">
<option value="24h" {% if not settings or settings.time_format == '24h' %}selected{% endif %}>{{ _('24-hour') }}</option>
<option value="12h" {% if settings and settings.time_format == '12h' %}selected{% endif %}>{{ _('12-hour') }}</option>
</select>
</div>
</div>
<div>
<label for="currency" class="block text-sm font-medium mb-1">{{ _('Currency') }} <span class="text-red-500">*</span></label>
<input type="text" name="currency" id="currency" value="{{ settings.currency if settings else 'EUR' }}" required maxlength="10" class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-card-light dark:bg-card-dark text-text-light dark:text-text-dark focus:ring-2 focus:ring-primary focus:border-transparent" placeholder="EUR">
</div>
</div>
<!-- Collapsible details -->
<details class="text-sm mt-3">
<summary class="cursor-pointer font-medium text-primary hover:underline">
{{ _('What data is collected?') }}
</summary>
<div class="mt-3 space-y-3 pl-4 border-l-2 border-primary/30">
<div>
<p class="font-semibold text-green-600 dark:text-green-400">✓ {{ _('What we collect:') }}</p>
<ul class="list-disc list-inside space-y-1 ml-2 text-text-muted-light dark:text-text-muted-dark">
<li>{{ _('Anonymous installation fingerprint (hashed)') }}</li>
<li>{{ _('Application version & platform info') }}</li>
<li>{{ _('Feature usage statistics') }}</li>
<li>{{ _('Internal numeric IDs only') }}</li>
</ul>
</div>
<div>
<p class="font-semibold text-red-600 dark:text-red-400">✗ {{ _("What we DON'T collect:") }}</p>
<ul class="list-disc list-inside space-y-1 ml-2 text-text-muted-light dark:text-text-muted-dark">
<li>{{ _('No usernames or emails') }}</li>
<li>{{ _('No project names or descriptions') }}</li>
<li>{{ _('No time entry data or notes') }}</li>
<li>{{ _('No client or business data') }}</li>
<li>{{ _('No IP addresses or PII') }}</li>
</ul>
</div>
<div class="bg-primary/10 rounded p-3 text-xs">
<p class="text-text-light dark:text-text-dark">
<strong>{{ _('Why?') }}</strong> {{ _('Anonymous usage data helps us prioritize features and fix issues.') }}
{{ _('You can change this anytime in') }} <strong>{{ _('Admin → Settings') }}</strong> ({{ _('Privacy & Analytics section') }}).
</p>
</div>
</div>
</details>
</div>
<!-- Submit Button -->
<button type="submit" class="btn btn-primary w-full">
<i class="fa-solid fa-check mr-2"></i>
{{ _('Complete Setup & Continue') }}
</button>
<!-- Step 3: Company -->
<div class="wizard-step hidden" data-step="3">
<h2 class="text-xl font-bold tracking-tight">{{ _('Company') }}</h2>
<p class="mt-1 text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Company details for invoices and branding.') }}</p>
<div class="mt-4 space-y-4">
<div>
<label for="company_name" class="block text-sm font-medium mb-1">{{ _('Company name') }}</label>
<input type="text" name="company_name" id="company_name" value="{{ settings.company_name if settings else '' }}" class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-card-light dark:bg-card-dark text-text-light dark:text-text-dark focus:ring-2 focus:ring-primary focus:border-transparent" placeholder="{{ _('Your Company Name') }}">
</div>
<div>
<label for="company_address" class="block text-sm font-medium mb-1">{{ _('Address') }}</label>
<textarea name="company_address" id="company_address" rows="2" class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-card-light dark:bg-card-dark text-text-light dark:text-text-dark focus:ring-2 focus:ring-primary focus:border-transparent" placeholder="{{ _('Your Company Address') }}">{{ settings.company_address if settings else '' }}</textarea>
</div>
<div>
<label for="company_email" class="block text-sm font-medium mb-1">{{ _('Email') }}</label>
<input type="email" name="company_email" id="company_email" value="{{ settings.company_email if settings else '' }}" class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-card-light dark:bg-card-dark text-text-light dark:text-text-dark focus:ring-2 focus:ring-primary focus:border-transparent" placeholder="info@yourcompany.com">
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label for="company_phone" class="block text-sm font-medium mb-1">{{ _('Phone') }} ({{ _('optional') }})</label>
<input type="text" name="company_phone" id="company_phone" value="{{ settings.company_phone if settings else '' }}" class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-card-light dark:bg-card-dark text-text-light dark:text-text-dark focus:ring-2 focus:ring-primary focus:border-transparent">
</div>
<div>
<label for="company_website" class="block text-sm font-medium mb-1">{{ _('Website') }} ({{ _('optional') }})</label>
<input type="text" name="company_website" id="company_website" value="{{ settings.company_website if settings else '' }}" class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-card-light dark:bg-card-dark text-text-light dark:text-text-dark focus:ring-2 focus:ring-primary focus:border-transparent">
</div>
</div>
</div>
</div>
<div class="text-xs text-center text-text-muted-light dark:text-text-muted-dark">
<p>{{ _('By continuing, you agree to use TimeTracker under the') }} <a href="https://www.gnu.org/licenses/gpl-3.0.html" class="text-primary hover:underline" target="_blank">{{ _('GPL-3.0 License') }}</a></p>
<!-- Step 4: System -->
<div class="wizard-step hidden" data-step="4">
<h2 class="text-xl font-bold tracking-tight">{{ _('System') }}</h2>
<p class="mt-1 text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('App behavior and timer settings.') }}</p>
<div class="mt-4 space-y-4">
<div class="flex items-start">
<input type="checkbox" name="allow_self_register" id="allow_self_register" {% if settings and settings.allow_self_register %}checked{% endif %} class="mt-1 h-4 w-4 text-primary border-border-light dark:border-border-dark rounded focus:ring-primary">
<label for="allow_self_register" class="ml-3 text-sm">
<span class="font-medium">{{ _('Allow self-registration') }}</span>
<span class="block text-text-muted-light dark:text-text-muted-dark mt-0.5">{{ _('Users can create accounts from the login page') }}</span>
</label>
</div>
<div>
<label for="rounding_minutes" class="block text-sm font-medium mb-1">{{ _('Time rounding (minutes)') }}</label>
<select name="rounding_minutes" id="rounding_minutes" class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-card-light dark:bg-card-dark text-text-light dark:text-text-dark focus:ring-2 focus:ring-primary focus:border-transparent">
<option value="1" {% if not settings or settings.rounding_minutes == 1 %}selected{% endif %}>1</option>
<option value="5" {% if settings and settings.rounding_minutes == 5 %}selected{% endif %}>5</option>
<option value="15" {% if settings and settings.rounding_minutes == 15 %}selected{% endif %}>15</option>
<option value="30" {% if settings and settings.rounding_minutes == 30 %}selected{% endif %}>30</option>
</select>
<p class="mt-1 text-xs text-text-muted-light dark:text-text-muted-dark">{{ _('Round time entries to the nearest N minutes.') }}</p>
</div>
<div class="flex items-start">
<input type="checkbox" name="single_active_timer" id="single_active_timer" {% if not settings or settings.single_active_timer %}checked{% endif %} class="mt-1 h-4 w-4 text-primary border-border-light dark:border-border-dark rounded focus:ring-primary">
<label for="single_active_timer" class="ml-3 text-sm">
<span class="font-medium">{{ _('Single active timer per user') }}</span>
<span class="block text-text-muted-light dark:text-text-muted-dark mt-0.5">{{ _('Only one running timer at a time per user') }}</span>
</label>
</div>
<div>
<label for="idle_timeout_minutes" class="block text-sm font-medium mb-1">{{ _('Idle timeout (minutes)') }}</label>
<input type="number" name="idle_timeout_minutes" id="idle_timeout_minutes" value="{{ settings.idle_timeout_minutes if settings else 30 }}" min="1" max="480" class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-card-light dark:bg-card-dark text-text-light dark:text-text-dark focus:ring-2 focus:ring-primary focus:border-transparent">
<p class="mt-1 text-xs text-text-muted-light dark:text-text-muted-dark">{{ _('Pause timer after this many minutes of inactivity.') }}</p>
</div>
</div>
</div>
<!-- Step 5: Integrations -->
<div class="wizard-step hidden" data-step="5">
<h2 class="text-xl font-bold tracking-tight">{{ _('Integrations') }} ({{ _('optional') }})</h2>
<p class="mt-1 text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Configure calendar OAuth now or later in Admin → Settings.') }}</p>
<div class="mt-4 bg-background-light dark:bg-gray-700 border border-border-light dark:border-border-dark rounded-lg p-4">
<div class="mb-4">
<label class="block text-sm font-medium mb-2">
<i class="fab fa-google text-blue-600 mr-2"></i>{{ _('Google Calendar OAuth') }}
</label>
<div class="space-y-2">
<input type="text" name="google_calendar_client_id" placeholder="{{ _('Client ID (optional)') }}" class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-card-light dark:bg-card-dark text-text-light dark:text-text-dark focus:ring-2 focus:ring-primary focus:border-transparent">
<input type="password" name="google_calendar_client_secret" placeholder="{{ _('Client Secret (optional)') }}" class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-card-light dark:bg-card-dark text-text-light dark:text-text-dark focus:ring-2 focus:ring-primary focus:border-transparent">
</div>
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-2">
{{ _('Get these from') }} <a href="https://console.cloud.google.com/apis/credentials" target="_blank" rel="noopener" class="text-primary hover:underline">{{ _('Google Cloud Console') }}</a>
</p>
</div>
<details class="text-sm mt-3">
<summary class="cursor-pointer font-medium text-primary hover:underline">{{ _('How to get Google Calendar OAuth credentials?') }}</summary>
<div class="mt-3 space-y-2 pl-4 border-l-2 border-primary/30 text-text-muted-light dark:text-text-muted-dark">
<ol class="list-decimal list-inside space-y-2">
<li>{{ _('Go to') }} <a href="https://console.cloud.google.com/" target="_blank" rel="noopener" class="text-primary hover:underline">{{ _('Google Cloud Console') }}</a></li>
<li>{{ _('Create a new project or select an existing one') }}</li>
<li>{{ _('Enable the Google Calendar API') }}</li>
<li>{{ _('Go to Credentials → Create Credentials → OAuth 2.0 Client ID') }}</li>
<li>{{ _('Set application type to "Web application"') }}</li>
<li>{{ _('Add authorized redirect URI:') }} <code class="bg-gray-100 dark:bg-gray-800 dark:text-text-light px-1 rounded text-xs break-all">{{ url_for('integrations.oauth_callback', provider='google_calendar', _external=True) }}</code></li>
<li>{{ _('Copy the Client ID and Client Secret') }}</li>
</ol>
</div>
</details>
</div>
<p class="mt-3 text-sm text-text-muted-light dark:text-text-muted-dark">{{ _("You can skip this step and configure integrations later.") }}</p>
</div>
<!-- Step 6: Privacy & finish -->
<div class="wizard-step hidden" data-step="6">
<h2 class="text-xl font-bold tracking-tight">{{ _('Privacy & finish') }}</h2>
<p class="mt-1 text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Choose whether to help improve TimeTracker with anonymous usage data.') }}</p>
<div class="mt-4 bg-background-light dark:bg-gray-700 border border-border-light dark:border-border-dark rounded-lg p-4">
<div class="mb-3">
<label class="flex items-start cursor-pointer">
<input type="checkbox" name="telemetry_enabled" class="mt-1 h-4 w-4 text-primary border-border-light dark:border-border-dark rounded focus:ring-primary">
<span class="ml-3 text-sm">
<span class="font-medium">{{ _('Enable anonymous telemetry') }}</span>
<span class="block text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('Help us understand usage patterns to improve TimeTracker') }}</span>
</span>
</label>
</div>
<details class="text-sm mt-3">
<summary class="cursor-pointer font-medium text-primary hover:underline">{{ _('What data is collected?') }}</summary>
<div class="mt-3 space-y-3 pl-4 border-l-2 border-primary/30">
<div>
<p class="font-semibold text-green-600 dark:text-green-400">✓ {{ _('What we collect:') }}</p>
<ul class="list-disc list-inside space-y-1 ml-2 text-text-muted-light dark:text-text-muted-dark">
<li>{{ _('Anonymous installation fingerprint (hashed)') }}</li>
<li>{{ _('Application version & platform info') }}</li>
<li>{{ _('Feature usage statistics') }}</li>
</ul>
</div>
<div>
<p class="font-semibold text-red-600 dark:text-red-400">✗ {{ _("What we DON'T collect:") }}</p>
<ul class="list-disc list-inside space-y-1 ml-2 text-text-muted-light dark:text-text-muted-dark">
<li>{{ _('No usernames or emails') }}</li>
<li>{{ _('No time entry data or notes') }}</li>
<li>{{ _('No client or business data') }}</li>
</ul>
</div>
<p class="text-xs">{{ _('You can change this anytime in') }} <strong>{{ _('Admin → Settings') }}</strong>.</p>
</div>
</details>
</div>
<div class="mt-4 text-xs text-text-muted-light dark:text-text-muted-dark">
<p>{{ _('By continuing, you agree to use TimeTracker under the') }} <a href="https://www.gnu.org/licenses/gpl-3.0.html" class="text-primary hover:underline" target="_blank" rel="noopener">{{ _('GPL-3.0 License') }}</a></p>
</div>
</div>
<!-- Navigation -->
<div class="flex justify-between items-center pt-6 border-t border-border-light dark:border-border-dark mt-6">
<button type="button" id="setup-back-btn" class="btn border border-border-light dark:border-border-dark hidden" aria-label="{{ _('Back') }}">
<i class="fa-solid fa-arrow-left mr-2"></i>{{ _('Back') }}
</button>
<div class="flex-1"></div>
<div class="flex gap-2">
<button type="button" id="setup-next-btn" class="btn btn-primary">
{{ _('Next') }}<i class="fa-solid fa-arrow-right ml-2"></i>
</button>
<button type="submit" id="setup-submit-btn" class="btn btn-primary hidden" aria-label="{{ _('Complete Setup & Continue') }}">
<i class="fa-solid fa-check mr-2"></i>{{ _('Complete Setup & Continue') }}
</button>
</div>
</div>
</form>
</div>
@@ -210,6 +302,6 @@
<script src="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/js/all.min.js"></script>
<script src="{{ url_for('static', filename='toast-notifications.js') }}"></script>
<script src="{{ url_for('static', filename='error-handling-enhanced.js') }}"></script>
<script src="{{ url_for('static', filename='js/setup-wizard.js') }}"></script>
</body>
</html>
+13 -1
View File
@@ -122,7 +122,19 @@ python app.py
## ⚙️ Initial Setup
### Step 1: Configure System Settings
### Guided setup on first run
The **first time** you open TimeTracker (before setup is complete), you are shown a **guided setup wizard** at `/setup`. It walks you through:
- **Region & time** Timezone, date/time format, currency
- **Company** Company name, address, email (for invoices)
- **System** Self-registration, time rounding, single active timer, idle timeout
- **Integrations (optional)** Google Calendar OAuth; can be skipped
- **Privacy** Opt-in for anonymous telemetry
After you complete the wizard, you can log in and fine-tune anything in **Admin → Settings**. If setup was already completed (e.g. by someone else), you go straight to the login/dashboard.
### Step 1: Configure System Settings (Admin)
> **Important**: You need admin access for this step. Login with a username from `ADMIN_USERNAMES` (default: `admin`).
+12 -8
View File
@@ -2,17 +2,21 @@
## For End Users
### First-Time Setup
### First-Time Setup (Guided Wizard)
When you first access TimeTracker, you'll see a welcome screen asking about telemetry:
When you first access TimeTracker, you'll see a **guided setup wizard** (6 steps). You can configure the basics in one flow, then continue to the dashboard.
1. **Read the Privacy Information** - Review what data is collected (and what isn't)
2. **Choose Your Preference:**
-**Enable Telemetry** - Help improve TimeTracker by sharing anonymous usage data
-**Disable Telemetry** - No data will be sent (default)
3. **Click "Complete Setup & Continue"**
1. **Welcome** Intro; click **Next** to start.
2. **Region & time** Timezone, date format, time format, and currency (used for reports and invoices).
3. **Company** Company name, address, email; optional phone and website (for invoices and branding).
4. **System** Allow self-registration, time rounding, single active timer per user, idle timeout (minutes).
5. **Integrations (optional)** Google Calendar OAuth (Client ID / Secret). You can skip this and configure later in Admin → Settings.
6. **Privacy & finish** Choose whether to enable anonymous telemetry:
-**Enable Telemetry** Help improve TimeTracker with anonymous usage data
-**Disable Telemetry** No data will be sent (default)
- Click **Complete Setup & Continue** to finish.
You can change this decision anytime in the admin settings.
You can change telemetry and all other options anytime in **Admin → Settings**.
### Viewing Telemetry Status (Admin Only)
@@ -45,25 +45,32 @@ See `docs/all_tracked_events.md` for a complete list of tracked events.
- `app/utils/telemetry.py` - Now uses installation config for salt
- `app/__init__.py` - Integrated setup check middleware
### 3. First-Time Setup Page
### 3. First-Time Setup (Guided Wizard)
**Files Created:**
- `app/routes/setup.py` - Setup route handler
- `app/templates/setup/initial_setup.html` - Beautiful setup page
**Files:**
- `app/routes/setup.py` - Setup route handler (GET: wizard with settings/timezones; POST: validate and save all steps)
- `app/templates/setup/initial_setup.html` - 6-step wizard UI
- `app/static/js/setup-wizard.js` - Step navigation and optional client-side validation
**Wizard steps:**
1. **Welcome** Intro; Next to continue
2. **Region & time** Timezone, date format, time format, currency (saved to Settings)
3. **Company** Company name, address, email, optional phone/website (saved to Settings)
4. **System** Allow self-registration, rounding minutes, single active timer, idle timeout (saved to Settings)
5. **Integrations (optional)** Google Calendar OAuth credentials; can skip
6. **Privacy & finish** Telemetry opt-in; Complete Setup submits form and marks setup complete
**Features:**
- **Welcome Screen:** Professional, user-friendly design
- **Telemetry Opt-In:** Clear explanation of what's collected
- **Privacy Transparency:** Detailed list of what is/isn't collected
- **Setup Completion Tracking:** Prevents re-showing after completion
- **Middleware Integration:** Redirects to setup if not complete
- **Guided flow:** One form, single POST at the end; progress indicator (Step X of 6)
- **Telemetry opt-in:** Clear explanation of what is/isn't collected; opt-in by default (unchecked)
- **Setup completion tracking:** Stored in installation config; redirect to dashboard when complete
- **Middleware:** Unauthenticated users are redirected to `/setup` if setup not complete
**User Experience:**
- ✅ Modern, clean UI with Tailwind CSS
-Clear privacy explanations
-Opt-in by default (unchecked checkbox)
- ✅ Links to privacy policy and documentation
- ✅ Easy to understand language
**User experience:**
- ✅ Modern UI with Tailwind CSS; Back/Next navigation
-Prefilled from existing Settings when re-rendered (e.g. validation error)
-Server-side validation (timezone, date format, currency, rounding, idle timeout)
- ✅ Links to privacy policy and GPL-3.0
### 4. Admin Telemetry Dashboard