diff --git a/tracker/admin.py b/tracker/admin.py index a211d97..59eb250 100644 --- a/tracker/admin.py +++ b/tracker/admin.py @@ -27,7 +27,7 @@ class ProjectAdmin(admin.ModelAdmin): @admin.register(TimeEntry) class TimeEntryAdmin(admin.ModelAdmin): - list_display = ("project", "date", "start_time", "end_time", "duration_minutes") - list_filter = ("project", "date") + list_display = ("project", "date", "status", "start_time", "end_time", "duration_minutes") + list_filter = ("project", "status", "date") search_fields = ("description",) date_hierarchy = "date" diff --git a/tracker/migrations/0002_timeentry_accumulated_minutes_and_more.py b/tracker/migrations/0002_timeentry_accumulated_minutes_and_more.py new file mode 100644 index 0000000..03dbf58 --- /dev/null +++ b/tracker/migrations/0002_timeentry_accumulated_minutes_and_more.py @@ -0,0 +1,53 @@ +# Generated by Django 5.0.6 on 2026-06-01 08:49 + +import django.core.validators +from django.db import migrations, models + + +def backfill_open_timers(apps, schema_editor): + """Pre-existing open timers (no end_time) were single-segment running timers.""" + TimeEntry = apps.get_model("tracker", "TimeEntry") + TimeEntry.objects.filter(end_time__isnull=True).update( + status="running", + segment_started_at=models.F("start_time"), + ) + + +def noop(apps, schema_editor): + pass + + +class Migration(migrations.Migration): + + dependencies = [ + ('tracker', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='timeentry', + name='accumulated_minutes', + field=models.PositiveIntegerField(default=0, help_text='Worked minutes from completed segments (before the live one).'), + ), + migrations.AddField( + model_name='timeentry', + name='segment_started_at', + field=models.DateTimeField(blank=True, help_text='Start of the currently running segment; null while paused.', null=True), + ), + migrations.AddField( + model_name='timeentry', + name='status', + field=models.CharField(choices=[('running', 'Running'), ('paused', 'Paused'), ('completed', 'Completed')], default='completed', max_length=10), + ), + migrations.AlterField( + model_name='project', + name='color', + field=models.CharField(default='#0d6efd', help_text='Hex color (e.g. #0d6efd) used as a visual marker.', max_length=7, validators=[django.core.validators.RegexValidator(message='Color must be a hex value like #0d6efd or #abc.', regex='^#(?:[0-9a-fA-F]{3}){1,2}$')]), + ), + migrations.AlterField( + model_name='timeentry', + name='duration_minutes', + field=models.PositiveIntegerField(default=0, help_text='Worked duration in minutes (excludes paused gaps).'), + ), + migrations.RunPython(backfill_open_timers, noop), + ] diff --git a/tracker/models.py b/tracker/models.py index 2982422..19c19ba 100644 --- a/tracker/models.py +++ b/tracker/models.py @@ -84,7 +84,20 @@ def active_timer(self) -> TimeEntry | None: class TimeEntry(models.Model): - """A single time log entry against a project.""" + """A single time log entry against a project. + + A timer-managed entry is worked in one or more segments. ``status`` tracks + its lifecycle, ``accumulated_minutes`` holds the time from completed + segments, and ``segment_started_at`` marks the start of the currently + running segment (``None`` while paused). ``start_time`` is the first start, + ``end_time`` the final stop, and ``duration_minutes`` the worked total + (excluding paused gaps). At most one entry may be ``RUNNING`` at a time. + """ + + class Status(models.TextChoices): + RUNNING = "running", "Running" + PAUSED = "paused", "Paused" + COMPLETED = "completed", "Completed" project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="entries") date = models.DateField(default=timezone.localdate) @@ -92,7 +105,21 @@ class TimeEntry(models.Model): end_time = models.DateTimeField(null=True, blank=True) duration_minutes = models.PositiveIntegerField( default=0, - help_text="Duration in minutes. Auto-calculated from start/end if both are set.", + help_text="Worked duration in minutes (excludes paused gaps).", + ) + status = models.CharField( + max_length=10, + choices=Status.choices, + default=Status.COMPLETED, + ) + accumulated_minutes = models.PositiveIntegerField( + default=0, + help_text="Worked minutes from completed segments (before the live one).", + ) + segment_started_at = models.DateTimeField( + null=True, + blank=True, + help_text="Start of the currently running segment; null while paused.", ) description = models.TextField(blank=True) created_at = models.DateTimeField(auto_now_add=True) @@ -105,17 +132,68 @@ def __str__(self) -> str: return f"{self.project.name} · {self.date} · {self.duration_display}" def save(self, *args, **kwargs): - # Auto-compute duration when both timestamps are set - if self.start_time and self.end_time: + # Derive duration from the span only for simple entries that never used + # pause/resume. Timer-managed entries set duration_minutes in stop(). + if self.start_time and self.end_time and self.accumulated_minutes == 0: delta: timedelta = self.end_time - self.start_time self.duration_minutes = max(0, int(delta.total_seconds() // 60)) if not self.date: self.date = timezone.localdate(self.start_time) super().save(*args, **kwargs) + # ----- Timer lifecycle ----- + + def _segment_minutes(self, when) -> int: + """Minutes elapsed in the currently running segment, if any.""" + if not self.segment_started_at: + return 0 + delta = when - self.segment_started_at + return max(0, int(delta.total_seconds() // 60)) + + def pause(self, when=None) -> None: + """Hold a running timer: bank the live segment, stop counting.""" + if self.status != self.Status.RUNNING: + return + when = when or timezone.now() + self.accumulated_minutes += self._segment_minutes(when) + self.segment_started_at = None + self.status = self.Status.PAUSED + self.save(update_fields=["accumulated_minutes", "segment_started_at", "status"]) + + def resume(self, when=None) -> None: + """Make this the active timer, holding any other running one.""" + when = when or timezone.now() + for other in TimeEntry.objects.filter(status=self.Status.RUNNING).exclude(pk=self.pk): + other.pause(when=when) + self.segment_started_at = when + self.status = self.Status.RUNNING + self.save(update_fields=["segment_started_at", "status"]) + + def stop(self, when=None) -> None: + """Finalize the session into a completed entry.""" + when = when or timezone.now() + if self.status == self.Status.RUNNING: + self.accumulated_minutes += self._segment_minutes(when) + self.segment_started_at = None + self.end_time = when + self.duration_minutes = self.accumulated_minutes + self.status = self.Status.COMPLETED + self.save() + + # ----- State helpers ----- + @property def is_running(self) -> bool: - return self.start_time is not None and self.end_time is None + return self.status == self.Status.RUNNING + + @property + def is_paused(self) -> bool: + return self.status == self.Status.PAUSED + + @property + def is_open(self) -> bool: + """Running or paused — an unfinished session shown on the dashboard.""" + return self.status in (self.Status.RUNNING, self.Status.PAUSED) @property def duration_display(self) -> str: @@ -124,10 +202,11 @@ def duration_display(self) -> str: return f"{hours:d}h {mins:02d}m" def current_duration_minutes(self) -> int: - """Live duration: for running timers, returns minutes since start.""" - if self.is_running and self.start_time: - delta = timezone.now() - self.start_time - return max(0, int(delta.total_seconds() // 60)) + """Worked minutes so far: banked segments plus the live one if running.""" + if self.status == self.Status.RUNNING: + return self.accumulated_minutes + self._segment_minutes(timezone.now()) + if self.status == self.Status.PAUSED: + return self.accumulated_minutes return self.duration_minutes def clean(self): diff --git a/tracker/templates/tracker/dashboard.html b/tracker/templates/tracker/dashboard.html index e615e28..6fc11df 100644 --- a/tracker/templates/tracker/dashboard.html +++ b/tracker/templates/tracker/dashboard.html @@ -22,10 +22,19 @@

