From a4c31bf40bba47f75c1ed0a7c24017a44e015f41 Mon Sep 17 00:00:00 2001 From: Boris Date: Tue, 31 Mar 2026 22:38:28 +0300 Subject: [PATCH 01/11] Add Steam library import via OpenID - Steam OpenID flow: user authenticates with Steam, we get their Steam ID - Server-side API key fetches their owned games with playtime - Import page shows full library, marks already-imported games - Imported games land in backlog as GAMES items with hours_played set - STEAM_API_KEY env var plumbed into both prod and dev containers Co-Authored-By: Claude Sonnet 4.6 --- backlogger/steam.py | 50 ++++ backlogger/templates/backlogger/list.html | 8 +- .../templates/backlogger/steam_import.html | 248 ++++++++++++++++++ backlogger/urls.py | 3 + backlogger/views.py | 73 ++++++ kboris/settings.py | 1 + 6 files changed, 382 insertions(+), 1 deletion(-) create mode 100644 backlogger/steam.py create mode 100644 backlogger/templates/backlogger/steam_import.html diff --git a/backlogger/steam.py b/backlogger/steam.py new file mode 100644 index 0000000..898ad47 --- /dev/null +++ b/backlogger/steam.py @@ -0,0 +1,50 @@ +import re +import requests +from urllib.parse import urlencode + +STEAM_OPENID_URL = 'https://steamcommunity.com/openid/login' +STEAM_API_BASE = 'https://api.steampowered.com' + + +def build_auth_url(return_to, realm): + params = { + 'openid.ns': 'http://specs.openid.net/auth/2.0', + 'openid.mode': 'checkid_setup', + 'openid.return_to': return_to, + 'openid.realm': realm, + 'openid.identity': 'http://specs.openid.net/auth/2.0/identifier_select', + 'openid.claimed_id': 'http://specs.openid.net/auth/2.0/identifier_select', + } + return f"{STEAM_OPENID_URL}?{urlencode(params)}" + + +def verify_and_get_steam_id(params): + """Verify OpenID assertion with Steam. Returns steam64 id string or None.""" + verify_params = {k: v for k, v in params.items()} + verify_params['openid.mode'] = 'check_authentication' + try: + resp = requests.post(STEAM_OPENID_URL, data=verify_params, timeout=10) + resp.raise_for_status() + except requests.RequestException: + return None + if 'is_valid:true' not in resp.text: + return None + claimed_id = params.get('openid.claimed_id', '') + match = re.search(r'/openid/id/(\d+)$', claimed_id) + return match.group(1) if match else None + + +def get_owned_games(api_key, steam_id): + """Return list of games sorted by playtime desc. Raises on API error.""" + url = f"{STEAM_API_BASE}/IPlayerService/GetOwnedGames/v1/" + params = { + 'key': api_key, + 'steamid': steam_id, + 'include_appinfo': 'true', + 'include_played_free_games': 'true', + 'format': 'json', + } + resp = requests.get(url, params=params, timeout=15) + resp.raise_for_status() + games = resp.json().get('response', {}).get('games', []) + return sorted(games, key=lambda g: g.get('playtime_forever', 0), reverse=True) diff --git a/backlogger/templates/backlogger/list.html b/backlogger/templates/backlogger/list.html index 1006a00..541dd46 100644 --- a/backlogger/templates/backlogger/list.html +++ b/backlogger/templates/backlogger/list.html @@ -164,7 +164,13 @@

Backlogger {{ items|length }}

- + Add item +
+ {% if request.GET.imported %} + ✓ {{ request.GET.imported }} game{{ request.GET.imported|pluralize }} imported + {% endif %} + ▶ Steam + + Add item +
diff --git a/backlogger/templates/backlogger/steam_import.html b/backlogger/templates/backlogger/steam_import.html new file mode 100644 index 0000000..1b43c4c --- /dev/null +++ b/backlogger/templates/backlogger/steam_import.html @@ -0,0 +1,248 @@ + + + + + + Import from Steam — Backlogger + + + + + + +
+ + {% if error %} +
{{ error }}
+
+

Want to try again?

+ + + Connect with Steam + +
+ + {% elif games %} +
+

Import from Steam {{ games|length }} games

+
+ +
+ {% csrf_token %} + +
+ + + +
+ + + + + + + + + + + {% for game in games %} + + + + + + {% endfor %} + +
GameHours played
+ {% if game.already_imported %} + + {% else %} + + {% endif %} + + {{ game.name }} + {% if game.already_imported %}already in backlog{% endif %} + + {{ game.hours }}h +
+ + +
+ + {% else %} +

Import from Steam

+
+

Connect your Steam account to import your library.

