Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
37a4c9b
Merge pull request #721 from progressrpg/staging
gaidheal1 Aug 8, 2026
d2ca7f0
Merge pull request #728 from progressrpg/staging
gaidheal1 Aug 9, 2026
4567c99
Fix GitHub Pages docs deploy: remove invalid storybook build flag
gaidheal1 Aug 9, 2026
0b1ee08
Merge pull request #731 from progressrpg/fix/storybook-build-base-flag
gaidheal1 Aug 9, 2026
db68e5f
Fix Storybook build: avoid react-docgen infinite recursion in config.ts
gaidheal1 Aug 9, 2026
43f5c44
Merge pull request #732 from progressrpg/fix/storybook-docgen-stack-o…
gaidheal1 Aug 9, 2026
9f26258
Merge pull request #744 from progressrpg/staging
gaidheal1 Aug 9, 2026
ee3e482
Replace map-view badge with daily goals + completion bonus (#751)
claude Aug 10, 2026
a4f0d0d
Change dailyGoalsBadge flag from 'all' to 'testers'
gaidheal1 Aug 11, 2026
4e29ccf
Merge pull request #752 from progressrpg/feat/daily-goals-badge
gaidheal1 Aug 11, 2026
708f759
fix: standardize timer duration formatting
Harshit15-coder Aug 12, 2026
4982652
Merge pull request #768 from Harshit15-coder/main
gaidheal1 Aug 13, 2026
62d3f91
feat: derive Character.can_link instead of storing it (#682)
gaidheal1 Aug 13, 2026
91af1db
Merge pull request #769 from progressrpg/feat/character-can-link-deri…
gaidheal1 Aug 13, 2026
a8202b0
feat: broadcast activity-timer updates to a player's other sessions
gaidheal1 Aug 13, 2026
3a170ed
Merge pull request #770 from progressrpg/feat/multi-session-timer-sync
gaidheal1 Aug 13, 2026
97cf8e0
Autosave the item-name field in PlayerItemList's edit modal
claude Aug 13, 2026
d14ea30
fix: keep parent-task field editable and render subtasks as indented …
gaidheal1 Aug 13, 2026
04b8ead
Merge pull request #772 from progressrpg/feat/task-parent-editable
gaidheal1 Aug 13, 2026
c49dacf
fix: correct save-status indicator position, add saving-delay, align …
gaidheal1 Aug 13, 2026
062abc6
Merge remote-tracking branch 'origin/development' into claude/issue-7…
gaidheal1 Aug 13, 2026
89b06dc
Merge pull request #771 from progressrpg/claude/issue-763-t3l7dz
gaidheal1 Aug 13, 2026
c47dd00
feat: extend formatDueAt with weeks/months granularity
gaidheal1 Aug 13, 2026
704f8dd
Merge pull request #773 from progressrpg/feat/format-due-at-granularity
gaidheal1 Aug 13, 2026
37f09c6
fix: simplify resident line display by removing activity status
gaidheal1 Aug 13, 2026
ca7adfd
style: standardize form control dimensions and radius across components
gaidheal1 Aug 13, 2026
44ce250
test: update resident-display tests for name-only rendering
gaidheal1 Aug 13, 2026
c254fbb
feat: add offline activity logging modal
gaidheal1 Aug 13, 2026
1e82866
Merge pull request #775 from progressrpg/feat/offline-activity-logging
gaidheal1 Aug 13, 2026
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
2 changes: 1 addition & 1 deletion .github/workflows/deploy-docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ jobs:
run: cd frontend && npm ci

- name: Build Storybook
run: cd frontend && npm run build-storybook -- --base=/ProgressRPG/storybook/
run: cd frontend && npm run build-storybook

- name: Combine sites
run: |
Expand Down
2 changes: 2 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ ds:

t:
docker compose exec web python manage.py test $(t) --keepdb --buffer
tnk:
docker compose exec web python manage.py test $(t) --buffer

vt:
cd ./frontend
Expand Down
62 changes: 54 additions & 8 deletions api/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ def player_for(user) -> Player:

class TestMeViewSet(APITestCase):
def setUp(self):
self.character = Character.objects.create(given_name="Hero", can_link=True)
self.character = Character.objects.create(given_name="Hero")
self.user = create_test_user(
email="duncan@example.com",
password="pass12345",
Expand Down Expand Up @@ -194,32 +194,78 @@ def test_complete_onboarding_sets_flag(self):
self.assertTrue(player_for(self.user).onboarding_completed)
self.assertEqual(res.data, {"onboarding_completed": True})

def test_today_points_null_when_no_active_link(self):
def test_daily_goals_null_when_no_active_link(self):
self.authenticate()
player = player_for(self.user)
self.assertIsNone(player.active_link)

res = self.client.get(reverse("me-today-points"))
res = self.client.get(reverse("me-daily-goals"))

self.assertEqual(res.status_code, status.HTTP_200_OK)
self.assertIsNone(res.data["points_today"])
self.assertIsNone(res.data["goals"])

def test_today_points_reflects_todays_completed_activities(self):
def test_daily_goals_all_false_when_linked_but_no_activity_or_login(self):
self.authenticate()
player = player_for(self.user)
PlayerCharacterLink.objects.create(player=player, character=self.character)

res = self.client.get(reverse("me-daily-goals"))

self.assertEqual(res.status_code, status.HTTP_200_OK)
goals = res.data["goals"]
self.assertFalse(goals["logged_in_today"])
self.assertFalse(goals["completed_activity_today"])
self.assertEqual(goals["activity_minutes_today"], 0)
self.assertFalse(goals["minutes_goal_met"])
self.assertFalse(goals["all_goals_met"])
self.assertFalse(goals["bonus_awarded_today"])
self.assertEqual(goals["bonus_ap"], 0)

def test_daily_goals_reflects_todays_completed_activities(self):
self.authenticate()
player = player_for(self.user)
PlayerCharacterLink.objects.create(player=player, character=self.character)

PlayerActivity.objects.create(
player=player,
is_complete=True,
duration=1200, # 20 minutes
completed_at=datetime.now(timezone.utc),
)

res = self.client.get(reverse("me-daily-goals"))

self.assertEqual(res.status_code, status.HTTP_200_OK)
goals = res.data["goals"]
self.assertTrue(goals["completed_activity_today"])
self.assertEqual(goals["activity_minutes_today"], 20)
self.assertTrue(goals["minutes_goal_met"])

def test_daily_goals_all_met_awards_bonus_once(self):
from users.models import UserLogin

self.authenticate()
player = player_for(self.user)
PlayerCharacterLink.objects.create(player=player, character=self.character)
UserLogin.objects.create(user=self.user)

PlayerActivity.objects.create(
player=player,
is_complete=True,
duration=1200, # 20 minutes -> 2 points
duration=1200,
completed_at=datetime.now(timezone.utc),
)

res = self.client.get(reverse("me-today-points"))
res = self.client.get(reverse("me-daily-goals"))

self.assertEqual(res.status_code, status.HTTP_200_OK)
self.assertEqual(res.data["points_today"], 2)
goals = res.data["goals"]
self.assertTrue(goals["all_goals_met"])
# Reads never award the bonus themselves - only activity completion
# does (via check_and_award_daily_goals), so a plain GET here
# shouldn't have paid it out.
self.assertFalse(goals["bonus_awarded_today"])
self.assertEqual(goals["bonus_ap"], 0)


class CustomTokenObtainPairViewTests(APITestCase):
Expand Down
51 changes: 44 additions & 7 deletions api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -363,24 +363,61 @@ def character(self, request):

@extend_schema(
responses=inline_serializer(
name="TodayPointsResponse",
name="DailyGoalsResponse",
fields={
"points_today": drf_serializers.IntegerField(allow_null=True),
"goals": inline_serializer(
name="DailyGoalsStateResponse",
fields={
"logged_in_today": drf_serializers.BooleanField(),
"completed_activity_today": drf_serializers.BooleanField(),
"activity_minutes_today": drf_serializers.IntegerField(),
"minutes_goal_threshold": drf_serializers.IntegerField(),
"minutes_goal_met": drf_serializers.BooleanField(),
"all_goals_met": drf_serializers.BooleanField(),
"bonus_awarded_today": drf_serializers.BooleanField(),
"bonus_ap": drf_serializers.IntegerField(),
},
allow_null=True,
),
},
)
)
@action(detail=False, methods=["get"])
def today_points(self, request):
def daily_goals(self, request):
"""
Personal "points earned today" for the map view's badge (issue #673).
`points_today` is null - not zero - when the player has no active
Daily goals for the map view's badge (issue #751, replacing the old
`today_points`/`points_today` mechanic from issue #673). `goals` is
null - not a set of all-false goals - when the player has no active
PlayerCharacterLink, so the frontend can tell "no link" apart from
"linked but nothing earned yet today" and hide the badge entirely.
"linked but no goals cleared yet today" and hide the badge entirely.
"""
from progression.daily_goals import (
MINUTES_GOAL_THRESHOLD,
get_daily_goals_state,
)

player = request.user.player
link = player.active_link

return Response({"points_today": link.points_today if link else None})
if not link:
return Response({"goals": None})

state = get_daily_goals_state(player)

return Response(
{
"goals": {
"logged_in_today": state.logged_in_today,
"completed_activity_today": state.completed_activity_today,
"activity_minutes_today": state.activity_minutes_today,
"minutes_goal_threshold": MINUTES_GOAL_THRESHOLD,
"minutes_goal_met": state.minutes_goal_met,
"all_goals_met": state.all_goals_met,
"bonus_awarded_today": state.bonus_awarded_today,
"bonus_ap": state.bonus_ap,
}
}
)

@extend_schema(
responses=inline_serializer(
Expand Down
39 changes: 26 additions & 13 deletions character/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,27 @@ def get_other_members(self, obj):
return ", ".join(str(c) for c in others)


class CanLinkListFilter(admin.SimpleListFilter):
"""
can_link is a derived property, not a DB column, so it can't be listed
in list_filter directly - filter via Character.objects.linkable()
(the queryset-level equivalent) instead.
"""

title = "can link"
parameter_name = "can_link"

def lookups(self, request, model_admin):
return (("yes", "Yes"), ("no", "No"))

def queryset(self, request, queryset):
if self.value() == "yes":
return queryset.filter(pk__in=Character.objects.linkable())
if self.value() == "no":
return queryset.exclude(pk__in=Character.objects.linkable())
return queryset


@admin.action(description="Mark selected characters as NPCs and unlink from players")
def mark_as_npc(modeladmin, request, queryset):
for character in queryset:
Expand All @@ -73,17 +94,6 @@ def mark_as_npc(modeladmin, request, queryset):
)


@admin.action(description="Mark selected characters as available to link")
def mark_as_canlink(modeladmin, request, queryset):
for character in queryset:
character.can_link = True
character.save(update_fields=["can_link"])

messages.success(
request, f"{queryset.count()} character(s) marked as available to link."
)


@admin.register(Character)
class CharacterAdmin(admin.ModelAdmin):
fieldsets = (
Expand All @@ -93,6 +103,7 @@ class CharacterAdmin(admin.ModelAdmin):
"fields": (
"given_name",
"can_link",
"is_reserved",
"sex",
)
},
Expand Down Expand Up @@ -145,7 +156,8 @@ class CharacterAdmin(admin.ModelAdmin):
"birth_date",
]
list_filter = [
"can_link",
CanLinkListFilter,
"is_reserved",
"birth_date",
"death_date",
"sex",
Expand All @@ -156,6 +168,7 @@ class CharacterAdmin(admin.ModelAdmin):
"links__player__name",
]
readonly_fields = [
"can_link",
"get_player",
"get_age",
"created_at",
Expand All @@ -168,7 +181,7 @@ class CharacterAdmin(admin.ModelAdmin):
CharacterRelationshipMembershipInline,
CharacterCurrencyInline,
]
actions = [mark_as_npc, mark_as_canlink]
actions = [mark_as_npc]

@admin.display(description="Player")
def get_player(self, obj):
Expand Down
14 changes: 13 additions & 1 deletion character/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ class CharacterFilter(django_filters.FilterSet):
level = django_filters.RangeFilter(field_name="level")
xp = django_filters.RangeFilter(field_name="xp")
is_npc = django_filters.BooleanFilter(method="filter_is_npc")
can_link = django_filters.BooleanFilter(field_name="can_link")
can_link = django_filters.BooleanFilter(method="filter_can_link")

class Meta:
model = Character
Expand All @@ -21,3 +21,15 @@ def filter_is_npc(self, queryset, name, value):
else:
# is_npc=False means HAS active player link
return queryset.filter(links__is_active=True)

def filter_can_link(self, queryset, name, value):
"""
can_link is derived, not a DB column - defer to
Character.objects.linkable(), the queryset-level equivalent of
Character.can_link, instead of duplicating its logic here.
"""
linkable = Character.objects.linkable()
if value:
return queryset.filter(pk__in=linkable)
else:
return queryset.exclude(pk__in=linkable)
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Generated by Django 5.2.17 on 2026-08-13 16:31

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("character", "0020_remove_character_building"),
]

operations = [
migrations.RemoveField(
model_name="character",
name="can_link",
),
migrations.AddField(
model_name="character",
name="is_reserved",
field=models.BooleanField(
default=False,
help_text="Manually held back from linking (e.g. reserved for a future storyline), independent of age or link status.",
verbose_name="Reserved",
),
),
]
Loading
Loading