Dashboard

{{ entry.project.name }}
Started {{ entry.start_time|time:"H:i" }} · - {{ entry.duration_display }} + {{ entry.duration_display }}
-
+ + {% csrf_token %} + + +
+
{% csrf_token %} +
+
+ {% csrf_token %} + + +
+
+ {% csrf_token %} + + +
+ + + + + {% endfor %} + +{% endif %} +

Projects

{% if project_stats %}
@@ -62,14 +113,40 @@

Projects

Week
{{ stat.week_h }}h
Month
{{ stat.month_h }}h
- {% if stat.active_timer %} -
- {% csrf_token %} - - -
+ {% if stat.open_entry.is_running %} +
+
+ {% csrf_token %} + + +
+
+ {% csrf_token %} + + +
+
+ {% elif stat.open_entry.is_paused %} +
+
+ {% csrf_token %} + + +
+
+ {% csrf_token %} + + +
+
{% else %}
{% csrf_token %} @@ -142,8 +219,9 @@

Recent entries

} function tick() { document.querySelectorAll(".js-timer").forEach(function (el) { - var start = new Date(el.dataset.start); - el.textContent = fmt(Date.now() - start.getTime()); + var accumulatedMs = (parseInt(el.dataset.accumulated, 10) || 0) * 60 * 1000; + var segmentStart = new Date(el.dataset.segmentStart); + el.textContent = fmt(accumulatedMs + (Date.now() - segmentStart.getTime())); }); } setInterval(tick, 1000); diff --git a/tracker/tests.py b/tracker/tests.py index 0f98565..15dacea 100644 --- a/tracker/tests.py +++ b/tracker/tests.py @@ -134,14 +134,17 @@ def test_start_then_stop_creates_completed_entry(self): running.refresh_from_db() self.assertIsNotNone(running.end_time) - def test_starting_second_timer_stops_first(self): + def test_starting_second_timer_holds_first(self): a = Project.objects.create(name="A") b = Project.objects.create(name="B") self.client.post(reverse("tracker:timer_start", args=[a.id])) self.client.post(reverse("tracker:timer_start", args=[b.id])) - # A is stopped, B is running - self.assertFalse(TimeEntry.objects.filter(project=a, end_time__isnull=True).exists()) - self.assertTrue(TimeEntry.objects.filter(project=b, end_time__isnull=True).exists()) + a_entry = TimeEntry.objects.get(project=a) + b_entry = TimeEntry.objects.get(project=b) + # A is held (paused, still open), B is running — not stopped + self.assertEqual(a_entry.status, TimeEntry.Status.PAUSED) + self.assertIsNone(a_entry.end_time) + self.assertEqual(b_entry.status, TimeEntry.Status.RUNNING) def test_open_redirect_is_blocked(self): """The `next` parameter must not redirect to an external host.""" @@ -154,6 +157,140 @@ def test_open_redirect_is_blocked(self): self.assertNotIn("evil.example.com", resp.url) +class PausableTimerModelTests(TestCase): + """Pause/resume/stop arithmetic on a single TimeEntry.""" + + def setUp(self): + self.project = Project.objects.create(name="Pausable") + + def _running(self, started_min_ago=0, accumulated=0, segment_min_ago=None, now=None): + now = now or timezone.now() + seg = None if segment_min_ago is None else now - timedelta(minutes=segment_min_ago) + return TimeEntry.objects.create( + project=self.project, + date=timezone.localdate(), + start_time=now - timedelta(minutes=started_min_ago), + segment_started_at=seg, + accumulated_minutes=accumulated, + status=TimeEntry.Status.RUNNING, + ) + + def test_pause_accumulates_elapsed_and_holds_open(self): + now = timezone.now() + e = self._running(started_min_ago=30, segment_min_ago=30, now=now) + e.pause(when=now) + self.assertEqual(e.status, TimeEntry.Status.PAUSED) + self.assertEqual(e.accumulated_minutes, 30) + self.assertIsNone(e.segment_started_at) + self.assertIsNone(e.end_time) + + def test_resume_continues_and_keeps_accumulated(self): + e = TimeEntry.objects.create( + project=self.project, + date=timezone.localdate(), + start_time=timezone.now() - timedelta(minutes=60), + accumulated_minutes=20, + segment_started_at=None, + status=TimeEntry.Status.PAUSED, + ) + e.resume(when=timezone.now()) + self.assertEqual(e.status, TimeEntry.Status.RUNNING) + self.assertEqual(e.accumulated_minutes, 20) + self.assertIsNotNone(e.segment_started_at) + + def test_current_duration_includes_live_segment_when_running(self): + e = self._running(started_min_ago=50, accumulated=20, segment_min_ago=10) + self.assertEqual(e.current_duration_minutes(), 30) + + def test_current_duration_is_accumulated_when_paused(self): + e = TimeEntry.objects.create( + project=self.project, + date=timezone.localdate(), + start_time=timezone.now() - timedelta(minutes=50), + accumulated_minutes=25, + segment_started_at=None, + status=TimeEntry.Status.PAUSED, + ) + self.assertEqual(e.current_duration_minutes(), 25) + + def test_stop_sums_final_running_segment(self): + now = timezone.now() + e = self._running(started_min_ago=60, accumulated=20, segment_min_ago=15, now=now) + e.stop(when=now) + self.assertEqual(e.status, TimeEntry.Status.COMPLETED) + self.assertIsNotNone(e.end_time) + self.assertEqual(e.duration_minutes, 35) + + def test_stop_while_paused_uses_accumulated(self): + e = TimeEntry.objects.create( + project=self.project, + date=timezone.localdate(), + start_time=timezone.now() - timedelta(minutes=60), + accumulated_minutes=42, + segment_started_at=None, + status=TimeEntry.Status.PAUSED, + ) + e.stop() + self.assertEqual(e.status, TimeEntry.Status.COMPLETED) + self.assertEqual(e.duration_minutes, 42) + + +class PausableTimerFlowTests(TestCase): + """View-level pause/resume/auto-hold flows (redirect + DB state only).""" + + def setUp(self): + self.user = _make_user() + self.client.login(username="tester", password="pass-word-1234") + self.a = Project.objects.create(name="A") + self.b = Project.objects.create(name="B") + + def _open_entry(self, project): + return TimeEntry.objects.get(project=project, end_time__isnull=True) + + def test_start_creates_running_entry(self): + self.client.post(reverse("tracker:timer_start", args=[self.a.id])) + entry = self._open_entry(self.a) + self.assertEqual(entry.status, TimeEntry.Status.RUNNING) + self.assertIsNotNone(entry.segment_started_at) + + def test_pause_endpoint_pauses_running_timer(self): + self.client.post(reverse("tracker:timer_start", args=[self.a.id])) + entry = self._open_entry(self.a) + resp = self.client.post(reverse("tracker:timer_pause", args=[entry.id])) + self.assertEqual(resp.status_code, 302) + entry.refresh_from_db() + self.assertEqual(entry.status, TimeEntry.Status.PAUSED) + self.assertIsNone(entry.end_time) + + def test_resume_endpoint_holds_other_running_timer(self): + self.client.post(reverse("tracker:timer_start", args=[self.a.id])) + a_entry = self._open_entry(self.a) + self.client.post(reverse("tracker:timer_pause", args=[a_entry.id])) + self.client.post(reverse("tracker:timer_start", args=[self.b.id])) + b_entry = self._open_entry(self.b) + self.client.post(reverse("tracker:timer_resume", args=[a_entry.id])) + a_entry.refresh_from_db() + b_entry.refresh_from_db() + self.assertEqual(a_entry.status, TimeEntry.Status.RUNNING) + self.assertEqual(b_entry.status, TimeEntry.Status.PAUSED) + + def test_only_one_running_timer_at_a_time(self): + self.client.post(reverse("tracker:timer_start", args=[self.a.id])) + self.client.post(reverse("tracker:timer_start", args=[self.b.id])) + self.assertEqual( + TimeEntry.objects.filter(status=TimeEntry.Status.RUNNING).count(), 1 + ) + + def test_starting_existing_paused_resumes_same_entry(self): + self.client.post(reverse("tracker:timer_start", args=[self.a.id])) + a_entry = self._open_entry(self.a) + self.client.post(reverse("tracker:timer_pause", args=[a_entry.id])) + self.client.post(reverse("tracker:timer_start", args=[self.a.id])) + self.assertEqual(TimeEntry.objects.filter(project=self.a).count(), 1) + a_entry.refresh_from_db() + self.assertEqual(a_entry.status, TimeEntry.Status.RUNNING) + + class ReportTests(TestCase): def setUp(self): self.user = _make_user() diff --git a/tracker/urls.py b/tracker/urls.py index f3d09ca..ba78725 100644 --- a/tracker/urls.py +++ b/tracker/urls.py @@ -33,6 +33,8 @@ path("entries//delete/", views.timeentry_delete, name="timeentry_delete"), # Live timer path("timer/start//", views.timer_start, name="timer_start"), + path("timer/pause//", views.timer_pause, name="timer_pause"), + path("timer/resume//", views.timer_resume, name="timer_resume"), path("timer/stop//", views.timer_stop, name="timer_stop"), path("timer/cancel//", views.timer_cancel, name="timer_cancel"), # Report diff --git a/tracker/views.py b/tracker/views.py index 5885462..16aae5a 100644 --- a/tracker/views.py +++ b/tracker/views.py @@ -73,12 +73,17 @@ def dashboard(request): "today_h": project.total_hours(since=today, until=today), "week_h": project.total_hours(since=week_start, until=today), "month_h": project.total_hours(since=month_start, until=today), - "active_timer": project.active_timer, + "open_entry": project.active_timer, }) - running = TimeEntry.objects.filter(end_time__isnull=True).select_related("project") + running = TimeEntry.objects.filter( + status=TimeEntry.Status.RUNNING + ).select_related("project") + held = TimeEntry.objects.filter( + status=TimeEntry.Status.PAUSED + ).select_related("project") recent_entries = ( - TimeEntry.objects.filter(end_time__isnull=False) + TimeEntry.objects.filter(status=TimeEntry.Status.COMPLETED) .select_related("project") .order_by("-date", "-end_time")[:10] ) @@ -86,6 +91,7 @@ def dashboard(request): return render(request, "tracker/dashboard.html", { "project_stats": project_stats, "running": running, + "held": held, "recent_entries": recent_entries, "today": today, }) @@ -215,31 +221,55 @@ def timeentry_delete(request, pk: int): @require_POST def timer_start(request, project_id: int): project = get_object_or_404(Project, pk=project_id, is_archived=False) - # Stop other running timers (single-concurrent-timer policy) - other_running = TimeEntry.objects.filter(end_time__isnull=True).exclude(project=project) now = timezone.now() - for r in other_running: - r.end_time = now - r.save() - messages.info(request, f"Stopped running timer on '{r.project.name}'.") - if not project.active_timer: + existing = project.active_timer + if existing: + # Resume the project's open session (auto-holds any other running timer). + existing.resume(when=now) + messages.success(request, f"Resumed timer for '{project.name}'.") + else: + # New session: hold any running timer, then start counting. + for other in TimeEntry.objects.filter(status=TimeEntry.Status.RUNNING): + other.pause(when=now) + messages.info(request, f"Paused running timer on '{other.project.name}'.") TimeEntry.objects.create( project=project, date=timezone.localdate(), start_time=now, + segment_started_at=now, + status=TimeEntry.Status.RUNNING, description=request.POST.get("description", ""), ) messages.success(request, f"Timer started for '{project.name}'.") return redirect(_safe_next(request)) +@login_required +@require_POST +def timer_pause(request, entry_id: int): + entry = get_object_or_404(TimeEntry, pk=entry_id, status=TimeEntry.Status.RUNNING) + entry.pause() + messages.info(request, f"Timer for '{entry.project.name}' put on hold.") + return redirect(_safe_next(request)) + + +@login_required +@require_POST +def timer_resume(request, entry_id: int): + entry = get_object_or_404(TimeEntry, pk=entry_id, status=TimeEntry.Status.PAUSED) + entry.resume() + messages.success(request, f"Resumed timer for '{entry.project.name}'.") + return redirect(_safe_next(request)) + + @login_required @require_POST def timer_stop(request, entry_id: int): entry = get_object_or_404(TimeEntry, pk=entry_id, end_time__isnull=True) - entry.end_time = timezone.now() - entry.description = request.POST.get("description", entry.description) or entry.description - entry.save() + description = request.POST.get("description", "") + if description: + entry.description = description + entry.stop() messages.success(request, f"Timer stopped — logged {entry.duration_display}.") return redirect(_safe_next(request))