+ + + Connect with Steam + +
+ {% endif %} + +
+ + + + diff --git a/backlogger/urls.py b/backlogger/urls.py index c4978d7..b281a66 100644 --- a/backlogger/urls.py +++ b/backlogger/urls.py @@ -7,4 +7,7 @@ urlpatterns = [ path('add/', views.item_add, name='add'), path('/edit/', views.item_edit, name='edit'), path('/delete/', views.item_delete, name='delete'), + path('steam/login/', views.steam_login, name='steam_login'), + path('steam/callback/', views.steam_callback, name='steam_callback'), + path('steam/import/', views.steam_import, name='steam_import'), ] diff --git a/backlogger/views.py b/backlogger/views.py index 90c09b8..0926380 100644 --- a/backlogger/views.py +++ b/backlogger/views.py @@ -1,7 +1,10 @@ +from django.conf import settings from django.contrib.auth.decorators import login_required from django.shortcuts import render, get_object_or_404, redirect +from django.urls import reverse from .models import Item from .forms import ItemForm, SignupForm +from . import steam as steam_api def signup(request): @@ -77,3 +80,73 @@ def item_delete(request, pk): if request.method == 'POST': get_object_or_404(Item, pk=pk, user=request.user).delete() return redirect('backlogger:list') + + +@login_required +def steam_login(request): + callback = request.build_absolute_uri(reverse('backlogger:steam_callback')) + realm = f"{request.scheme}://{request.get_host()}" + return redirect(steam_api.build_auth_url(callback, realm)) + + +@login_required +def steam_callback(request): + steam_id = steam_api.verify_and_get_steam_id(request.GET.dict()) + if not steam_id: + return render(request, 'backlogger/steam_import.html', {'error': 'Steam verification failed. Please try again.'}) + + api_key = getattr(settings, 'STEAM_API_KEY', '') + if not api_key: + return render(request, 'backlogger/steam_import.html', {'error': 'Steam API key is not configured on the server.'}) + + try: + games = steam_api.get_owned_games(api_key, steam_id) + except Exception: + return render(request, 'backlogger/steam_import.html', {'error': 'Could not fetch your Steam library. Your profile may be set to private.'}) + + existing = set( + Item.objects.filter(user=request.user, category=Item.GAMES) + .values_list('name', flat=True) + ) + + game_list = [] + for g in games: + name = g.get('name', '') + hours = round(g.get('playtime_forever', 0) / 60, 1) + game_list.append({ + 'appid': g.get('appid'), + 'name': name, + 'hours': hours, + 'already_imported': name in existing, + }) + + request.session['steam_games'] = game_list + return render(request, 'backlogger/steam_import.html', {'games': game_list}) + + +@login_required +def steam_import(request): + if request.method != 'POST': + return redirect('backlogger:list') + + games_by_appid = {str(g['appid']): g for g in request.session.get('steam_games', [])} + selected = request.POST.getlist('appids') + + imported = 0 + for appid in selected: + game = games_by_appid.get(appid) + if not game or game['already_imported']: + continue + hours = game['hours'] + progress = min(100.0, hours) if hours > 0 else 0.0 + Item.objects.create( + user=request.user, + category=Item.GAMES, + name=game['name'], + hours_played=hours, + progress_percent=progress, + ) + imported += 1 + + del request.session['steam_games'] + return redirect(f"{reverse('backlogger:list')}?category=games&imported={imported}") diff --git a/kboris/settings.py b/kboris/settings.py index 0806b9d..72239a8 100644 --- a/kboris/settings.py +++ b/kboris/settings.py @@ -5,6 +5,7 @@ BASE_DIR = Path(__file__).resolve().parent.parent SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY', 'dev-insecure-key') DEBUG = os.environ.get('DEBUG', 'False').lower() == 'true' +STEAM_API_KEY = os.environ.get('STEAM_API_KEY', '') ALLOWED_HOSTS = [ 'k-boris.tech', -- 2.49.1 From ffcd8c40b48a6aed0606f9a2eaf98b3acac5e902 Mon Sep 17 00:00:00 2001 From: Boris Date: Tue, 31 Mar 2026 23:04:36 +0300 Subject: [PATCH 02/11] Add HowLongToBeat estimates to game cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fetch HLTB main/extra/completionist hours when a game item is saved - Re-fetch only when name or category changes on edit - Steam imports also fetch HLTB for each selected game - Cards show compact HLTB row: "HLTB: 40h · +extra 60h · 100% 100h" - Edit form shows HLTB breakdown as a hint next to Total hours field Co-Authored-By: Claude Sonnet 4.6 --- backlogger/hltb.py | 48 +++++++++++++++++++ .../migrations/0004_item_hltb_fields.py | 26 ++++++++++ backlogger/models.py | 5 ++ .../templates/backlogger/item_form.html | 8 ++++ backlogger/templates/backlogger/list.html | 5 ++ backlogger/views.py | 9 +++- requirements.txt | 1 + 7 files changed, 100 insertions(+), 2 deletions(-) create mode 100644 backlogger/hltb.py create mode 100644 backlogger/migrations/0004_item_hltb_fields.py diff --git a/backlogger/hltb.py b/backlogger/hltb.py new file mode 100644 index 0000000..181eb03 --- /dev/null +++ b/backlogger/hltb.py @@ -0,0 +1,48 @@ +from howlongtobeatpy import HowLongToBeat + + +def _h(val): + """Return float hours if valid, else None.""" + try: + v = float(val) + return v if v > 0 else None + except (TypeError, ValueError): + return None + + +def fetch(game_name): + """ + Search HowLongToBeat for game_name. + Returns dict with keys 'main', 'extra', 'complete' (each float or None), + or None if nothing found / on any error. + """ + try: + results = HowLongToBeat().search(game_name) + except Exception: + return None + + if not results: + return None + + best = max(results, key=lambda r: r.similarity) + if best.similarity < 0.4: + return None + + return { + 'main': _h(best.main_story), + 'extra': _h(best.main_extra), + 'complete': _h(best.completionist), + } + + +def apply_to_item(item): + """Fetch HLTB data and save it onto item. Silently does nothing on failure.""" + if item.category != 'games' or not item.name: + return + data = fetch(item.name) + if data is None: + return + item.hltb_main = data['main'] + item.hltb_extra = data['extra'] + item.hltb_complete = data['complete'] + item.save(update_fields=['hltb_main', 'hltb_extra', 'hltb_complete']) diff --git a/backlogger/migrations/0004_item_hltb_fields.py b/backlogger/migrations/0004_item_hltb_fields.py new file mode 100644 index 0000000..52576da --- /dev/null +++ b/backlogger/migrations/0004_item_hltb_fields.py @@ -0,0 +1,26 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('backlogger', '0003_assign_existing_items_to_first_user'), + ] + + operations = [ + migrations.AddField( + model_name='item', + name='hltb_main', + field=models.FloatField(blank=True, null=True), + ), + migrations.AddField( + model_name='item', + name='hltb_extra', + field=models.FloatField(blank=True, null=True), + ), + migrations.AddField( + model_name='item', + name='hltb_complete', + field=models.FloatField(blank=True, null=True), + ), + ] diff --git a/backlogger/models.py b/backlogger/models.py index eef1e25..9bac648 100644 --- a/backlogger/models.py +++ b/backlogger/models.py @@ -35,6 +35,11 @@ class Item(models.Model): watched = models.BooleanField(null=True, blank=True) duration_minutes = models.IntegerField(null=True, blank=True) + # HowLongToBeat estimates (games only) + hltb_main = models.FloatField(null=True, blank=True) + hltb_extra = models.FloatField(null=True, blank=True) + hltb_complete = models.FloatField(null=True, blank=True) + class Meta: ordering = ['-favorite', 'name'] diff --git a/backlogger/templates/backlogger/item_form.html b/backlogger/templates/backlogger/item_form.html index 5627b0f..ea5e449 100644 --- a/backlogger/templates/backlogger/item_form.html +++ b/backlogger/templates/backlogger/item_form.html @@ -188,6 +188,14 @@ {{ form.total_hours }} {{ form.total_hours.errors }} + {% if item.hltb_main or item.hltb_extra or item.hltb_complete %} +
+ HowLongToBeat: + {% if item.hltb_main %}Main {{ item.hltb_main|floatformat:0 }}h{% endif %} + {% if item.hltb_extra %} · +Extra {{ item.hltb_extra|floatformat:0 }}h{% endif %} + {% if item.hltb_complete %} · 100% {{ item.hltb_complete|floatformat:0 }}h{% endif %} +
+ {% endif %}
diff --git a/backlogger/templates/backlogger/list.html b/backlogger/templates/backlogger/list.html index 541dd46..332f99b 100644 --- a/backlogger/templates/backlogger/list.html +++ b/backlogger/templates/backlogger/list.html @@ -213,6 +213,11 @@ {% if item.hours_played is not None %} {{ item.hours_played|floatformat:1 }}h played{% if item.total_hours %} / {{ item.total_hours|floatformat:0 }}h total{% endif %} {% endif %} + {% if item.hltb_main or item.hltb_extra or item.hltb_complete %} +
+ HLTB:{% if item.hltb_main %} {{ item.hltb_main|floatformat:0 }}h{% endif %}{% if item.hltb_extra %} · +extra {{ item.hltb_extra|floatformat:0 }}h{% endif %}{% if item.hltb_complete %} · 100% {{ item.hltb_complete|floatformat:0 }}h{% endif %} +
+ {% endif %} {% elif item.category == 'books' %} {% if item.pages_read is not None %} {{ item.pages_read }} pages{% if item.total_pages %} / {{ item.total_pages }} total{% endif %} diff --git a/backlogger/views.py b/backlogger/views.py index 0926380..9805e0d 100644 --- a/backlogger/views.py +++ b/backlogger/views.py @@ -5,6 +5,7 @@ from django.urls import reverse from .models import Item from .forms import ItemForm, SignupForm from . import steam as steam_api +from . import hltb as hltb_api def signup(request): @@ -56,6 +57,7 @@ def item_add(request): item = form.save(commit=False) item.user = request.user item.save() + hltb_api.apply_to_item(item) return redirect('backlogger:list') else: form = ItemForm() @@ -68,7 +70,9 @@ def item_edit(request, pk): if request.method == 'POST': form = ItemForm(request.POST, instance=item) if form.is_valid(): - form.save() + updated = form.save() + if 'name' in form.changed_data or 'category' in form.changed_data: + hltb_api.apply_to_item(updated) return redirect('backlogger:list') else: form = ItemForm(instance=item) @@ -139,13 +143,14 @@ def steam_import(request): continue hours = game['hours'] progress = min(100.0, hours) if hours > 0 else 0.0 - Item.objects.create( + item = Item.objects.create( user=request.user, category=Item.GAMES, name=game['name'], hours_played=hours, progress_percent=progress, ) + hltb_api.apply_to_item(item) imported += 1 del request.session['steam_games'] diff --git a/requirements.txt b/requirements.txt index d4664a0..6af458c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,3 +4,4 @@ whitenoise>=6.6 requests>=2.31 beautifulsoup4>=4.12 deep-translator>=1.11 +howlongtobeatpy>=2.0 -- 2.49.1 From 095614cb65c57f39ac353871e41bdbf99e6f2aa5 Mon Sep 17 00:00:00 2001 From: Boris Date: Tue, 31 Mar 2026 23:06:45 +0300 Subject: [PATCH 03/11] Fix howlongtobeatpy version constraint (max is 1.0.21) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 6af458c..ccd0c81 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,4 +4,4 @@ whitenoise>=6.6 requests>=2.31 beautifulsoup4>=4.12 deep-translator>=1.11 -howlongtobeatpy>=2.0 +howlongtobeatpy>=1.0.21 -- 2.49.1 From da11a056edee7a3316011e531e2d61f3ced175b4 Mon Sep 17 00:00:00 2001 From: Boris Date: Wed, 1 Apr 2026 23:19:10 +0300 Subject: [PATCH 04/11] Add completed/abandoned shelves with status transitions Items can be moved to Completed or Abandoned via card buttons. Only active items appear in the default/category tabs; completed and abandoned items are visible only in their respective shelf tabs. Restore button moves items back to active. Co-Authored-By: Claude Sonnet 4.6 --- backlogger/migrations/0005_item_status.py | 20 +++++++++++++ backlogger/models.py | 10 +++++++ backlogger/templates/backlogger/list.html | 34 ++++++++++++++++++++++- backlogger/urls.py | 1 + backlogger/views.py | 18 +++++++++++- 5 files changed, 81 insertions(+), 2 deletions(-) create mode 100644 backlogger/migrations/0005_item_status.py diff --git a/backlogger/migrations/0005_item_status.py b/backlogger/migrations/0005_item_status.py new file mode 100644 index 0000000..f04592d --- /dev/null +++ b/backlogger/migrations/0005_item_status.py @@ -0,0 +1,20 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('backlogger', '0004_item_hltb_fields'), + ] + + operations = [ + migrations.AddField( + model_name='item', + name='status', + field=models.CharField( + choices=[('active', 'Active'), ('completed', 'Completed'), ('abandoned', 'Abandoned')], + default='active', + max_length=10, + ), + ), + ] diff --git a/backlogger/models.py b/backlogger/models.py index 9bac648..962cd47 100644 --- a/backlogger/models.py +++ b/backlogger/models.py @@ -14,9 +14,19 @@ class Item(models.Model): (OTHER, 'Other'), ] + ACTIVE = 'active' + COMPLETED = 'completed' + ABANDONED = 'abandoned' + STATUS_CHOICES = [ + (ACTIVE, 'Active'), + (COMPLETED, 'Completed'), + (ABANDONED, 'Abandoned'), + ] + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='items', null=True) category = models.CharField(max_length=10, choices=CATEGORY_CHOICES) + status = models.CharField(max_length=10, choices=STATUS_CHOICES, default=ACTIVE) name = models.CharField(max_length=200) progress_percent = models.FloatField(default=0.0) favorite = models.BooleanField(default=False) diff --git a/backlogger/templates/backlogger/list.html b/backlogger/templates/backlogger/list.html index 332f99b..a87801e 100644 --- a/backlogger/templates/backlogger/list.html +++ b/backlogger/templates/backlogger/list.html @@ -53,6 +53,10 @@ .btn-outline { background: transparent; color: #94a3b8; border: 1px solid #334155; } .btn-danger { background: transparent; color: #f87171; border: 1px solid #f87171; padding: 0.3rem 0.65rem; font-size: 0.78rem; } .btn-danger:hover { background: #f87171; color: #0f172a; opacity: 1; } + .btn-done { background: transparent; color: #34d399; border: 1px solid #34d399; padding: 0.3rem 0.65rem; font-size: 0.78rem; } + .btn-done:hover { background: #34d399; color: #0f172a; opacity: 1; } + .btn-abandon { background: transparent; color: #fb923c; border: 1px solid #fb923c; padding: 0.3rem 0.65rem; font-size: 0.78rem; } + .btn-abandon:hover { background: #fb923c; color: #0f172a; opacity: 1; } .filter-bar { display: flex; @@ -72,6 +76,7 @@ } .tab:hover { color: #e2e8f0; } .tab.active { color: #38bdf8; border-bottom-color: #38bdf8; } + .tab-sep { width: 1px; background: #1e293b; margin: 0.5rem 0.25rem; align-self: stretch; } .sort-wrap { padding-bottom: 0.75rem; } .sort-wrap select { @@ -175,13 +180,19 @@
+ Active + Completed + Abandoned + {% if shelf == 'active' %} + All {% for val, label in categories %} {{ label }} {% endfor %} + {% endif %}
- @@ -229,6 +240,27 @@
Edit + {% if shelf == 'active' %} +
+ {% csrf_token %} + + + +
+
+ {% csrf_token %} + + + +
+ {% else %} +
+ {% csrf_token %} + + + +
+ {% endif %}
{% csrf_token %} diff --git a/backlogger/urls.py b/backlogger/urls.py index b281a66..f817c1b 100644 --- a/backlogger/urls.py +++ b/backlogger/urls.py @@ -7,6 +7,7 @@ urlpatterns = [ path('add/', views.item_add, name='add'), path('/edit/', views.item_edit, name='edit'), path('/delete/', views.item_delete, name='delete'), + path('/status/', views.item_set_status, name='set_status'), path('steam/login/', views.steam_login, name='steam_login'), path('steam/callback/', views.steam_callback, name='steam_callback'), path('steam/import/', views.steam_import, name='steam_import'), diff --git a/backlogger/views.py b/backlogger/views.py index 9805e0d..0d18254 100644 --- a/backlogger/views.py +++ b/backlogger/views.py @@ -35,8 +35,11 @@ SORT_MAP = { def item_list(request): category = request.GET.get('category', '') sort = request.GET.get('sort', 'fav') + shelf = request.GET.get('shelf', Item.ACTIVE) + if shelf not in (Item.ACTIVE, Item.COMPLETED, Item.ABANDONED): + shelf = Item.ACTIVE - items = Item.objects.filter(user=request.user) + items = Item.objects.filter(user=request.user, status=shelf) if category: items = items.filter(category=category) items = items.order_by(*SORT_MAP.get(sort, SORT_MAP['fav'])) @@ -45,6 +48,7 @@ def item_list(request): 'items': items, 'category': category, 'sort': sort, + 'shelf': shelf, 'categories': Item.CATEGORY_CHOICES, }) @@ -79,6 +83,18 @@ def item_edit(request, pk): return render(request, 'backlogger/item_form.html', {'form': form, 'action': 'Edit', 'item': item}) +@login_required +def item_set_status(request, pk): + if request.method == 'POST': + item = get_object_or_404(Item, pk=pk, user=request.user) + new_status = request.POST.get('status') + if new_status in (Item.ACTIVE, Item.COMPLETED, Item.ABANDONED): + item.status = new_status + item.save(update_fields=['status', 'updated_at']) + next_url = request.POST.get('next') or reverse('backlogger:list') + return redirect(next_url) + + @login_required def item_delete(request, pk): if request.method == 'POST': -- 2.49.1 From b765067c5b48795baeb03b6ee4659ae5e37352d6 Mon Sep 17 00:00:00 2001 From: Boris Date: Wed, 1 Apr 2026 23:25:41 +0300 Subject: [PATCH 05/11] Add unending category for session-based games MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For games like Dota 2 that have no completion state — shows hours played on the card, reuses the games fields in the form (hours played / total hours), and gets its own cyan badge. No DB column change, only choices metadata update. Co-Authored-By: Claude Sonnet 4.6 --- .../migrations/0006_item_category_unending.py | 25 +++++++++++++++++++ backlogger/models.py | 2 ++ .../templates/backlogger/item_form.html | 4 ++- backlogger/templates/backlogger/list.html | 5 ++++ 4 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 backlogger/migrations/0006_item_category_unending.py diff --git a/backlogger/migrations/0006_item_category_unending.py b/backlogger/migrations/0006_item_category_unending.py new file mode 100644 index 0000000..1849bad --- /dev/null +++ b/backlogger/migrations/0006_item_category_unending.py @@ -0,0 +1,25 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('backlogger', '0005_item_status'), + ] + + operations = [ + migrations.AlterField( + model_name='item', + name='category', + field=models.CharField( + choices=[ + ('games', 'Games'), + ('books', 'Books'), + ('films', 'Films'), + ('unending', 'Unending'), + ('other', 'Other'), + ], + max_length=10, + ), + ), + ] diff --git a/backlogger/models.py b/backlogger/models.py index 962cd47..fd0b374 100644 --- a/backlogger/models.py +++ b/backlogger/models.py @@ -6,11 +6,13 @@ class Item(models.Model): GAMES = 'games' BOOKS = 'books' FILMS = 'films' + UNENDING = 'unending' OTHER = 'other' CATEGORY_CHOICES = [ (GAMES, 'Games'), (BOOKS, 'Books'), (FILMS, 'Films'), + (UNENDING, 'Unending'), (OTHER, 'Other'), ] diff --git a/backlogger/templates/backlogger/item_form.html b/backlogger/templates/backlogger/item_form.html index ea5e449..dbd6d6a 100644 --- a/backlogger/templates/backlogger/item_form.html +++ b/backlogger/templates/backlogger/item_form.html @@ -252,7 +252,9 @@ function updateSections() { document.querySelectorAll('.cat-section').forEach(el => el.style.display = 'none'); - const sec = document.getElementById('section-' + catSelect.value); + const cat = catSelect.value; + const secId = cat === 'unending' ? 'section-games' : 'section-' + cat; + const sec = document.getElementById(secId); if (sec) sec.style.display = 'block'; } diff --git a/backlogger/templates/backlogger/list.html b/backlogger/templates/backlogger/list.html index a87801e..9c93fe4 100644 --- a/backlogger/templates/backlogger/list.html +++ b/backlogger/templates/backlogger/list.html @@ -120,6 +120,7 @@ .badge-games { background: #3b2f6a; color: #a78bfa; } .badge-books { background: #064e3b; color: #34d399; } .badge-films { background: #431407; color: #fb923c; } + .badge-unending { background: #0c3a52; color: #38bdf8; } .badge-other { background: #1e293b; color: #94a3b8; } .star { color: #fbbf24; font-size: 0.9rem; margin-left: auto; } @@ -233,6 +234,10 @@ {% if item.pages_read is not None %} {{ item.pages_read }} pages{% if item.total_pages %} / {{ item.total_pages }} total{% endif %} {% endif %} + {% elif item.category == 'unending' %} + {% if item.hours_played is not None %} + {{ item.hours_played|floatformat:1 }}h played + {% endif %} {% elif item.category == 'films' %} {% if item.watched %}✓ Watched{% else %}○ Not watched{% endif %}{% if item.duration_minutes %} · {{ item.duration_minutes }} min{% endif %} {% endif %} -- 2.49.1 From 2e0ca22dd91ee3fb45c3326b3f2cf07014b89d6b Mon Sep 17 00:00:00 2001 From: Boris Date: Wed, 1 Apr 2026 23:32:09 +0300 Subject: [PATCH 06/11] Fix unending: shelf status, not category MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unending belongs in STATUS_CHOICES alongside completed/abandoned. Adds an Unending shelf tab and an ∞ button on active game cards. Reverts the incorrect category addition from the previous commit. Co-Authored-By: Claude Sonnet 4.6 --- ...ry_unending.py => 0006_item_status_unending.py} | 10 +++++----- backlogger/models.py | 4 ++-- backlogger/templates/backlogger/item_form.html | 4 +--- backlogger/templates/backlogger/list.html | 14 +++++++++----- backlogger/views.py | 4 ++-- 5 files changed, 19 insertions(+), 17 deletions(-) rename backlogger/migrations/{0006_item_category_unending.py => 0006_item_status_unending.py} (68%) diff --git a/backlogger/migrations/0006_item_category_unending.py b/backlogger/migrations/0006_item_status_unending.py similarity index 68% rename from backlogger/migrations/0006_item_category_unending.py rename to backlogger/migrations/0006_item_status_unending.py index 1849bad..85485ba 100644 --- a/backlogger/migrations/0006_item_category_unending.py +++ b/backlogger/migrations/0006_item_status_unending.py @@ -10,15 +10,15 @@ class Migration(migrations.Migration): operations = [ migrations.AlterField( model_name='item', - name='category', + name='status', field=models.CharField( choices=[ - ('games', 'Games'), - ('books', 'Books'), - ('films', 'Films'), + ('active', 'Active'), + ('completed', 'Completed'), + ('abandoned', 'Abandoned'), ('unending', 'Unending'), - ('other', 'Other'), ], + default='active', max_length=10, ), ), diff --git a/backlogger/models.py b/backlogger/models.py index fd0b374..fa8394b 100644 --- a/backlogger/models.py +++ b/backlogger/models.py @@ -6,23 +6,23 @@ class Item(models.Model): GAMES = 'games' BOOKS = 'books' FILMS = 'films' - UNENDING = 'unending' OTHER = 'other' CATEGORY_CHOICES = [ (GAMES, 'Games'), (BOOKS, 'Books'), (FILMS, 'Films'), - (UNENDING, 'Unending'), (OTHER, 'Other'), ] ACTIVE = 'active' COMPLETED = 'completed' ABANDONED = 'abandoned' + UNENDING = 'unending' STATUS_CHOICES = [ (ACTIVE, 'Active'), (COMPLETED, 'Completed'), (ABANDONED, 'Abandoned'), + (UNENDING, 'Unending'), ] user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='items', null=True) diff --git a/backlogger/templates/backlogger/item_form.html b/backlogger/templates/backlogger/item_form.html index dbd6d6a..ea5e449 100644 --- a/backlogger/templates/backlogger/item_form.html +++ b/backlogger/templates/backlogger/item_form.html @@ -252,9 +252,7 @@ function updateSections() { document.querySelectorAll('.cat-section').forEach(el => el.style.display = 'none'); - const cat = catSelect.value; - const secId = cat === 'unending' ? 'section-games' : 'section-' + cat; - const sec = document.getElementById(secId); + const sec = document.getElementById('section-' + catSelect.value); if (sec) sec.style.display = 'block'; } diff --git a/backlogger/templates/backlogger/list.html b/backlogger/templates/backlogger/list.html index 9c93fe4..ee7a285 100644 --- a/backlogger/templates/backlogger/list.html +++ b/backlogger/templates/backlogger/list.html @@ -120,7 +120,6 @@ .badge-games { background: #3b2f6a; color: #a78bfa; } .badge-books { background: #064e3b; color: #34d399; } .badge-films { background: #431407; color: #fb923c; } - .badge-unending { background: #0c3a52; color: #38bdf8; } .badge-other { background: #1e293b; color: #94a3b8; } .star { color: #fbbf24; font-size: 0.9rem; margin-left: auto; } @@ -184,6 +183,7 @@ Active Completed Abandoned + Unending {% if shelf == 'active' %} All @@ -234,10 +234,6 @@ {% if item.pages_read is not None %} {{ item.pages_read }} pages{% if item.total_pages %} / {{ item.total_pages }} total{% endif %} {% endif %} - {% elif item.category == 'unending' %} - {% if item.hours_played is not None %} - {{ item.hours_played|floatformat:1 }}h played - {% endif %} {% elif item.category == 'films' %} {% if item.watched %}✓ Watched{% else %}○ Not watched{% endif %}{% if item.duration_minutes %} · {{ item.duration_minutes }} min{% endif %} {% endif %} @@ -252,6 +248,14 @@ + {% if item.category == 'games' %} +
+ {% csrf_token %} + + + +
+ {% endif %}
{% csrf_token %} diff --git a/backlogger/views.py b/backlogger/views.py index 0d18254..11040ab 100644 --- a/backlogger/views.py +++ b/backlogger/views.py @@ -36,7 +36,7 @@ def item_list(request): category = request.GET.get('category', '') sort = request.GET.get('sort', 'fav') shelf = request.GET.get('shelf', Item.ACTIVE) - if shelf not in (Item.ACTIVE, Item.COMPLETED, Item.ABANDONED): + if shelf not in (Item.ACTIVE, Item.COMPLETED, Item.ABANDONED, Item.UNENDING): shelf = Item.ACTIVE items = Item.objects.filter(user=request.user, status=shelf) @@ -88,7 +88,7 @@ def item_set_status(request, pk): if request.method == 'POST': item = get_object_or_404(Item, pk=pk, user=request.user) new_status = request.POST.get('status') - if new_status in (Item.ACTIVE, Item.COMPLETED, Item.ABANDONED): + if new_status in (Item.ACTIVE, Item.COMPLETED, Item.ABANDONED, Item.UNENDING): item.status = new_status item.save(update_fields=['status', 'updated_at']) next_url = request.POST.get('next') or reverse('backlogger:list') -- 2.49.1 From 05235d234e7a6203d484a6504cbbe3660d6ec053 Mon Sep 17 00:00:00 2001 From: Boris Date: Wed, 1 Apr 2026 23:38:22 +0300 Subject: [PATCH 07/11] Add merge migration for conflicting 0006 leaves 0006_item_category_unending was already applied to the dev DB before the rename commit, causing a conflict with 0006_item_status_unending. Co-Authored-By: Claude Sonnet 4.6 --- backlogger/migrations/0007_merge.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 backlogger/migrations/0007_merge.py diff --git a/backlogger/migrations/0007_merge.py b/backlogger/migrations/0007_merge.py new file mode 100644 index 0000000..bd370ac --- /dev/null +++ b/backlogger/migrations/0007_merge.py @@ -0,0 +1,12 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('backlogger', '0006_item_category_unending'), + ('backlogger', '0006_item_status_unending'), + ] + + operations = [ + ] -- 2.49.1 From 23eefc269b6813830fe44f9a56573788fad03a83 Mon Sep 17 00:00:00 2001 From: Boris Date: Wed, 1 Apr 2026 23:46:20 +0300 Subject: [PATCH 08/11] Fix migration graph: restore missing 0006 file, add 0008 cleanup 0007_merge referenced 0006_item_category_unending which git had renamed, causing NodeNotFoundError on clean DBs. Restores the file so both 0006 leaves exist, then 0008 reverts the stale category choices back to the correct set (no unending in category). Co-Authored-By: Claude Sonnet 4.6 --- .../migrations/0006_item_category_unending.py | 25 +++++++++++++++++++ .../migrations/0008_fix_category_choices.py | 24 ++++++++++++++++++ 2 files changed, 49 insertions(+) create mode 100644 backlogger/migrations/0006_item_category_unending.py create mode 100644 backlogger/migrations/0008_fix_category_choices.py diff --git a/backlogger/migrations/0006_item_category_unending.py b/backlogger/migrations/0006_item_category_unending.py new file mode 100644 index 0000000..1849bad --- /dev/null +++ b/backlogger/migrations/0006_item_category_unending.py @@ -0,0 +1,25 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('backlogger', '0005_item_status'), + ] + + operations = [ + migrations.AlterField( + model_name='item', + name='category', + field=models.CharField( + choices=[ + ('games', 'Games'), + ('books', 'Books'), + ('films', 'Films'), + ('unending', 'Unending'), + ('other', 'Other'), + ], + max_length=10, + ), + ), + ] diff --git a/backlogger/migrations/0008_fix_category_choices.py b/backlogger/migrations/0008_fix_category_choices.py new file mode 100644 index 0000000..c042dbc --- /dev/null +++ b/backlogger/migrations/0008_fix_category_choices.py @@ -0,0 +1,24 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('backlogger', '0007_merge'), + ] + + operations = [ + migrations.AlterField( + model_name='item', + name='category', + field=models.CharField( + choices=[ + ('games', 'Games'), + ('books', 'Books'), + ('films', 'Films'), + ('other', 'Other'), + ], + max_length=10, + ), + ), + ] -- 2.49.1 From b289e7c7fee8be779f8029d497b12a2fdeb43877 Mon Sep 17 00:00:00 2001 From: Boris Date: Wed, 1 Apr 2026 23:55:47 +0300 Subject: [PATCH 09/11] Refactor item form visibility to data-attribute driven JS Replace hardcoded id-based section lookup with declarative rules: - data-show-category="games|books|films" on sections - data-hide-status="unending" on individual fields JS now has a single updateVisibility() that evaluates attributes. Adding new conditions only requires touching HTML, not JS. Also hides Progress and Total Hours for unending items. Co-Authored-By: Claude Sonnet 4.6 --- .../templates/backlogger/item_form.html | 31 ++++++++++++------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/backlogger/templates/backlogger/item_form.html b/backlogger/templates/backlogger/item_form.html index ea5e449..fce87bb 100644 --- a/backlogger/templates/backlogger/item_form.html +++ b/backlogger/templates/backlogger/item_form.html @@ -158,7 +158,7 @@ {{ form.name.errors }}
-
+
{{ form.progress_percent.value|default:0|floatformat:0 }}% @@ -175,7 +175,7 @@
-