13 Commits
1.2.0 ... 1.3.0

Author SHA1 Message Date
4552cf7616 Version 1.3.0
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-05 15:10:56 +01:00
a614b51d29 Make some pages redirect back instead to session list 2023-11-05 15:09:51 +01:00
e67aa3fda1 Add more stats
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-02 20:12:32 +01:00
8423fd02b4 Extend stats range to 2018
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-02 15:32:57 +01:00
2bd07e5f2d Remove cruft
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-02 15:14:57 +01:00
058b83522c Group by game instead of purchase 2023-11-02 15:14:50 +01:00
f13ed8a078 Reorder imports in views.py
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-02 09:53:28 +01:00
02d5adcb3c Remove hardcoded year 2023-11-02 09:52:59 +01:00
d6fb16bb74 Make navigation more compact 2023-11-02 09:52:42 +01:00
71b90b8202 Add stats link, year selector 2023-11-02 09:20:09 +01:00
3ee36932c3 Limit stats of single year correctly 2023-11-02 09:17:08 +01:00
391fcc79a8 Version 1.2.0
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-01 20:35:58 +01:00
57d4fd7212 Add yearly stats page
Fixes #15
2023-11-01 20:35:52 +01:00
9 changed files with 220 additions and 54 deletions

View File

@ -1,3 +1,18 @@
## 1.3.0 / 2023-11-05 15:09+01:00
### New
* Add Stats to the main navigation
* Allow selecting year on the Stats page
### Improved
* Make some pages redirect back instead to session list
### Improved
* Make navigation more compact
### Fixed
* Correctly limit sessions to a single year for stats
## 1.2.0 / 2023-11-01 20:18+01:00 ## 1.2.0 / 2023-11-01 20:18+01:00
### New ### New

View File

@ -6,7 +6,7 @@ RUN npm install && \
FROM python:3.10.9-slim-bullseye FROM python:3.10.9-slim-bullseye
ENV VERSION_NUMBER 1.2.0 ENV VERSION_NUMBER 1.3.0
ENV PROD 1 ENV PROD 1
ENV PYTHONUNBUFFERED=1 ENV PYTHONUNBUFFERED=1

View File

