diff --git a/data/db.sqlite3 b/data/db.sqlite3
index 0152086..a7f902b 100644
Binary files a/data/db.sqlite3 and b/data/db.sqlite3 differ
diff --git a/links/templates/links/link_detail.html b/links/templates/links/link_detail.html
index cb450b4..4fef08c 100644
--- a/links/templates/links/link_detail.html
+++ b/links/templates/links/link_detail.html
@@ -76,7 +76,7 @@
{% for tag in link.tags.all %}
{% with number=forloop.counter %}
-
{{ tag.name }}
@@ -105,10 +105,28 @@
-
{% trans "Click Statistics" %}
+
+
{% trans "Click Statistics" %} - {{ period_name }}
+
+
+
+
+
+
+
@@ -144,34 +162,87 @@
document.addEventListener('DOMContentLoaded', function() {
var ctx = document.getElementById('clickChart').getContext('2d');
var clickData = {{ click_stats|safe }};
+ var intervalDays = {{ interval_days }};
var chart = new Chart(ctx, {
type: 'line',
data: {
- labels: clickData.map(item => item.date),
+ labels: clickData.map(item => {
+ // Use custom label if available, otherwise format date based on interval
+ if (item.label) {
+ return item.label;
+ }
+
+ const date = new Date(item.date);
+ if (intervalDays === 1) {
+ // Daily: show "Jan 15"
+ return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
+ } else if (intervalDays <= 14) {
+ // Weekly/Bi-weekly: show "1/15"
+ return date.toLocaleDateString('en-US', { month: 'numeric', day: 'numeric' });
+ } else {
+ // Monthly: show "Jan 2025"
+ return date.toLocaleDateString('en-US', { month: 'short', year: 'numeric' });
+ }
+ }),
datasets: [{
label: '{% trans "Clicks" %}',
data: clickData.map(item => item.count),
- backgroundColor: 'rgba(75, 192, 192, 0.2)',
- borderColor: 'rgba(75, 192, 192, 1)',
- borderWidth: 1
+ backgroundColor: 'rgba(99, 102, 241, 0.1)',
+ borderColor: 'rgba(99, 102, 241, 1)',
+ borderWidth: 3,
+ fill: true,
+ tension: 0.4,
+ pointBackgroundColor: 'rgba(99, 102, 241, 1)',
+ pointBorderColor: '#ffffff',
+ pointBorderWidth: 2,
+ pointRadius: 4,
+ pointHoverRadius: 6,
+ pointHoverBackgroundColor: 'rgba(99, 102, 241, 1)',
+ pointHoverBorderColor: '#ffffff',
+ pointHoverBorderWidth: 2
}]
},
options: {
+ responsive: true,
+ maintainAspectRatio: false,
+ plugins: {
+ legend: {
+ display: false
+ }
+ },
scales: {
y: {
beginAtZero: true,
- title: {
- display: true,
- text: '{% trans "Number of Clicks" %}'
+ grid: {
+ color: 'rgba(0, 0, 0, 0.05)',
+ borderDash: [2, 2]
+ },
+ ticks: {
+ stepSize: 1,
+ color: '#6b7280',
+ font: {
+ size: 12
+ }
}
},
x: {
- title: {
- display: true,
- text: '{% trans "Date" %}'
+ grid: {
+ display: false
+ },
+ ticks: {
+ color: '#6b7280',
+ font: {
+ size: 12
+ },
+ maxTicksLimit: intervalDays === 1 ? 15 : 10
}
}
+ },
+ elements: {
+ line: {
+ borderJoinStyle: 'round'
+ }
}
}
});
@@ -200,6 +271,13 @@
tag.classList.add(...randomColor.split(' '));
});
});
+
+ // Function to change time period
+ function changePeriod(period) {
+ const url = new URL(window.location.href);
+ url.searchParams.set('period', period);
+ window.location.href = url.toString();
+ }
{% endblock %}
@@ -271,6 +349,40 @@
padding-right: 1.1428571em;
padding-bottom: 0.8571429em;
padding-left: 1.1428571em;
- }
+ }
+
+ /* Chart container styling */
+ #clickChart {
+ border-radius: 8px;
+ }
+
+ /* Period selection buttons */
+ .period-btn {
+ padding: 0.375rem 0.75rem;
+ font-size: 0.875rem;
+ font-weight: 500;
+ border-radius: 0.375rem;
+ border: 1px solid #d1d5db;
+ background-color: #ffffff;
+ color: #374151;
+ cursor: pointer;
+ transition: all 0.2s ease-in-out;
+ }
+
+ .period-btn:hover {
+ background-color: #f9fafb;
+ border-color: #9ca3af;
+ }
+
+ .period-btn.active {
+ background-color: #6366f1;
+ border-color: #6366f1;
+ color: #ffffff;
+ }
+
+ .period-btn.active:hover {
+ background-color: #5b21b6;
+ border-color: #5b21b6;
+ }
{% endblock %}
diff --git a/links/views.py b/links/views.py
index de347a5..d8bf10f 100644
--- a/links/views.py
+++ b/links/views.py
@@ -271,16 +271,103 @@ class LinkDetailView(DetailView):
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
- click_stats = ClickLog.objects.filter(link=self.object).annotate(
+ from datetime import datetime, timedelta
+ import calendar
+
+ # Get the time period from request (default to 3 months)
+ period = self.request.GET.get('period', '3m')
+
+ # Calculate date range and interval based on period
+ end_date = datetime.now().date()
+ if period == '3m':
+ start_date = end_date - timedelta(days=89) # 90 days total
+ period_name = "3 Months"
+ interval_days = 1 # Daily
+ elif period == '6m':
+ start_date = end_date - timedelta(days=179) # 180 days total
+ period_name = "6 Months"
+ interval_days = 7 # Weekly
+ elif period == '1y':
+ start_date = end_date - timedelta(days=364) # 365 days total
+ period_name = "1 Year"
+ interval_days = 7 # Weekly
+ elif period == 'all':
+ # Get the earliest click date, or default to 1 year ago if no clicks
+ earliest_click = ClickLog.objects.filter(link=self.object).order_by('clicked_at').first()
+ if earliest_click:
+ start_date = earliest_click.clicked_at.date()
+ # Determine interval based on data range
+ days_range = (end_date - start_date).days
+ if days_range <= 90:
+ interval_days = 7 # Weekly
+ elif days_range <= 365:
+ interval_days = 7 # Weekly
+ else:
+ interval_days = 30 # Monthly
+ else:
+ start_date = end_date - timedelta(days=364)
+ interval_days = 7 # Weekly
+ period_name = "All Time"
+ else:
+ start_date = end_date - timedelta(days=89) # Default to 3 months
+ period_name = "3 Months"
+ interval_days = 1 # Daily
+
+ # Get click stats from database
+ click_stats = ClickLog.objects.filter(
+ link=self.object,
+ clicked_at__date__gte=start_date,
+ clicked_at__date__lte=end_date
+ ).annotate(
date=TruncDate('clicked_at')
).values('date').annotate(count=Count('id')).order_by('date')
- # Convert query results to list and format dates as strings
- click_stats_list = list(click_stats)
- for item in click_stats_list:
- item['date'] = item['date'].strftime('%Y-%m-%d')
+ # Convert to dictionary for easy lookup
+ click_dict = {item['date']: item['count'] for item in click_stats}
+
+ # Create complete dataset with appropriate intervals
+ click_stats_list = []
+ current_date = start_date
+
+ if interval_days == 1:
+ # Daily intervals
+ while current_date <= end_date:
+ click_stats_list.append({
+ 'date': current_date.strftime('%Y-%m-%d'),
+ 'count': click_dict.get(current_date, 0)
+ })
+ current_date += timedelta(days=1)
+ else:
+ # Weekly, bi-weekly, or monthly intervals
+ while current_date <= end_date:
+ interval_end = min(current_date + timedelta(days=interval_days - 1), end_date)
+
+ # Sum clicks for this interval
+ interval_count = 0
+ temp_date = current_date
+ while temp_date <= interval_end:
+ interval_count += click_dict.get(temp_date, 0)
+ temp_date += timedelta(days=1)
+
+ # Format label based on interval
+ if interval_days == 7: # Weekly
+ label = f"{current_date.strftime('%m/%d')}"
+ elif interval_days == 14: # Bi-weekly
+ label = f"{current_date.strftime('%m/%d')}"
+ else: # Monthly
+ label = f"{calendar.month_abbr[current_date.month]} {current_date.year}"
+
+ click_stats_list.append({
+ 'date': current_date.strftime('%Y-%m-%d'),
+ 'count': interval_count,
+ 'label': label
+ })
+ current_date += timedelta(days=interval_days)
context['click_stats'] = json.dumps(click_stats_list, cls=DjangoJSONEncoder)
+ context['current_period'] = period
+ context['period_name'] = period_name
+ context['interval_days'] = interval_days
# Convert markdown to HTML if the link is a custom type
if self.object.link_type == Link.LinkType.CUSTOM: