Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions tracker/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
53 changes: 53 additions & 0 deletions tracker/migrations/0002_timeentry_accumulated_minutes_and_more.py
Original file line number Diff line number Diff line change
@@ -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),
]
97 changes: 88 additions & 9 deletions tracker/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,15 +84,42 @@ 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)
start_time = models.DateTimeField(null=True, blank=True)
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)
Expand All @@ -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:
Expand All @@ -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):
Expand Down
102 changes: 90 additions & 12 deletions tracker/templates/tracker/dashboard.html
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,19 @@ <h1 class="h3 mb-0">Dashboard</h1>
{{ entry.project.name }}
</div>
<div class="text-muted">Started {{ entry.start_time|time:"H:i" }} ·
<span class="js-timer" data-start="{{ entry.start_time|date:'c' }}">{{ entry.duration_display }}</span>
<span class="js-timer"
data-accumulated="{{ entry.accumulated_minutes }}"
data-segment-start="{{ entry.segment_started_at|date:'c' }}">{{ entry.duration_display }}</span>
</div>
</div>
<form method="post" action="{% url 'tracker:timer_stop' entry.id %}" class="d-flex gap-2">
<form method="post" action="{% url 'tracker:timer_pause' entry.id %}">
{% csrf_token %}
<input type="hidden" name="next" value="{% url 'tracker:dashboard' %}">
<button type="submit" class="btn btn-outline-warning">
<i class="bi bi-pause-circle me-1"></i>Pause
</button>
</form>
<form method="post" action="{% url 'tracker:timer_stop' entry.id %}">
{% csrf_token %}
<input type="hidden" name="next" value="{% url 'tracker:dashboard' %}">
<button type="submit" class="btn btn-success">
Expand All @@ -44,6 +53,48 @@ <h1 class="h3 mb-0">Dashboard</h1>
{% endfor %}
{% endif %}

{% if held %}
<h2 class="h5 text-muted mb-3"><i class="bi bi-pause-circle me-1"></i>On hold</h2>
<div class="row g-3 mb-4">
{% for entry in held %}
<div class="col-md-6 col-lg-4">
<div class="card h-100 border-warning shadow-sm">
<div class="card-body d-flex flex-column gap-2">
<div class="fw-semibold">
<span class="rounded-circle me-2" style="display:inline-block;background:{{ entry.project.color }};width:14px;height:14px;"></span>
{{ entry.project.name }}
</div>
<div class="text-muted small">Held at {{ entry.duration_display }}</div>
<div class="d-flex gap-2 mt-auto">
<form method="post" action="{% url 'tracker:timer_resume' entry.id %}" class="flex-grow-1">
{% csrf_token %}
<input type="hidden" name="next" value="{% url 'tracker:dashboard' %}">
<button type="submit" class="btn btn-outline-primary w-100">
<i class="bi bi-play-circle me-1"></i>Resume
</button>
</form>
<form method="post" action="{% url 'tracker:timer_stop' entry.id %}">
{% csrf_token %}
<input type="hidden" name="next" value="{% url 'tracker:dashboard' %}">
<button type="submit" class="btn btn-success" title="Stop &amp; Save">
<i class="bi bi-stop-circle"></i>
</button>
</form>
<form method="post" action="{% url 'tracker:timer_cancel' entry.id %}" onsubmit="return confirm('Discard this held timer?');">
{% csrf_token %}
<input type="hidden" name="next" value="{% url 'tracker:dashboard' %}">
<button type="submit" class="btn btn-outline-danger" title="Discard">
<i class="bi bi-trash"></i>
</button>
</form>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{% endif %}

<h2 class="h5 text-muted mb-3">Projects</h2>
{% if project_stats %}
<div class="row g-3 mb-4">
Expand All @@ -62,14 +113,40 @@ <h2 class="h5 text-muted mb-3">Projects</h2>
<div class="col"><div class="text-muted">Week</div><strong>{{ stat.week_h }}h</strong></div>
<div class="col"><div class="text-muted">Month</div><strong>{{ stat.month_h }}h</strong></div>
</div>
{% if stat.active_timer %}
<form method="post" action="{% url 'tracker:timer_stop' stat.active_timer.id %}">
{% csrf_token %}
<input type="hidden" name="next" value="{% url 'tracker:dashboard' %}">
<button type="submit" class="btn btn-success w-100">
<i class="bi bi-stop-circle me-1"></i>Stop Timer
</button>
</form>
{% if stat.open_entry.is_running %}
<div class="d-flex gap-2">
<form method="post" action="{% url 'tracker:timer_pause' stat.open_entry.id %}" class="flex-grow-1">
{% csrf_token %}
<input type="hidden" name="next" value="{% url 'tracker:dashboard' %}">
<button type="submit" class="btn btn-outline-warning w-100">
<i class="bi bi-pause-circle me-1"></i>Pause
</button>
</form>
<form method="post" action="{% url 'tracker:timer_stop' stat.open_entry.id %}">
{% csrf_token %}
<input type="hidden" name="next" value="{% url 'tracker:dashboard' %}">
<button type="submit" class="btn btn-success" title="Stop &amp; Save">
<i class="bi bi-stop-circle"></i>
</button>
</form>
</div>
{% elif stat.open_entry.is_paused %}
<div class="d-flex gap-2">
<form method="post" action="{% url 'tracker:timer_resume' stat.open_entry.id %}" class="flex-grow-1">
{% csrf_token %}
<input type="hidden" name="next" value="{% url 'tracker:dashboard' %}">
<button type="submit" class="btn btn-outline-primary w-100">
<i class="bi bi-play-circle me-1"></i>Resume
</button>
</form>
<form method="post" action="{% url 'tracker:timer_stop' stat.open_entry.id %}">
{% csrf_token %}
<input type="hidden" name="next" value="{% url 'tracker:dashboard' %}">
<button type="submit" class="btn btn-success" title="Stop &amp; Save">
<i class="bi bi-stop-circle"></i>
</button>
</form>
</div>
{% else %}
<form method="post" action="{% url 'tracker:timer_start' stat.project.id %}">
{% csrf_token %}
Expand Down Expand Up @@ -142,8 +219,9 @@ <h2 class="h5 text-muted mb-3">Recent entries</h2>
}
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);
Expand Down
Loading
Loading