@ -755,6 +755,10 @@ select {
position: absolute; position: absolute;
} }
.relative {
position: relative;
}
.bottom-2 { .bottom-2 {
bottom: 0.5rem; bottom: 0.5rem;
} }
@ -791,11 +795,6 @@ select {
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.my-5 {
margin-top: 1.25rem;
margin-bottom: 1.25rem;
}
.my-6 { .my-6 {
margin-top: 1.5rem; margin-top: 1.5rem;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
@ -805,6 +804,10 @@ select {
margin-bottom: 0.25rem; margin-bottom: 0.25rem;
} }
.mb-10 {
margin-bottom: 2.5rem;
}
.mb-4 { .mb-4 {
margin-bottom: 1rem; margin-bottom: 1rem;
} }
@ -829,6 +832,10 @@ select {
display: block; display: block;
} }
.inline-block {
display: inline-block;
}
.inline { .inline {
display: inline; display: inline;
} }
@ -873,6 +880,10 @@ select {
width: 1.75rem; width: 1.75rem;
} }
.w-auto {
width: auto;
}
.w-full { .w-full {
width: 100%; width: 100%;
} }
@ -959,6 +970,11 @@ select {
border-color: rgb(100 116 139 / var(--tw-border-opacity)); border-color: rgb(100 116 139 / var(--tw-border-opacity));
} }
.bg-gray-200 {
--tw-bg-opacity: 1;
background-color: rgb(229 231 235 / var(--tw-bg-opacity));
}
.bg-green-600 { .bg-green-600 {
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgb(22 163 74 / var(--tw-bg-opacity)); background-color: rgb(22 163 74 / var(--tw-bg-opacity));
@ -983,6 +999,11 @@ select {
padding-right: 0.5rem; padding-right: 0.5rem;
} }
.px-4 {
padding-left: 1rem;
padding-right: 1rem;
}
.py-1 { .py-1 {
padding-top: 0.25rem; padding-top: 0.25rem;
padding-bottom: 0.25rem; padding-bottom: 0.25rem;
@ -1001,6 +1022,10 @@ select {
padding-right: 1rem; padding-right: 1rem;
} }
.pt-1 {
padding-top: 0.25rem;
}
.text-center { .text-center {
text-align: center; text-align: center;
} }
@ -1052,6 +1077,11 @@ select {
font-style: italic; font-style: italic;
} }
.text-gray-700 {
--tw-text-opacity: 1;
color: rgb(55 65 81 / var(--tw-text-opacity));
}
.text-slate-300 { .text-slate-300 {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(203 213 225 / var(--tw-text-opacity)); color: rgb(203 213 225 / var(--tw-text-opacity));
@ -1287,6 +1317,11 @@ th label {
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
} }
.hover\:bg-gray-400:hover {
--tw-bg-opacity: 1;
background-color: rgb(156 163 175 / var(--tw-bg-opacity));
}
.hover\:bg-green-700:hover { .hover\:bg-green-700:hover {
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgb(21 128 61 / var(--tw-bg-opacity)); background-color: rgb(21 128 61 / var(--tw-bg-opacity));
@ -1334,6 +1369,10 @@ th label {
--tw-ring-offset-color: #ddd6fe; --tw-ring-offset-color: #ddd6fe;
} }
.group:hover .group-hover\:block {
display: block;
}
:is(.dark .dark\:bg-gray-800) { :is(.dark .dark\:bg-gray-800) {
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgb(31 41 55 / var(--tw-bg-opacity)); background-color: rgb(31 41 55 / var(--tw-bg-opacity));

View File

@ -25,19 +25,37 @@
<div class="w-full md:block md:w-auto"> <div class="w-full md:block md:w-auto">
<ul <ul
class="flex flex-col md:flex-row p-4 mt-4 dark:text-white"> class="flex flex-col md:flex-row p-4 mt-4 dark:text-white">
<li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_game' %}">New Game</a></li> <li class="relative group">
<li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_platform' %}">New Platform</a></li> <a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_game' %}">New</a>
{% if game_available and platform_available %} <ul class="absolute hidden text-gray-700 pt-1 group-hover:block w-auto whitespace-nowrap">
<li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_edition' %}">New Edition</a></li> {% if purchase_available %}
<li><a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap" href="{% url 'add_device' %}">Device</a></li>
{% endif %} {% endif %}
<li><a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap" href="{% url 'add_game' %}">Game</a></li>
{% if game_available and platform_available %}
<li><a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap" href="{% url 'add_edition' %}">Edition</a></li>
{% endif %}
<li><a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap" href="{% url 'add_platform' %}">Platform</a></li>
{% if edition_available %} {% if edition_available %}
<li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_purchase' %}">New Purchase</a></li> <li><a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap" href="{% url 'add_purchase' %}">Purchase</a></li>
{% endif %} {% endif %}
{% if purchase_available %} {% if purchase_available %}
<li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_session' %}">New Session</a></li> <li><a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap" href="{% url 'add_session' %}">Session</a></li>
<li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_device' %}">New Device</a></li>
{% endif %} {% endif %}
</ul>
</li>
{% if session_count > 0 %} {% if session_count > 0 %}
<li class="relative group">
<a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'stats_current_year' %}">Stats</a>
<ul class="absolute hidden text-gray-700 pt-1 group-hover:block">
{% for year in stats_dropdown_year_range %}
<li>
<a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap" href="{% url 'stats_by_year' year %}">{{ year }}</a>
</li>
{% endfor %}
</ul>
</li>
<li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'list_sessions' %}">All Sessions</a></li> <li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'list_sessions' %}">All Sessions</a></li>
{% endif %} {% endif %}
</ul> </ul>

View File

@ -6,19 +6,34 @@
{% block content %} {% block content %}
<div class="dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto"> <div class="dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto">
<h1 class="text-5xl text-center my-6">Stats for {{ year }}</h1> <div class="flex justify-center items-center">
<form method="get" class="text-center">
<label class="text-5xl text-center inline-block mb-10" for="yearSelect">Stats for:</label>
<select name="year" id="yearSelect" onchange="this.form.submit();" class="mx-2">
{% for year_item in stats_dropdown_year_range %}
<option value="{{ year_item }}" {% if year == year_item %}selected{% endif %}>{{ year_item }}</option>
{% endfor %}
</select>
</form>
</div>
<table class="responsive-table"> <table class="responsive-table">
<thead> <thead>
<tr> <tr>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Total hours</th> <th class="px-2 sm:px-4 md:px-6 md:py-2">Hours</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Total games</th> <th class="px-2 sm:px-4 md:px-6 md:py-2">Games</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Total 2023 games</th> <th class="px-2 sm:px-4 md:px-6 md:py-2">Games ({{ year }})</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Purchases</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Spendings ({{ total_spent_currency }})</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">{{ total_spent_currency }}/game</th>
</tr> </tr>
<tbody> <tbody>
<tr> <tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_hours }}</td> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_hours }}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_games }}</td> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_games }}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_2023_games }}</td> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_2023_games }}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ all_purchased_this_year.count }}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_spent }}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ spent_per_game }}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@ -31,14 +46,14 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for purchase in top_10_by_playtime %} {% for game in top_10_games_by_playtime %}
<tr> <tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono"> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
<a href="{% url 'view_game' purchase.edition.game.id %}">{{ purchase.edition.name }} <a class="underline decoration-slate-500 sm:decoration-2" href="{% url 'view_game' game.id %}">{{ game.name }}
</a> </a>
</td> </td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.formatted_playtime }}</td> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ game.formatted_playtime }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
@ -54,11 +69,30 @@
<tbody> <tbody>
{% for item in total_playtime_per_platform %} {% for item in total_playtime_per_platform %}
<tr> <tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ item.platform_name }} </td> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ item.platform_name }}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ item.formatted_playtime }}</td> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ item.formatted_playtime }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
<h1 class="text-5xl text-center my-6">Purchases</h1>
<table class="responsive-table">
<thead>
<tr>
<th class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char">Name</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Price ({{ total_spent_currency }})</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th>
</tr>
</thead>
<tbody>
{% for purchase in all_purchased_this_year %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono"><a class="underline decoration-slate-500 sm:decoration-2" href="{% url 'edit_purchase' purchase.id %}">{{ purchase.edition.name }}</a></td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.price }}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_purchased | date:"d/m/Y" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div> </div>
{% endblock content %} {% endblock content %}

View File

@ -73,6 +73,7 @@ urlpatterns = [
{"filter": "ownership_type"}, {"filter": "ownership_type"},
name="list_sessions_by_ownership_type", name="list_sessions_by_ownership_type",
), ),
path("stats/", views.stats, name="stats_current_year"),
path( path(
"stats/<int:year>", "stats/<int:year>",
views.stats, views.stats,

View File

@ -1,10 +1,12 @@
from common.time import format_duration, now as now_with_tz
from datetime import datetime, timedelta from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from common.time import now as now_with_tz
from common.time import format_duration
from django.conf import settings from django.conf import settings
from django.db.models import Sum, F
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
from django.shortcuts import redirect, render from django.shortcuts import redirect, render
from django.urls import reverse
from typing import Callable, Any
from zoneinfo import ZoneInfo
from .forms import ( from .forms import (
GameForm, GameForm,
@ -27,6 +29,10 @@ def model_counts(request):
} }
def stats_dropdown_year_range(request):
return {"stats_dropdown_year_range": range(2018, 2024)}
def add_session(request): def add_session(request):
context = {} context = {}
initial = {} initial = {}
@ -55,6 +61,25 @@ def update_session(request, session_id=None):
return redirect("list_sessions") return redirect("list_sessions")
def use_custom_redirect(
func: Callable[..., HttpResponse]
) -> Callable[..., HttpResponse]:
"""
Will redirect to "return_path" session variable if set.
"""
def wrapper(request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
response = func(request, *args, **kwargs)
if isinstance(response, HttpResponseRedirect) and (
next_url := request.session.get("return_path")
):
return HttpResponseRedirect(next_url)
return response
return wrapper
@use_custom_redirect
def edit_session(request, session_id=None): def edit_session(request, session_id=None):
context = {} context = {}
session = Session.objects.get(id=session_id) session = Session.objects.get(id=session_id)
@ -67,6 +92,7 @@ def edit_session(request, session_id=None):
return render(request, "add_session.html", context) return render(request, "add_session.html", context)
@use_custom_redirect
def edit_purchase(request, purchase_id=None): def edit_purchase(request, purchase_id=None):
context = {} context = {}
purchase = Purchase.objects.get(id=purchase_id) purchase = Purchase.objects.get(id=purchase_id)
@ -79,6 +105,7 @@ def edit_purchase(request, purchase_id=None):
return render(request, "add.html", context) return render(request, "add.html", context)
@use_custom_redirect
def edit_game(request, game_id=None): def edit_game(request, game_id=None):
context = {} context = {}
purchase = Game.objects.get(id=game_id) purchase = Game.objects.get(id=game_id)
@ -113,9 +140,11 @@ def view_game(request, game_id=None):
context["last_session"] = context["sessions"].first() context["last_session"] = context["sessions"].first()
context["first_session"] = context["sessions"].last() context["first_session"] = context["sessions"].last()
context["sessions_with_notes"] = context["sessions"].exclude(note="") context["sessions_with_notes"] = context["sessions"].exclude(note="")
request.session["return_path"] = request.path
return render(request, "view_game.html", context) return render(request, "view_game.html", context)
@use_custom_redirect
def edit_platform(request, platform_id=None): def edit_platform(request, platform_id=None):
context = {} context = {}
purchase = Platform.objects.get(id=platform_id) purchase = Platform.objects.get(id=platform_id)
@ -128,6 +157,7 @@ def edit_platform(request, platform_id=None):
return render(request, "add.html", context) return render(request, "add.html", context)
@use_custom_redirect
def edit_edition(request, edition_id=None): def edit_edition(request, edition_id=None):
context = {} context = {}
edition = Edition.objects.get(id=edition_id) edition = Edition.objects.get(id=edition_id)
@ -140,6 +170,7 @@ def edit_edition(request, edition_id=None):
return render(request, "add.html", context) return render(request, "add.html", context)
@use_custom_redirect
def start_game_session(request, game_id: int): def start_game_session(request, game_id: int):
last_session = ( last_session = (
Session.objects.filter(purchase__edition__game_id=game_id) Session.objects.filter(purchase__edition__game_id=game_id)
@ -229,29 +260,50 @@ def list_sessions(
return render(request, "list_sessions.html", context) return render(request, "list_sessions.html", context)
def stats(request, year: int): def stats(request, year: int = 0):
selected_year = request.GET.get("year")
if selected_year:
return HttpResponseRedirect(reverse("stats_by_year", args=[selected_year]))
if year == 0:
year = now_with_tz().year
first_day_of_year = datetime(year, 1, 1) first_day_of_year = datetime(year, 1, 1)
year_sessions = Session.objects.filter(timestamp_start__gte=first_day_of_year) last_day_of_year = datetime(year + 1, 1, 1)
year_purchases = Purchase.objects.filter(session__in=year_sessions).distinct() year_sessions = Session.objects.filter(timestamp_start__year=year)
year_purchases_with_playtime = year_purchases.annotate( year_played_purchases = Purchase.objects.filter(
session__in=year_sessions
).distinct()
selected_currency = "CZK"
all_purchased_this_year = (
Purchase.objects.filter(date_purchased__year=year)
.filter(price_currency__exact=selected_currency)
.filter(date_refunded__exact=None)
.order_by("date_purchased")
)
this_year_spendings = all_purchased_this_year.aggregate(total_spent=Sum(F("price")))
total_spent = this_year_spendings["total_spent"]
games_with_playtime = (
Game.objects.filter(edition__purchase__session__in=year_sessions)
.annotate(
total_playtime=Sum( total_playtime=Sum(
F("session__duration_calculated") + F("session__duration_manual") F("edition__purchase__session__duration_calculated")
+ F("edition__purchase__session__duration_manual")
) )
) )
top_10_by_playtime = year_purchases_with_playtime.order_by("-total_playtime")[:10] .values("id", "name", "total_playtime")
for purchase in top_10_by_playtime: )
purchase.formatted_playtime = format_duration(purchase.total_playtime, "%2.0H") top_10_games_by_playtime = games_with_playtime.order_by("-total_playtime")[:10]
for game in top_10_games_by_playtime:
game["formatted_playtime"] = format_duration(game["total_playtime"], "%2.0H")
total_playtime_per_platform = ( total_playtime_per_platform = (
year_sessions.values("purchase__platform__name") # Group by platform name year_sessions.values("purchase__platform__name")
.annotate( .annotate(total_playtime=Sum(F("duration_calculated") + F("duration_manual")))
total_playtime=Sum(F("duration_calculated") + F("duration_manual")) .annotate(platform_name=F("purchase__platform__name"))
) # Sum the duration_calculated for each group .values("platform_name", "total_playtime")
.annotate(platform_name=F("purchase__platform__name")) # Rename the field .order_by("-total_playtime")
.values(
"platform_name", "total_playtime"
) # Select the renamed field and total_playtime
.order_by("-total_playtime") # Optional: Order by the renamed platform name
) )
for item in total_playtime_per_platform: for item in total_playtime_per_platform:
item["formatted_playtime"] = format_duration(item["total_playtime"], "%2.0H") item["formatted_playtime"] = format_duration(item["total_playtime"], "%2.0H")
@ -260,14 +312,20 @@ def stats(request, year: int):
"total_hours": format_duration( "total_hours": format_duration(
year_sessions.total_duration_unformatted(), "%2.0H" year_sessions.total_duration_unformatted(), "%2.0H"
), ),
"total_games": year_purchases.count(), "total_games": year_played_purchases.count(),
"total_2023_games": year_purchases.filter(edition__year_released=year).count(), "total_2023_games": year_played_purchases.filter(
"top_10_by_playtime_formatted": top_10_by_playtime, edition__year_released=year
"top_10_by_playtime": top_10_by_playtime, ).count(),
"top_10_games_by_playtime": top_10_games_by_playtime,
"year": year, "year": year,
"total_playtime_per_platform": total_playtime_per_platform, "total_playtime_per_platform": total_playtime_per_platform,
"total_spent": total_spent,
"total_spent_currency": selected_currency,
"all_purchased_this_year": all_purchased_this_year,
"spent_per_game": int(total_spent / all_purchased_this_year.count()),
} }
request.session["return_path"] = request.path
return render(request, "stats.html", context) return render(request, "stats.html", context)

View File

@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "timetracker" name = "timetracker"
version = "1.2.0" version = "1.3.0"
description = "A simple time tracker." description = "A simple time tracker."
authors = ["Lukáš Kucharczyk <lukas@kucharczyk.xyz>"] authors = ["Lukáš Kucharczyk <lukas@kucharczyk.xyz>"]
license = "GPL" license = "GPL"

View File

@ -68,6 +68,7 @@ TEMPLATES = [
"django.contrib.auth.context_processors.auth", "django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages", "django.contrib.messages.context_processors.messages",
"games.views.model_counts", "games.views.model_counts",
"games.views.stats_dropdown_year_range",
], ],
}, },
}, },