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 @@