From 55b36b920c1e9856f1242f69429148b8cd8f8a08 Mon Sep 17 00:00:00 2001 From: Junwei Zhao Date: Mon, 30 Jun 2025 22:00:22 +1000 Subject: [PATCH] Update chart style --- data/db.sqlite3 | Bin 389120 -> 389120 bytes links/templates/links/link_detail.html | 140 ++++++++++++++++++++++--- links/views.py | 97 ++++++++++++++++- 3 files changed, 218 insertions(+), 19 deletions(-) diff --git a/data/db.sqlite3 b/data/db.sqlite3 index 0152086527e515ca1f7f9df865e301d4b445e0e2..a7f902b8453dfd1288802ed5042ef2ae9f59cabb 100644 GIT binary patch delta 437 zcmZp8Al~pme1bG%_(U0J#_){^qWX+p&1L%A%k&xhE--tt)lFq*>f~Z)VSB`OfNefo z-DX3BC#?1IUMvibj7A1Vrn&}Zy2b_yhK5$ACRQd!dd4OOX66=-@}5`~7+LC>8=IJ# z8apa@02MHynqXpKWo)TuVr*z?X~8g6!5zB_Q#~UM6EkxobB3w%ZkV>17+INE>KPfD z8kiX|Z&zh!y3e8D$!EmC|C|3S|9k%Z{G0jL@-O7ifCH-FqLh)Dm&AC z4mlUTAO`;5{9pOs^Y7>1%)gd@A%7-+GJiN<4qqBy!gLh@CXML<0!${`I|P_k@lI|K z(Apj($CM!?W1?$lq-$uYU|?!xVq#@tpl4=bW@u>6?&Im_Jv~>RS$+Fo1E$UUrkAoa z@pG9P85^4Nf~E53GnsGuz~rhUY-p%pXk=wzU}a*eXJ%<^Y+%9J9wEaF#4Ot*WLQ^R F1^}I!I(h&A 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: