From 4567c992be6404c63a5639b6cf9f1def07ea78b6 Mon Sep 17 00:00:00 2001 From: Duncan Appleby Date: Sun, 9 Aug 2026 16:00:23 +0100 Subject: [PATCH 01/15] Fix GitHub Pages docs deploy: remove invalid storybook build flag storybook build has no --base option (that's Vite-only), so the build-storybook step has been failing on every push to main since the mkdocs conversion, leaving the deployed site stuck on a stale pre-mkdocs Storybook-only build. The base path is already set via viteFinal in .storybook/main.ts, so the flag was redundant anyway. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01QSwWkchRGye37hoCmEfDCz --- .github/workflows/deploy-docs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index a9fd57c1..db4d8ed5 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -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: | From db68e5fe466798516a75fe34aa7db152e4edbc00 Mon Sep 17 00:00:00 2001 From: Duncan Appleby Date: Sun, 9 Aug 2026 16:07:24 +0100 Subject: [PATCH 02/15] Fix Storybook build: avoid react-docgen infinite recursion in config.ts react-docgen's resolveToValue recurses infinitely on a let variable that is conditionally assigned and then reassigned multiple times, causing "Maximum call stack size exceeded" during `npm run build-storybook`. Compute API_BASE_URL once via a helper instead of reassigning it. Co-Authored-By: Claude Sonnet 5 --- frontend/src/config.ts | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/frontend/src/config.ts b/frontend/src/config.ts index 5cf9b01f..586d481d 100644 --- a/frontend/src/config.ts +++ b/frontend/src/config.ts @@ -1,16 +1,14 @@ // src/config.ts -let API_BASE_URL: string; -const envApiBaseUrl = import.meta.env.VITE_API_BASE_URL as string | undefined; +function resolveApiBaseUrl(): string { + const envApiBaseUrl = import.meta.env.VITE_API_BASE_URL as string | undefined; -if (envApiBaseUrl) { - API_BASE_URL = envApiBaseUrl; -} else if (window.location.hostname === 'localhost') { - API_BASE_URL = 'http://localhost:8000'; -} else { - API_BASE_URL = window.location.origin; -} + const base = + envApiBaseUrl ?? + (window.location.hostname === 'localhost' + ? 'http://localhost:8000' + : window.location.origin); -API_BASE_URL = API_BASE_URL.replace(/\/api\/v1\/?$/i, ''); -API_BASE_URL = API_BASE_URL.replace(/\/$/, ''); + return base.replace(/\/api\/v1\/?$/i, '').replace(/\/$/, ''); +} -export { API_BASE_URL }; +export const API_BASE_URL = resolveApiBaseUrl(); From ee3e482c9b4b2f3d739179a59d9ff1eb8706c5be Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 18:21:10 +0000 Subject: [PATCH 03/15] Replace map-view badge with daily goals + completion bonus (#751) Replaces the truncation-prone `points_today` badge with three easy daily goals (logged in, completed an activity, 3+ minutes recorded) plus a one-off AP bonus for clearing all three in a day. - progression.daily_goals: live goal-state computation and idempotent bonus award, gated by a new DailyGoalAward(player, date) row - Wired into both activity-completion paths (ActivityTimer.complete and offline logging), not just the timer flow - GameSettings.daily_goals_completion_bonus_ap controls the bonus amount - MeViewSet.daily_goals replaces today_points with the full goal state - Removed the now-dead PlayerCharacterLink.player_time_today/points_today - Frontend: DailyGoalsBadge replaces TodayPointsBadge Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_014hFSsXRtBgoJX7HTqdmTEb --- api/tests.py | 60 +++++- api/views.py | 51 ++++- character/models/character.py | 28 --- character/tests/test_models.py | 59 ------ ...ettings_daily_goals_completion_bonus_ap.py | 21 ++ core/models.py | 9 + frontend/src/api/player.ts | 26 ++- .../ActivityInput/useActivityInput.test.ts | 4 +- .../ActivityInput/useActivityInput.ts | 4 +- .../DailyGoalsBadge.module.scss | 60 ++++++ .../DailyGoalsBadge/DailyGoalsBadge.test.tsx | 96 +++++++++ .../DailyGoalsBadge/DailyGoalsBadge.tsx | 65 ++++++ .../TodayPointsBadge.module.scss | 12 -- .../TodayPointsBadge.test.tsx | 46 ----- .../TodayPointsBadge/TodayPointsBadge.tsx | 23 --- frontend/src/featureFlags.ts | 2 +- frontend/src/hooks/usePlayer.ts | 20 +- frontend/src/pages/MapPage/MapPage.tsx | 6 +- frontend/src/types/enums.ts | 2 +- gameplay/models.py | 7 + gameplay/tests/test_activity_timer_premium.py | 1 + gameplay/tests/test_models.py | 38 ++++ progression/admin.py | 18 ++ progression/daily_goals.py | 123 +++++++++++ progression/migrations/0023_dailygoalaward.py | 48 +++++ progression/models.py | 32 +++ progression/services.py | 5 + progression/tests/test_daily_goals.py | 192 ++++++++++++++++++ .../tests/test_offline_activity_logging.py | 26 +++ 29 files changed, 876 insertions(+), 208 deletions(-) create mode 100644 core/migrations/0016_gamesettings_daily_goals_completion_bonus_ap.py create mode 100644 frontend/src/components/DailyGoalsBadge/DailyGoalsBadge.module.scss create mode 100644 frontend/src/components/DailyGoalsBadge/DailyGoalsBadge.test.tsx create mode 100644 frontend/src/components/DailyGoalsBadge/DailyGoalsBadge.tsx delete mode 100644 frontend/src/components/TodayPointsBadge/TodayPointsBadge.module.scss delete mode 100644 frontend/src/components/TodayPointsBadge/TodayPointsBadge.test.tsx delete mode 100644 frontend/src/components/TodayPointsBadge/TodayPointsBadge.tsx create mode 100644 progression/daily_goals.py create mode 100644 progression/migrations/0023_dailygoalaward.py create mode 100644 progression/tests/test_daily_goals.py diff --git a/api/tests.py b/api/tests.py index 6a4d5fe8..2e40ffba 100644 --- a/api/tests.py +++ b/api/tests.py @@ -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): diff --git a/api/views.py b/api/views.py index 65505a45..4d67fa11 100644 --- a/api/views.py +++ b/api/views.py @@ -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( diff --git a/character/models/character.py b/character/models/character.py index fab8ffc6..74286ee5 100644 --- a/character/models/character.py +++ b/character/models/character.py @@ -520,34 +520,6 @@ def link_points(self): ) return int(base_points * multiplier) - @property - def player_time_today(self): - """ - Completed activity time for this link so far today (in minutes) - - a date-filtered variant of player_time, bounded to the current UTC - day instead of the whole link lifetime. - """ - start_of_day = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0) - start = max(start_of_day, self.linked_at) - - qs = self.player.activities.filter(is_complete=True, completed_at__gte=start) - if self.unlinked_at: - qs = qs.filter(completed_at__lte=self.unlinked_at) - - total_seconds = qs.aggregate(total=Sum("duration"))["total"] or 0 - return int(total_seconds // 60) - - @property - def points_today(self): - """ - Points earned today (issue #673's map-view "today" badge) - only the - activity-time component of link_points, date-filtered to today. - link_points' other terms (days_linked * 20, login_points) aren't - "earned today" in the same sense, so they're deliberately left out - here rather than prorated. - """ - return self.player_time_today // 10 - @classmethod def get_character(cls, player: Player) -> Character: return link_services.player_link_get_character(cls, player) diff --git a/character/tests/test_models.py b/character/tests/test_models.py index 45704cff..0f803f25 100644 --- a/character/tests/test_models.py +++ b/character/tests/test_models.py @@ -506,65 +506,6 @@ def test_has_available_all_linked(self): self.assertFalse(Character.has_available()) -class PlayerCharacterLinkPointsTodayTests(TestCase): - """Tests for PlayerCharacterLink.player_time_today/points_today (issue #673).""" - - def setUp(self): - from progression.models import PlayerActivity - - self.PlayerActivity = PlayerActivity - self.user = user_factory(with_player=True) - self.player = self.user.player - character = Character.objects.create(given_name="Hero") - self.link = PlayerCharacterLink.objects.create( - player=self.player, character=character - ) - # Backdated well before "today" so player_time_today's max(start_of_day, - # linked_at) resolves to start_of_day in these tests, rather than to - # whatever moment setUp happened to run at. - self.link.linked_at = now() - timedelta(days=30) - self.link.save(update_fields=["linked_at"]) - - def _complete_activity(self, *, duration_seconds, completed_at): - return self.PlayerActivity.objects.create( - player=self.player, - is_complete=True, - duration=duration_seconds, - completed_at=completed_at, - ) - - def test_points_today_counts_only_activities_completed_today(self): - today_start = now().replace(hour=0, minute=0, second=0, microsecond=0) - self._complete_activity( - duration_seconds=1800, completed_at=today_start + timedelta(hours=2) - ) # 30 min today - self._complete_activity( - duration_seconds=3600, completed_at=today_start - timedelta(hours=1) - ) # 60 min yesterday - excluded - - self.assertEqual(self.link.player_time_today, 30) - self.assertEqual(self.link.points_today, 3) - - def test_points_today_excludes_activity_before_link_started(self): - today_start = now().replace(hour=0, minute=0, second=0, microsecond=0) - self.link.linked_at = today_start + timedelta(hours=5) - self.link.save(update_fields=["linked_at"]) - - self._complete_activity( - duration_seconds=1800, completed_at=today_start + timedelta(hours=1) - ) # today, but before the link started - excluded - self._complete_activity( - duration_seconds=600, completed_at=today_start + timedelta(hours=6) - ) # 10 min, after linked_at - - self.assertEqual(self.link.player_time_today, 10) - self.assertEqual(self.link.points_today, 1) - - def test_points_today_zero_with_no_activities(self): - self.assertEqual(self.link.player_time_today, 0) - self.assertEqual(self.link.points_today, 0) - - class CharacterTotalLinkPointsTests(TestCase): """Tests for Character.total_link_points (the character-side counterpart to Player.total_link_points).""" diff --git a/core/migrations/0016_gamesettings_daily_goals_completion_bonus_ap.py b/core/migrations/0016_gamesettings_daily_goals_completion_bonus_ap.py new file mode 100644 index 00000000..b30f306a --- /dev/null +++ b/core/migrations/0016_gamesettings_daily_goals_completion_bonus_ap.py @@ -0,0 +1,21 @@ +# Generated by Django 5.2.16 on 2026-08-10 00:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0015_featureflag"), + ] + + operations = [ + migrations.AddField( + model_name="gamesettings", + name="daily_goals_completion_bonus_ap", + field=models.IntegerField( + default=50, + help_text="Lump-sum AP awarded once per day when a player clears all three daily goals (see progression.daily_goals).", + ), + ), + ] diff --git a/core/models.py b/core/models.py index 07e9204c..da57cd33 100644 --- a/core/models.py +++ b/core/models.py @@ -90,6 +90,13 @@ class GameSettings(models.Model): max_digits=5, decimal_places=2, default="1.25" ) task_completion_xp = models.IntegerField(default=100) + daily_goals_completion_bonus_ap = models.IntegerField( + default=50, + help_text=( + "Lump-sum AP awarded once per day when a player clears all " + "three daily goals (see progression.daily_goals)." + ), + ) xp_mastery_scale = models.DecimalField( max_digits=10, decimal_places=2, @@ -162,6 +169,8 @@ def clean(self): errors["task_activity_xp_multiplier"] = "Must be > 0." if self.task_completion_xp < 0: errors["task_completion_xp"] = "Must be non-negative." + if self.daily_goals_completion_bonus_ap < 0: + errors["daily_goals_completion_bonus_ap"] = "Must be non-negative." if self.xp_mastery_scale <= 0: errors["xp_mastery_scale"] = "Must be > 0." if self.xp_mastery_multiplier_cap < 1: diff --git a/frontend/src/api/player.ts b/frontend/src/api/player.ts index b4263b44..ba23d15b 100644 --- a/frontend/src/api/player.ts +++ b/frontend/src/api/player.ts @@ -41,13 +41,25 @@ export const deleteAccount = async (): Promise => { return response; }; -export interface TodayPointsResponse { - // null (not 0) when the player has no active PlayerCharacterLink - see - // MeViewSet.today_points in api/views.py. Callers use this to hide the - // map view's "today" badge entirely rather than showing a zero (issue #673). - points_today: number | null; +export interface DailyGoalsState { + logged_in_today: boolean; + completed_activity_today: boolean; + activity_minutes_today: number; + minutes_goal_threshold: number; + minutes_goal_met: boolean; + all_goals_met: boolean; + bonus_awarded_today: boolean; + bonus_ap: number; } -export const fetchTodayPoints = async (): Promise => { - return apiFetch("/me/today_points/"); +export interface DailyGoalsResponse { + // null (not a set of all-false goals) when the player has no active + // PlayerCharacterLink - see MeViewSet.daily_goals in api/views.py. + // Callers use this to hide the map view's badge entirely rather than + // showing an unearned "0 of 3" (issue #673, redesigned in #751). + goals: DailyGoalsState | null; +} + +export const fetchDailyGoals = async (): Promise => { + return apiFetch("/me/daily_goals/"); }; diff --git a/frontend/src/components/ActivityInput/useActivityInput.test.ts b/frontend/src/components/ActivityInput/useActivityInput.test.ts index cbf94b1f..137e83e1 100644 --- a/frontend/src/components/ActivityInput/useActivityInput.test.ts +++ b/frontend/src/components/ActivityInput/useActivityInput.test.ts @@ -344,7 +344,7 @@ describe('useActivityInput unified handlers', () => { }); }); - it('invalidates the today-points query after completing an activity, so the map badge updates immediately (#673)', async () => { + it('invalidates the daily-goals query after completing an activity, so the map badge updates immediately (#673, #751)', async () => { mockGame({ status: 'active', currentActivity: { name: 'Deep work' } }); stop.mockResolvedValue({ xp_gained: 10 }); @@ -355,7 +355,7 @@ describe('useActivityInput unified handlers', () => { }); expect(invalidateQueries).toHaveBeenCalledWith({ - queryKey: ['me', 'today-points'], + queryKey: ['me', 'daily-goals'], }); }); }); diff --git a/frontend/src/components/ActivityInput/useActivityInput.ts b/frontend/src/components/ActivityInput/useActivityInput.ts index c130ef24..5e8c3739 100644 --- a/frontend/src/components/ActivityInput/useActivityInput.ts +++ b/frontend/src/components/ActivityInput/useActivityInput.ts @@ -5,7 +5,7 @@ import { useGame } from "../../hooks/useGame"; import { useEntitySearchCache } from "../../hooks/useEntitySearchCache"; import { useSupportFlow } from "../../hooks/useSupportFlow"; import { useFeatureFlag } from "../../hooks/useFeatureFlag"; -import { TODAY_POINTS_QUERY_KEY } from "../../hooks/usePlayer"; +import { DAILY_GOALS_QUERY_KEY } from "../../hooks/usePlayer"; import type { PlayerActivity } from "../../types"; import { playLimitReachedSound, primeAudio } from "../../utils/sounds"; @@ -295,7 +295,7 @@ export function useActivityInput() { fetchPlayerAndCharacter(), fetchCharacterCurrent(), fetchActivities(), - queryClient.invalidateQueries({ queryKey: TODAY_POINTS_QUERY_KEY }), + queryClient.invalidateQueries({ queryKey: DAILY_GOALS_QUERY_KEY }), completedTaskId ? queryClient.invalidateQueries({ queryKey: ["tasks"] }) : Promise.resolve(), ]); } catch (err) { diff --git a/frontend/src/components/DailyGoalsBadge/DailyGoalsBadge.module.scss b/frontend/src/components/DailyGoalsBadge/DailyGoalsBadge.module.scss new file mode 100644 index 00000000..3bdf9ece --- /dev/null +++ b/frontend/src/components/DailyGoalsBadge/DailyGoalsBadge.module.scss @@ -0,0 +1,60 @@ +@use '../../styles/semantic/colors' as c; +@use '../../styles/semantic/typography' as t; +@use '../../styles/semantic/spacing' as sp; + +.badge { + background: c.$color-bg; + border: 1px solid c.$color-border-primary; + border-radius: sp.$border-radius; + padding: sp.$spacing-xs sp.$spacing-sm; + min-width: 11rem; + @include t.apply-text-style(t.$text-body); +} + +.title { + margin: 0 0 sp.$spacing-xs; + @include t.apply-text-style(t.$text-caption); + color: c.$color-text-muted; +} + +.goalList { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.goal, +.goalMet { + display: flex; + align-items: center; + gap: sp.$spacing-xs; + @include t.apply-text-style(t.$text-list); +} + +.goal { + color: c.$color-text-muted; +} + +.goalMet { + color: c.$color-text-body; +} + +.goalMark { + color: c.$color-status-success; + width: 1em; + text-align: center; +} + +.goal .goalMark { + color: c.$color-text-disabled; +} + +.bonus { + margin: sp.$spacing-xs 0 0; + color: c.$color-status-success; + font-weight: bold; + @include t.apply-text-style(t.$text-list); +} diff --git a/frontend/src/components/DailyGoalsBadge/DailyGoalsBadge.test.tsx b/frontend/src/components/DailyGoalsBadge/DailyGoalsBadge.test.tsx new file mode 100644 index 00000000..3313ca75 --- /dev/null +++ b/frontend/src/components/DailyGoalsBadge/DailyGoalsBadge.test.tsx @@ -0,0 +1,96 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import DailyGoalsBadge from './DailyGoalsBadge'; +import type { DailyGoalsResponse } from '../../api/player'; + +const mockUseDailyGoals = vi.fn<() => { data: DailyGoalsResponse | undefined }>( + () => ({ data: { goals: null } }) +); + +vi.mock('../../hooks/usePlayer', () => ({ + useDailyGoals: () => mockUseDailyGoals(), +})); + +const baseGoals = { + logged_in_today: false, + completed_activity_today: false, + activity_minutes_today: 0, + minutes_goal_threshold: 3, + minutes_goal_met: false, + all_goals_met: false, + bonus_awarded_today: false, + bonus_ap: 0, +}; + +describe('DailyGoalsBadge', () => { + it('renders nothing when the player has no active character link', () => { + mockUseDailyGoals.mockReturnValue({ data: { goals: null } }); + + const { container } = render(); + + expect(container).toBeEmptyDOMElement(); + }); + + it('renders nothing while the query has no data yet', () => { + mockUseDailyGoals.mockReturnValue({ data: undefined }); + + const { container } = render(); + + expect(container).toBeEmptyDOMElement(); + }); + + it('shows all three goals as unmet', () => { + mockUseDailyGoals.mockReturnValue({ data: { goals: baseGoals } }); + + render(); + + expect(screen.getByText('Logged in today')).toBeInTheDocument(); + expect(screen.getByText('Completed an activity')).toBeInTheDocument(); + expect(screen.getByText('3+ minutes recorded')).toBeInTheDocument(); + expect(screen.queryByText(/AP bonus earned/)).not.toBeInTheDocument(); + expect(screen.queryByText('All goals cleared!')).not.toBeInTheDocument(); + }); + + it('shows the awarded bonus once all goals are cleared and the bonus has paid out', () => { + mockUseDailyGoals.mockReturnValue({ + data: { + goals: { + ...baseGoals, + logged_in_today: true, + completed_activity_today: true, + activity_minutes_today: 5, + minutes_goal_met: true, + all_goals_met: true, + bonus_awarded_today: true, + bonus_ap: 50, + }, + }, + }); + + render(); + + expect(screen.getByText('+50 AP bonus earned!')).toBeInTheDocument(); + }); + + it('shows a generic "cleared" message if goals are met but the bonus has not landed yet', () => { + mockUseDailyGoals.mockReturnValue({ + data: { + goals: { + ...baseGoals, + logged_in_today: true, + completed_activity_today: true, + activity_minutes_today: 5, + minutes_goal_met: true, + all_goals_met: true, + bonus_awarded_today: false, + bonus_ap: 0, + }, + }, + }); + + render(); + + expect(screen.getByText('All goals cleared!')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/DailyGoalsBadge/DailyGoalsBadge.tsx b/frontend/src/components/DailyGoalsBadge/DailyGoalsBadge.tsx new file mode 100644 index 00000000..ed841a59 --- /dev/null +++ b/frontend/src/components/DailyGoalsBadge/DailyGoalsBadge.tsx @@ -0,0 +1,65 @@ +import React from "react"; +import { useDailyGoals } from "../../hooks/usePlayer"; +import styles from "./DailyGoalsBadge.module.scss"; + +interface GoalItem { + key: string; + label: string; + met: boolean; +} + +// Personal "daily goals" badge for the map view (issue #751, replacing the +// old single-number "today" points badge from issue #673) - deliberately +// not attached to any village marker, and deliberately silent about the +// player/character link: framed as the player's own goals rather than +// naming a character, since players don't yet know about the link. +// Renders nothing (not an empty/all-unmet state) when the player has no +// active PlayerCharacterLink - see MeViewSet.daily_goals in api/views.py. +export default function DailyGoalsBadge(): React.ReactElement | null { + const { data } = useDailyGoals(); + const goals = data?.goals; + + if (goals == null) { + return null; + } + + const items: GoalItem[] = [ + { key: "login", label: "Logged in today", met: goals.logged_in_today }, + { + key: "activity", + label: "Completed an activity", + met: goals.completed_activity_today, + }, + { + key: "minutes", + label: `${goals.minutes_goal_threshold}+ minutes recorded`, + met: goals.minutes_goal_met, + }, + ]; + + return ( +
+

Today's goals

+
    + {items.map((item) => ( +
  • + + {item.label} +
  • + ))} +
+ {goals.all_goals_met && ( +

+ {goals.bonus_awarded_today + ? `+${goals.bonus_ap} AP bonus earned!` + : "All goals cleared!"} +

+ )} +
+ ); +} diff --git a/frontend/src/components/TodayPointsBadge/TodayPointsBadge.module.scss b/frontend/src/components/TodayPointsBadge/TodayPointsBadge.module.scss deleted file mode 100644 index 905ae3fe..00000000 --- a/frontend/src/components/TodayPointsBadge/TodayPointsBadge.module.scss +++ /dev/null @@ -1,12 +0,0 @@ -@use '../../styles/semantic/colors' as c; -@use '../../styles/semantic/typography' as t; -@use '../../styles/semantic/spacing' as sp; - -.badge { - background: c.$color-bg; - border: 1px solid c.$color-border-primary; - border-radius: sp.$border-radius; - padding: 0.25rem 0.6rem; - white-space: nowrap; - @include t.apply-text-style(t.$text-body); -} diff --git a/frontend/src/components/TodayPointsBadge/TodayPointsBadge.test.tsx b/frontend/src/components/TodayPointsBadge/TodayPointsBadge.test.tsx deleted file mode 100644 index c2c50430..00000000 --- a/frontend/src/components/TodayPointsBadge/TodayPointsBadge.test.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import { describe, expect, it, vi } from 'vitest'; - -import TodayPointsBadge from './TodayPointsBadge'; - -const mockUseTodayPoints = vi.fn<() => { data: { points_today: number | null } | undefined }>( - () => ({ data: { points_today: 12 } }) -); - -vi.mock('../../hooks/usePlayer', () => ({ - useTodayPoints: () => mockUseTodayPoints(), -})); - -describe('TodayPointsBadge', () => { - it("shows the player's points earned today", () => { - mockUseTodayPoints.mockReturnValue({ data: { points_today: 12 } }); - - render(); - - expect(screen.getByText('You contributed 12 today')).toBeInTheDocument(); - }); - - it('renders nothing when the player has no active character link', () => { - mockUseTodayPoints.mockReturnValue({ data: { points_today: null } }); - - const { container } = render(); - - expect(container).toBeEmptyDOMElement(); - }); - - it('renders nothing (not a zero) while the query has no data yet', () => { - mockUseTodayPoints.mockReturnValue({ data: undefined }); - - const { container } = render(); - - expect(container).toBeEmptyDOMElement(); - }); - - it('shows zero points as a real value, not treating it as "no link"', () => { - mockUseTodayPoints.mockReturnValue({ data: { points_today: 0 } }); - - render(); - - expect(screen.getByText('You contributed 0 today')).toBeInTheDocument(); - }); -}); diff --git a/frontend/src/components/TodayPointsBadge/TodayPointsBadge.tsx b/frontend/src/components/TodayPointsBadge/TodayPointsBadge.tsx deleted file mode 100644 index c2cb5cc4..00000000 --- a/frontend/src/components/TodayPointsBadge/TodayPointsBadge.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import React from "react"; -import { useTodayPoints } from "../../hooks/usePlayer"; -import styles from "./TodayPointsBadge.module.scss"; - -// Personal "points earned today" badge for the map view (issue #673) - -// deliberately not attached to any village marker, and deliberately silent -// about the player/character link: framed as "you contributed today" rather -// than naming a character, since players don't yet know about the link. -// Renders nothing (not a zero) when the player has no active -// PlayerCharacterLink - see MeViewSet.today_points in api/views.py. -export default function TodayPointsBadge(): React.ReactElement | null { - const { data } = useTodayPoints(); - - if (data?.points_today == null) { - return null; - } - - return ( -
- You contributed {data.points_today} today -
- ); -} diff --git a/frontend/src/featureFlags.ts b/frontend/src/featureFlags.ts index a20f67b1..d3251e6d 100644 --- a/frontend/src/featureFlags.ts +++ b/frontend/src/featureFlags.ts @@ -16,7 +16,7 @@ const featureFlags: Record = { unified_homepage: [], results_mode: [], map: ['testers'], - todayPointsBadge: ['all'], + dailyGoalsBadge: ['all'], }; export default featureFlags; diff --git a/frontend/src/hooks/usePlayer.ts b/frontend/src/hooks/usePlayer.ts index 6773eedf..11e12ea2 100644 --- a/frontend/src/hooks/usePlayer.ts +++ b/frontend/src/hooks/usePlayer.ts @@ -1,27 +1,27 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useNavigate } from "react-router"; -import { updatePlayer, downloadUserData, deleteAccount, fetchTodayPoints } from "../api/player"; +import { updatePlayer, downloadUserData, deleteAccount, fetchDailyGoals } from "../api/player"; import { useAuth } from "../context/AuthContext"; // Query key shared with useActivityInput's refreshAfterActivityChange, which -// invalidates it after every completed task/session so the map view's -// "today" badge (issue #673) updates immediately instead of waiting for the -// next poll. -export const TODAY_POINTS_QUERY_KEY = ["me", "today-points"]; +// invalidates it after every completed task/session so the map view's daily +// goals badge (issue #673, redesigned in #751) updates immediately instead +// of waiting for the next poll. +export const DAILY_GOALS_QUERY_KEY = ["me", "daily-goals"]; // Polled lightly (rather than one-shot like other /me/ data) so the badge // still rolls over to the new day's value on its own if the map view is left // open across midnight, without needing a dedicated push mechanism for that. -const TODAY_POINTS_POLL_INTERVAL_MS = 60_000; +const DAILY_GOALS_POLL_INTERVAL_MS = 60_000; -export function useTodayPoints() { +export function useDailyGoals() { const { isAuthenticated } = useAuth(); return useQuery({ - queryKey: TODAY_POINTS_QUERY_KEY, - queryFn: fetchTodayPoints, + queryKey: DAILY_GOALS_QUERY_KEY, + queryFn: fetchDailyGoals, enabled: isAuthenticated, - refetchInterval: TODAY_POINTS_POLL_INTERVAL_MS, + refetchInterval: DAILY_GOALS_POLL_INTERVAL_MS, }); } diff --git a/frontend/src/pages/MapPage/MapPage.tsx b/frontend/src/pages/MapPage/MapPage.tsx index 04a35d76..45e65503 100644 --- a/frontend/src/pages/MapPage/MapPage.tsx +++ b/frontend/src/pages/MapPage/MapPage.tsx @@ -5,7 +5,7 @@ import Button from "../../components/Button/Button"; import PopulationCentreMap, { type PopulationCentreMapHandle, } from "../../components/Map/Map"; -import TodayPointsBadge from "../../components/TodayPointsBadge/TodayPointsBadge"; +import DailyGoalsBadge from "../../components/DailyGoalsBadge/DailyGoalsBadge"; import { fetchPopulationCentreMap } from "../../api/map"; import FeatureToggle from "../../components/FeatureToggle"; import { @@ -136,8 +136,8 @@ export default function MapPage(): React.ReactElement { onViewportChange={handleViewportChange} worldBounds={worldBounds?.bbox} > - - + + {nextVillage && ( - ) : null} {canDelete ? ( @@ -197,12 +205,16 @@ export default function TasksPanel({ className={styles.parentSelect} disabled={hasSubtasks} defaultValue={taskItem.parent ?? ""} - onChange={(event) => - updateTask.mutate({ - id: taskItem.id, - data: { parent: event.target.value ? Number(event.target.value) : null }, - }) - } + onChange={(event) => { + saveHelpers.reportSaving(); + updateTask.mutate( + { + id: taskItem.id, + data: { parent: event.target.value ? Number(event.target.value) : null }, + }, + { onSuccess: saveHelpers.reportSaved, onError: saveHelpers.reportError }, + ); + }} > {parentOptions.map((option) => ( diff --git a/frontend/src/components/TasksPanel/useTasksPanel.tsx b/frontend/src/components/TasksPanel/useTasksPanel.tsx index 9da699f5..af963a66 100644 --- a/frontend/src/components/TasksPanel/useTasksPanel.tsx +++ b/frontend/src/components/TasksPanel/useTasksPanel.tsx @@ -245,15 +245,8 @@ export function useTasksPanel(openTaskId?: number | null, onOpenNote?: (noteId: }, []); const handleEdit = useCallback( - (task: ItemRecord, name: string, options?: { parent?: number | null; due_at?: string | null }) => { - updateTask.mutate({ - id: task.id, - data: { - name, - ...(options?.parent !== undefined ? { parent: options.parent } : {}), - ...(options?.due_at !== undefined ? { due_at: options.due_at } : {}), - }, - }); + (task: ItemRecord, name: string, callbacks?: { onSuccess?: () => void; onError?: () => void }) => { + updateTask.mutate({ id: task.id, data: { name } }, callbacks); }, [updateTask] ); diff --git a/frontend/src/hooks/useSimpleCrudPanel.ts b/frontend/src/hooks/useSimpleCrudPanel.ts index 9baa8297..e5437581 100644 --- a/frontend/src/hooks/useSimpleCrudPanel.ts +++ b/frontend/src/hooks/useSimpleCrudPanel.ts @@ -8,6 +8,11 @@ interface Entity { name: string; } +interface SaveCallbacks { + onSuccess?: () => void; + onError?: () => void; +} + interface UseSimpleCrudPanelOptions { useList: () => Pick, "data" | "isLoading">; useCreate: () => Pick>, "mutate">; @@ -41,8 +46,8 @@ export function useSimpleCrudPanel({ ); const handleEdit = useCallback( - (item: T, name: string) => { - update.mutate({ id: item.id, data: { name } as Partial }); + (item: T, name: string, callbacks?: SaveCallbacks) => { + update.mutate({ id: item.id, data: { name } as Partial }, callbacks); }, [update], ); From d14ea3088404e21113c5f90a33845c9754c6b67e Mon Sep 17 00:00:00 2001 From: Duncan Appleby Date: Thu, 13 Aug 2026 18:30:21 +0100 Subject: [PATCH 09/15] fix: keep parent-task field editable and render subtasks as indented rows - TasksPanel: the "Parent task" select was hidden entirely once a task had a parent, with no way to revert it. It now always renders (still disabled when the task itself has subtasks, to prevent nesting). - PlayerItemList: child tasks were rendered nested inside the parent's own
  • , structurally and visually boxed inside it. They now render as independent rows, siblings of other top-level items, indented via a width/margin modifier so the right edge still lines up with the parent's. - gameplay/tasks.py: fix a circular import (gameplay.utils -> gameplay.services.xp_modifiers -> gameplay.tasks -> gameplay.utils) that crashed web/celery on startup, by deferring the broadcast_activity_timer import into the two functions that use it. Fixes #765. --- .../PlayerItemList/PlayerItemList.module.scss | 17 ++--- .../PlayerItemList/PlayerItemList.test.tsx | 11 +++- .../PlayerItemList/PlayerItemList.tsx | 35 +++-------- .../components/TasksPanel/TasksPanel.test.tsx | 11 +++- .../src/components/TasksPanel/TasksPanel.tsx | 62 +++++++++---------- gameplay/tasks.py | 3 +- 6 files changed, 63 insertions(+), 76 deletions(-) diff --git a/frontend/src/components/PlayerItemList/PlayerItemList.module.scss b/frontend/src/components/PlayerItemList/PlayerItemList.module.scss index 87e9fd30..fc7a3e7e 100644 --- a/frontend/src/components/PlayerItemList/PlayerItemList.module.scss +++ b/frontend/src/components/PlayerItemList/PlayerItemList.module.scss @@ -156,19 +156,12 @@ } } -.childList { - list-style: none; - margin: sp.$spacing-xs 0 0; - padding: 0 0 0 sp.$spacing-lg; - display: flex; - flex-direction: column; - gap: sp.$spacing-xs; -} - .childItem { - background: transparent; - border-color: rgba(c.$color-border-primary, 0.12); - opacity: 0.9; + // Shrink width by the indent instead of just shifting it, so the right + // edge still lines up with the parent's — .listItem's width: 100% would + // otherwise push the box that far past the parent's right edge. + width: calc(100% - #{sp.$spacing-lg}); + margin-left: sp.$spacing-lg; } .itemCompleted { diff --git a/frontend/src/components/PlayerItemList/PlayerItemList.test.tsx b/frontend/src/components/PlayerItemList/PlayerItemList.test.tsx index 682abdd2..7cd8e76b 100644 --- a/frontend/src/components/PlayerItemList/PlayerItemList.test.tsx +++ b/frontend/src/components/PlayerItemList/PlayerItemList.test.tsx @@ -154,7 +154,7 @@ describe("PlayerItemList", () => { const child = { id: 11, name: "Child task" }; const flatItems = [parent, child]; - it("renders children nested under their parent without a top-level row", () => { + it("renders children as independent rows, indented, directly after their parent", () => { render( { expect(screen.getByRole("button", { name: "Open task Parent task" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Open task Child task" })).toBeInTheDocument(); - // The child is not rendered as its own top-level list item. + // The child is not duplicated as a second top-level entry sourced from `items`. expect(screen.getAllByRole("button", { name: /^Open task / })).toHaveLength(2); + + // Both render as siblings within the list, not one nested inside the other. + const rows = screen.getAllByRole("listitem"); + expect(rows).toHaveLength(2); + expect(rows[0]).toHaveTextContent("Parent task"); + expect(rows[1]).toHaveTextContent("Child task"); + expect(within(rows[0]).queryByText("Child task")).not.toBeInTheDocument(); }); it("is unaffected when getChildren is not passed (ProjectsPanel-style usage)", () => { diff --git a/frontend/src/components/PlayerItemList/PlayerItemList.tsx b/frontend/src/components/PlayerItemList/PlayerItemList.tsx index 53356cc3..398b2f81 100644 --- a/frontend/src/components/PlayerItemList/PlayerItemList.tsx +++ b/frontend/src/components/PlayerItemList/PlayerItemList.tsx @@ -3,7 +3,6 @@ import classNames from "classnames"; import Button from "../Button/Button"; import List from "../List/List"; -import Li from "../List/Li"; import Modal from "../Modal/Modal"; import { usePlayerItemListControls } from "./usePlayerItemListControls"; import { usePlayerItemModal } from "./usePlayerItemModal"; @@ -137,11 +136,15 @@ export default function PlayerItemList { + // Sort/filter controls only apply to top-level items; a child keeps its + // place directly after its parent (in `getChildren`'s order) rather than + // being reordered independently. + const flatDisplayItems = useMemo(() => { if (!getChildren) return displayItems; - return displayItems.filter( + const topLevel = displayItems.filter( (item) => item.id === undefined || !childIds.has(item.id) ); + return topLevel.flatMap((item) => [item, ...(getChildren(item) ?? [])]); }, [displayItems, getChildren, childIds]); const renderRow = (item: T): React.ReactNode => ( @@ -252,7 +255,7 @@ export default function PlayerItemList classNames(styles.item, { + [styles.childItem]: item.id !== undefined && childIds.has(item.id), [styles.itemCompleted]: isItemComplete?.(item), }) } - renderItem={(item) => { - const children = getChildren?.(item); - return ( - <> - {renderRow(item)} - {children?.length ? ( -
      - {children.map((child, index) => ( -
    • - {renderRow(child)} -
    • - ))} -
    - ) : null} - - ); - }} + renderItem={(item) => renderRow(item)} /> diff --git a/frontend/src/components/TasksPanel/TasksPanel.test.tsx b/frontend/src/components/TasksPanel/TasksPanel.test.tsx index 5671697c..34608e33 100644 --- a/frontend/src/components/TasksPanel/TasksPanel.test.tsx +++ b/frontend/src/components/TasksPanel/TasksPanel.test.tsx @@ -325,7 +325,7 @@ describe("TasksPanel", () => { total_records: 0, }; - it("renders a subtask nested under its parent", () => { + it("renders a subtask as an independent, indented row directly after its parent", () => { mockUseTasks.mockReturnValue({ isLoading: false, data: [parentTask, childTask], @@ -336,9 +336,14 @@ describe("TasksPanel", () => { const childButton = screen.getAllByRole("button", { name: "Edit task Child subtask" })[0]; expect(parentButton).toBeInTheDocument(); expect(childButton).toBeInTheDocument(); - // The subtask is nested inside the parent's
  • , not a sibling top-level row. + // The subtask is its own sibling row, not nested inside the parent's
  • . const parentListItem = parentButton.closest("li"); - expect(parentListItem).toContainElement(childButton); + const childListItem = childButton.closest("li"); + expect(parentListItem).not.toBe(childListItem); + expect(parentListItem).not.toContainElement(childButton); + + const rows = screen.getAllByRole("listitem"); + expect(rows.indexOf(childListItem!)).toBe(rows.indexOf(parentListItem!) + 1); }); it("hides a completed parent and its subtasks together", () => { diff --git a/frontend/src/components/TasksPanel/TasksPanel.tsx b/frontend/src/components/TasksPanel/TasksPanel.tsx index e8d64ca0..076a4a46 100644 --- a/frontend/src/components/TasksPanel/TasksPanel.tsx +++ b/frontend/src/components/TasksPanel/TasksPanel.tsx @@ -180,40 +180,38 @@ export default function TasksPanel({ )} - {taskItem.parent == null && ( -
    - - + + + - updateTask.mutate({ - id: taskItem.id, - data: { parent: event.target.value ? Number(event.target.value) : null }, - }) - } - > - - {parentOptions.map((option) => ( - - ))} - - -
    - )} + + {parentOptions.map((option) => ( + + ))} + + + ); }} diff --git a/gameplay/tasks.py b/gameplay/tasks.py index 52311e2e..e1b02306 100644 --- a/gameplay/tasks.py +++ b/gameplay/tasks.py @@ -7,7 +7,6 @@ from character.models import PlayerCharacterLink from .models import XpModifier -from .utils import broadcast_activity_timer DISCONNECT_TASK_CACHE_KEY = "disconnect_task:{player_id}" @@ -28,6 +27,7 @@ def auto_complete_timer_on_disconnect(self, player_id: int): Revoked by TimerConsumer.connect() if the player reconnects in time. """ from .models import ActivityTimer + from .utils import broadcast_activity_timer stored_task_id = cache.get(DISCONNECT_TASK_CACHE_KEY.format(player_id=player_id)) if stored_task_id != self.request.id: @@ -63,6 +63,7 @@ def auto_complete_timers_for_stale_players(): completes them the same way the disconnect grace period does. """ from .models import ActivityTimer + from .utils import broadcast_activity_timer cutoff = timezone.now() - STALE_TIMER_THRESHOLD stale_timers = ( From c49dacfa78669100f16814bd4e9741f7589c077c Mon Sep 17 00:00:00 2001 From: Duncan Appleby Date: Thu, 13 Aug 2026 18:53:48 +0100 Subject: [PATCH 10/15] fix: correct save-status indicator position, add saving-delay, align with modal actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - right: sp.$padding-base was a two-value shorthand (invalid for a single-value offset), so the browser dropped the declaration and the indicator fell back to its static (bottom-left) position, overlapping the Close button. Use $spacing-md instead. - Debounce "Saving…" by 150ms so fast autosaves go straight to "Saved" instead of flashing the interim state. - Move the indicator into the actions row and vertically center it with the Close/Delete buttons instead of anchoring to the modal's bottom edge; bump contrast (border, stronger shadow, bolder text) so it reads clearly without changing its size. Co-Authored-By: Claude Sonnet 5 --- .../PlayerItemList/PlayerItemList.module.scss | 10 ++++-- .../PlayerItemList/PlayerItemList.tsx | 2 +- .../PlayerItemList/useSaveStatus.ts | 35 +++++++++++++++---- 3 files changed, 37 insertions(+), 10 deletions(-) diff --git a/frontend/src/components/PlayerItemList/PlayerItemList.module.scss b/frontend/src/components/PlayerItemList/PlayerItemList.module.scss index 760a5243..97e5d4da 100644 --- a/frontend/src/components/PlayerItemList/PlayerItemList.module.scss +++ b/frontend/src/components/PlayerItemList/PlayerItemList.module.scss @@ -259,6 +259,7 @@ } .editConfirmActions { + position: relative; display: flex; justify-content: flex-start; align-items: center; @@ -293,14 +294,17 @@ .saveStatus { position: absolute; - right: sp.$padding-base; - bottom: sp.$spacing-sm; + right: 0; + top: 50%; + transform: translateY(-50%); padding: sp.$spacing-xs sp.$spacing-sm; border-radius: sp.$border-radius; + border: 1px solid rgba(c.$color-border-primary, 0.4); background: rgba(c.$color-bg, 0.95); - box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25); pointer-events: none; z-index: 1; + font-weight: 600; @include t.apply-text-style(t.$text-caption); animation: saveStatusFadeIn 0.15s ease; } diff --git a/frontend/src/components/PlayerItemList/PlayerItemList.tsx b/frontend/src/components/PlayerItemList/PlayerItemList.tsx index 381830e1..549a542d 100644 --- a/frontend/src/components/PlayerItemList/PlayerItemList.tsx +++ b/frontend/src/components/PlayerItemList/PlayerItemList.tsx @@ -363,8 +363,8 @@ export default function PlayerItemList ) : null} + - )} diff --git a/frontend/src/components/PlayerItemList/useSaveStatus.ts b/frontend/src/components/PlayerItemList/useSaveStatus.ts index b51370a9..077f03da 100644 --- a/frontend/src/components/PlayerItemList/useSaveStatus.ts +++ b/frontend/src/components/PlayerItemList/useSaveStatus.ts @@ -9,6 +9,9 @@ export interface SaveStatusHelpers { } const SAVED_DISPLAY_MS = 2500; +// Saves usually resolve fast enough that "Saving…" would just flash on +// screen; only show it once a save has been pending this long. +const SAVING_DISPLAY_DELAY_MS = 150; /** * Tracks a single in-flight autosave's status so a modal can show a brief @@ -19,6 +22,7 @@ const SAVED_DISPLAY_MS = 2500; export function useSaveStatus() { const [saveStatus, setSaveStatus] = useState("idle"); const hideTimerRef = useRef | null>(null); + const savingTimerRef = useRef | null>(null); const clearHideTimer = useCallback(() => { if (hideTimerRef.current) { @@ -27,28 +31,47 @@ export function useSaveStatus() { } }, []); + const clearSavingTimer = useCallback(() => { + if (savingTimerRef.current) { + clearTimeout(savingTimerRef.current); + savingTimerRef.current = null; + } + }, []); + const reportSaving = useCallback(() => { clearHideTimer(); - setSaveStatus("saving"); - }, [clearHideTimer]); + clearSavingTimer(); + savingTimerRef.current = setTimeout(() => { + savingTimerRef.current = null; + setSaveStatus("saving"); + }, SAVING_DISPLAY_DELAY_MS); + }, [clearHideTimer, clearSavingTimer]); const reportSaved = useCallback(() => { clearHideTimer(); + clearSavingTimer(); setSaveStatus("saved"); hideTimerRef.current = setTimeout(() => setSaveStatus("idle"), SAVED_DISPLAY_MS); - }, [clearHideTimer]); + }, [clearHideTimer, clearSavingTimer]); const reportError = useCallback(() => { clearHideTimer(); + clearSavingTimer(); setSaveStatus("error"); - }, [clearHideTimer]); + }, [clearHideTimer, clearSavingTimer]); const resetSaveStatus = useCallback(() => { clearHideTimer(); + clearSavingTimer(); setSaveStatus("idle"); - }, [clearHideTimer]); + }, [clearHideTimer, clearSavingTimer]); - useEffect(() => clearHideTimer, [clearHideTimer]); + useEffect(() => { + return () => { + clearHideTimer(); + clearSavingTimer(); + }; + }, [clearHideTimer, clearSavingTimer]); return { saveStatus, reportSaving, reportSaved, reportError, resetSaveStatus }; } From c47dd00d81a7c93c37da8a6673f7dc46eb7a892b Mon Sep 17 00:00:00 2001 From: Duncan Appleby Date: Thu, 13 Aug 2026 20:54:31 +0100 Subject: [PATCH 11/15] feat: extend formatDueAt with weeks/months granularity Scale due-date formatting smoothly on both sides of now: days (2-6), then weeks[, leftover days] out to 8 weeks, then months, matching the pattern already used by "last worked on". Future dates beyond ~6 months still fall back to an absolute date; past dates keep counting months uncapped. Closes #760 Co-Authored-By: Claude Sonnet 5 --- frontend/src/utils/formatUtils.test.ts | 44 ++++++++++++++++++++------ frontend/src/utils/formatUtils.ts | 33 +++++++++++++++---- 2 files changed, 60 insertions(+), 17 deletions(-) diff --git a/frontend/src/utils/formatUtils.test.ts b/frontend/src/utils/formatUtils.test.ts index c4090fe3..474acf97 100644 --- a/frontend/src/utils/formatUtils.test.ts +++ b/frontend/src/utils/formatUtils.test.ts @@ -31,16 +31,16 @@ describe("formatDueAt", () => { expect(formatDueAt(null)).toBe("-"); }); - it("labels the current day as Today", () => { - expect(formatDueAt(atLocalMidnight(0))).toBe("Today"); + it("labels the current day as today", () => { + expect(formatDueAt(atLocalMidnight(0))).toBe("today"); }); - it("labels the next day as Tomorrow", () => { - expect(formatDueAt(atLocalMidnight(1))).toBe("Tomorrow"); + it("labels the next day as tomorrow", () => { + expect(formatDueAt(atLocalMidnight(1))).toBe("tomorrow"); }); - it("labels the previous day as Yesterday", () => { - expect(formatDueAt(atLocalMidnight(-1))).toBe("Yesterday"); + it("labels the previous day as yesterday", () => { + expect(formatDueAt(atLocalMidnight(-1))).toBe("yesterday"); }); it("labels a few days out as 'in N days'", () => { @@ -51,13 +51,37 @@ describe("formatDueAt", () => { expect(formatDueAt(atLocalMidnight(-3))).toBe("3 days ago"); }); - it("labels a further-back date in weeks", () => { + it("labels a further-back date in whole weeks", () => { expect(formatDueAt(atLocalMidnight(-14))).toBe("2 weeks ago"); }); - it("labels a further-out date as an absolute weekday/day/month", () => { - // 9 days out from Wed 15 Jul 2026 is Fri 24 Jul 2026. - expect(formatDueAt(atLocalMidnight(9))).toBe("Fri 24th Jul"); + it("labels a further-out date in weeks, with a leftover-days remainder", () => { + expect(formatDueAt(atLocalMidnight(9))).toBe("in 1 week, 2 days"); + }); + + it("labels a further-out whole-week date without a days remainder", () => { + expect(formatDueAt(atLocalMidnight(14))).toBe("in 2 weeks"); + }); + + it("labels a further-back date in weeks, with a leftover-days remainder", () => { + expect(formatDueAt(atLocalMidnight(-20))).toBe("2 weeks, 6 days ago"); + }); + + it("labels a date beyond the week cutoff in months (future)", () => { + expect(formatDueAt(atLocalMidnight(60))).toBe("in 2 months"); + }); + + it("labels a date beyond the week cutoff in months (past)", () => { + expect(formatDueAt(atLocalMidnight(-60))).toBe("2 months ago"); + }); + + it("keeps counting months uncapped on the past side", () => { + expect(formatDueAt(atLocalMidnight(-200))).toBe("7 months ago"); + }); + + it("falls back to an absolute date beyond the month cap (future)", () => { + // 200 days out from Wed 15 Jul 2026 is Sun 31 Jan 2027. + expect(formatDueAt(atLocalMidnight(200))).toBe("Sun 31st Jan"); }); }); diff --git a/frontend/src/utils/formatUtils.ts b/frontend/src/utils/formatUtils.ts index d7eed39c..3bbd83b4 100644 --- a/frontend/src/utils/formatUtils.ts +++ b/frontend/src/utils/formatUtils.ts @@ -75,6 +75,21 @@ function startOfDay(date: Date): Date { return new Date(date.getFullYear(), date.getMonth(), date.getDate()); } +const WEEK_CUTOFF_DAYS = 56; // 8 weeks: weeks[, days] granularity applies up to this many days out/back +const MONTH_CUTOFF_DAYS = 180; // 6 months: beyond this on the future side, fall back to an absolute date + +function formatWeeksAndDays(totalDays: number): string { + const weeks = Math.floor(totalDays / 7); + const days = totalDays % 7; + const weeksPart = `${weeks} ${pluralize(weeks, "week")}`; + return days > 0 ? `${weeksPart}, ${days} ${pluralize(days, "day")}` : weeksPart; +} + +function formatMonths(totalDays: number): string { + const months = Math.max(1, Math.round(totalDays / 30)); + return `${months} ${pluralize(months, "month")}`; +} + export function formatDueAt(dueAt: string | null): string { if (!dueAt) return "-"; const date = new Date(dueAt); @@ -84,19 +99,23 @@ export function formatDueAt(dueAt: string | null): string { (startOfDay(date).getTime() - startOfDay(new Date()).getTime()) / (24 * 60 * 60 * 1000) ); - if (diffDays === 0) return "Today"; - if (diffDays === 1) return "Tomorrow"; - if (diffDays === -1) return "Yesterday"; + if (diffDays === 0) return "today"; + if (diffDays === 1) return "tomorrow"; + if (diffDays === -1) return "yesterday"; if (diffDays > 1 && diffDays <= 6) return `in ${diffDays} days`; if (diffDays < -1 && diffDays >= -6) return `${-diffDays} days ago`; - if (diffDays < -6) { - const weeks = Math.round(-diffDays / 7); - return `${weeks} week${weeks === 1 ? "" : "s"} ago`; + if (diffDays > 6 && diffDays <= WEEK_CUTOFF_DAYS) return `in ${formatWeeksAndDays(diffDays)}`; + if (diffDays < -6 && diffDays >= -WEEK_CUTOFF_DAYS) return `${formatWeeksAndDays(-diffDays)} ago`; + + if (diffDays < -WEEK_CUTOFF_DAYS) return `${formatMonths(-diffDays)} ago`; + + if (diffDays > WEEK_CUTOFF_DAYS && diffDays <= MONTH_CUTOFF_DAYS) { + return `in ${formatMonths(diffDays)}`; } - // diffDays > 6: further out than a week, show an absolute date. + // diffDays > MONTH_CUTOFF_DAYS: further out than ~6 months, show an absolute date. const weekday = date.toLocaleDateString(undefined, { weekday: "short" }); const month = date.toLocaleDateString(undefined, { month: "short" }); const day = date.getDate(); From 37f09c668c94004f1494cb93fdba12f645f9fdd8 Mon Sep 17 00:00:00 2001 From: Duncan Appleby Date: Thu, 13 Aug 2026 21:08:13 +0100 Subject: [PATCH 12/15] fix: simplify resident line display by removing activity status --- frontend/src/components/BuildingDetail/BuildingDetail.tsx | 3 +-- frontend/src/components/List/List.module.scss | 1 - 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/frontend/src/components/BuildingDetail/BuildingDetail.tsx b/frontend/src/components/BuildingDetail/BuildingDetail.tsx index c14c6aef..f114f6b7 100644 --- a/frontend/src/components/BuildingDetail/BuildingDetail.tsx +++ b/frontend/src/components/BuildingDetail/BuildingDetail.tsx @@ -35,8 +35,7 @@ function capitalize(word: string): string { function residentLine(resident: BuildingDetailResident): string { // "walking" overrides the scheduled activity while moving, same rule as // a character's own map tooltip (CharacterTooltipContent). - const activity = resident.isMoving ? "walking" : resident.currentActivity; - return activity ? `${resident.name} — ${activity}` : resident.name; + return resident.name; } export default function BuildingDetail({ diff --git a/frontend/src/components/List/List.module.scss b/frontend/src/components/List/List.module.scss index 09ae1119..836525a9 100644 --- a/frontend/src/components/List/List.module.scss +++ b/frontend/src/components/List/List.module.scss @@ -61,7 +61,6 @@ .canHover .listItem:hover { background: rgba(c.$color-border-primary, 0.18); - border-color: rgba(c.$color-border-primary, 0.7); @include m.box-shadow(md); z-index: 1; cursor: default; From ca7adfd26147ce7c35b773d210e173f2d79bdd1f Mon Sep 17 00:00:00 2001 From: Duncan Appleby Date: Thu, 13 Aug 2026 21:40:13 +0100 Subject: [PATCH 13/15] style: standardize form control dimensions and radius across components --- .../ActivityInput/ActivityInput.module.scss | 18 +----------------- .../EntitySearchInput.module.scss | 3 ++- .../src/components/Input/Input.module.scss | 3 ++- .../PlayerItemList/PlayerItemList.module.scss | 3 ++- .../UnifiedTimerHome.module.scss | 14 +------------- frontend/src/styles/semantic/_spacing.scss | 3 +++ 6 files changed, 11 insertions(+), 33 deletions(-) diff --git a/frontend/src/components/ActivityInput/ActivityInput.module.scss b/frontend/src/components/ActivityInput/ActivityInput.module.scss index 6a5a3d63..f4725474 100644 --- a/frontend/src/components/ActivityInput/ActivityInput.module.scss +++ b/frontend/src/components/ActivityInput/ActivityInput.module.scss @@ -4,7 +4,7 @@ @use '../../styles/semantic/spacing' as sp; @use 'sass:map'; -$row-control-height: 44px; +$row-control-height: sp.$form-control-height; .control { height: $row-control-height; display: flex; @@ -216,14 +216,6 @@ $row-control-height: 44px; } @include m.respond-to(md, down) { - .control { - height: 40px; - } - - .grow { - min-height: 40px; - } - .inputText { font-size: 1rem; line-height: 1.1; @@ -242,14 +234,6 @@ $row-control-height: 44px; } @include m.respond-to(sm, down) { - .control { - height: 38px; - } - - .grow { - min-height: 38px; - } - .inputText { font-size: 0.9rem; line-height: 1.05; diff --git a/frontend/src/components/EntitySearchInput/EntitySearchInput.module.scss b/frontend/src/components/EntitySearchInput/EntitySearchInput.module.scss index 2a60608e..48c964ef 100644 --- a/frontend/src/components/EntitySearchInput/EntitySearchInput.module.scss +++ b/frontend/src/components/EntitySearchInput/EntitySearchInput.module.scss @@ -13,9 +13,10 @@ .input { width: 100%; min-width: 0; + height: sp.$form-control-height; padding: sp.$padding-base; box-sizing: border-box; - border-radius: sp.$border-radius; + border-radius: sp.$form-control-radius; @include t.apply-text-style(t.$text-body); @include m.border-interactive( rgba(c.$color-border-primary, 0.4), diff --git a/frontend/src/components/Input/Input.module.scss b/frontend/src/components/Input/Input.module.scss index a991bf0a..6441da5e 100644 --- a/frontend/src/components/Input/Input.module.scss +++ b/frontend/src/components/Input/Input.module.scss @@ -27,6 +27,7 @@ .inputField { padding: sp.$padding-base; + height: sp.$form-control-height; box-sizing: border-box; max-width: 30rem; @include m.border-interactive( @@ -34,7 +35,7 @@ c.$color-border-primary, c.$color-border-accent ); - border-radius: sp.$border-radius; + border-radius: sp.$form-control-radius; transition: border 0.2s, background 0.2s; @include t.apply-text-style(t.$text-body); diff --git a/frontend/src/components/PlayerItemList/PlayerItemList.module.scss b/frontend/src/components/PlayerItemList/PlayerItemList.module.scss index e1162d13..d577afcd 100644 --- a/frontend/src/components/PlayerItemList/PlayerItemList.module.scss +++ b/frontend/src/components/PlayerItemList/PlayerItemList.module.scss @@ -228,9 +228,10 @@ .editInput { flex: 1; min-width: 0; + height: sp.$form-control-height; padding: sp.$spacing-xs sp.$spacing-sm; border: 2px solid rgba(c.$color-status-success, 0.45); - border-radius: sp.$border-radius; + border-radius: sp.$form-control-radius; font-size: 1em; font-family: inherit; transition: border-color 0.2s ease; diff --git a/frontend/src/components/UnifiedTimerHome/UnifiedTimerHome.module.scss b/frontend/src/components/UnifiedTimerHome/UnifiedTimerHome.module.scss index 2793e6af..db2234e2 100644 --- a/frontend/src/components/UnifiedTimerHome/UnifiedTimerHome.module.scss +++ b/frontend/src/components/UnifiedTimerHome/UnifiedTimerHome.module.scss @@ -4,7 +4,7 @@ @use '../../styles/semantic/spacing' as sp; @use 'sass:map'; -$row-control-height: 44px; +$row-control-height: sp.$form-control-height; .wrapper { position: relative; @@ -180,25 +180,13 @@ $row-control-height: 44px; } @include m.respond-to(md, down) { - .timerPill, - .ctaButton { - height: 40px; - } - .inputText { - height: 40px; font-size: 1rem; } } @include m.respond-to(sm, down) { - .timerPill, - .ctaButton { - height: 38px; - } - .inputText { - height: 38px; font-size: 0.9rem; } } diff --git a/frontend/src/styles/semantic/_spacing.scss b/frontend/src/styles/semantic/_spacing.scss index d9a3c5a1..3e326fe5 100644 --- a/frontend/src/styles/semantic/_spacing.scss +++ b/frontend/src/styles/semantic/_spacing.scss @@ -38,6 +38,9 @@ $button-padding: $spacing-xs $spacing-md; $button-radius: $spacing-md; $content-padding: $spacing-lg; +$form-control-height: 2rem; +$form-control-radius: $border-radius; + // === Gap / radius / shadow collections === $gap: ( xs: map.get(s.$spacing, xs), From 44ce250174efead2195696852fac5b554ae75fc6 Mon Sep 17 00:00:00 2001 From: Duncan Appleby Date: Thu, 13 Aug 2026 22:46:23 +0100 Subject: [PATCH 14/15] test: update resident-display tests for name-only rendering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BuildingDetail.tsx's residentLine() was simplified in 37f09c66 to drop the " — idle"/" — walking" activity-status suffix, but the tests weren't updated to match, leaving BuildingDetail.test.tsx and Map.test.tsx failing on development. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Mur33yGv419VMfAaKxjaGS --- .../src/components/BuildingDetail/BuildingDetail.test.tsx | 6 +++--- frontend/src/components/Map/Map.test.tsx | 1 - 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/BuildingDetail/BuildingDetail.test.tsx b/frontend/src/components/BuildingDetail/BuildingDetail.test.tsx index 85593148..6d986ec6 100644 --- a/frontend/src/components/BuildingDetail/BuildingDetail.test.tsx +++ b/frontend/src/components/BuildingDetail/BuildingDetail.test.tsx @@ -56,7 +56,7 @@ describe('BuildingDetail', () => { expect(screen.queryByText(/Wheat/)).not.toBeInTheDocument(); }); - it('shows each resident, with "walking" overriding their scheduled activity', () => { + it('shows each resident by name only', () => { render( { /> ); - expect(screen.getByText('Alice — idle')).toBeInTheDocument(); - expect(screen.getByText('Bob — walking')).toBeInTheDocument(); + expect(screen.getByText('Alice')).toBeInTheDocument(); + expect(screen.getByText('Bob')).toBeInTheDocument(); }); it('calls onSelectResident when a resident row is clicked', async () => { diff --git a/frontend/src/components/Map/Map.test.tsx b/frontend/src/components/Map/Map.test.tsx index 33f39f40..c17e6d9c 100644 --- a/frontend/src/components/Map/Map.test.tsx +++ b/frontend/src/components/Map/Map.test.tsx @@ -1165,7 +1165,6 @@ describe('PopulationCentreMap entity detail card', () => { const dialog = await screen.findByRole('dialog', { name: 'House 2' }); expect(dialog).toHaveTextContent('1 / 4'); expect(dialog).toHaveTextContent('Alice'); - expect(dialog).toHaveTextContent('idle'); }); it('switches to a resident\'s own detail card when clicked inside the building detail card', async () => { From c254fbbc937757103276e14d347dba439ae4321a Mon Sep 17 00:00:00 2001 From: Duncan Appleby Date: Thu, 13 Aug 2026 22:46:32 +0100 Subject: [PATCH 15/15] feat: add offline activity logging modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a "Log activity" flow (issue #704) for recording work done outside a running timer: a "+" entry point next to the activities summary opens a modal to search/enter a task, split hours+minutes duration, and a completion date/time (capped at today via the native date picker). Submits to the existing backend endpoint and surfaces its XP-eligibility messaging — the backend remains authoritative on whether XP is actually awarded; the client only does obvious sanity-checks (positive duration, required fields). Also fixes two things this surfaced along the way: - Input.tsx dropped the native `required` attribute (kept aria-required) — it was silently blocking form submission in favor of the browser's own validation UI, overriding the app's styled error messages. - Modal.module.scss's .modalContent had overflow-x: hidden with no horizontal padding, clipping the focus ring on any full-width input flush against its edge. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Mur33yGv419VMfAaKxjaGS --- frontend/src/api/activities.ts | 20 ++ .../ActivitiesPanel.module.scss | 28 +- .../ActivitiesPanel/ActivitiesPanel.test.tsx | 13 +- .../ActivitiesPanel/ActivitiesPanel.tsx | 25 +- frontend/src/components/Input/Input.tsx | 10 +- .../LogOfflineActivityModal.module.scss | 126 +++++++++ .../LogOfflineActivityModal.test.tsx | 122 +++++++++ .../LogOfflineActivityModal.tsx | 171 +++++++++++++ .../useLogOfflineActivityForm.ts | 242 ++++++++++++++++++ .../src/components/Modal/Modal.module.scss | 3 +- frontend/src/hooks/useActivities.ts | 16 +- frontend/src/types/api.ts | 11 + frontend/src/types/index.ts | 1 + 13 files changed, 778 insertions(+), 10 deletions(-) create mode 100644 frontend/src/components/LogOfflineActivityModal/LogOfflineActivityModal.module.scss create mode 100644 frontend/src/components/LogOfflineActivityModal/LogOfflineActivityModal.test.tsx create mode 100644 frontend/src/components/LogOfflineActivityModal/LogOfflineActivityModal.tsx create mode 100644 frontend/src/components/LogOfflineActivityModal/useLogOfflineActivityForm.ts diff --git a/frontend/src/api/activities.ts b/frontend/src/api/activities.ts index bbb97450..2d8a8019 100644 --- a/frontend/src/api/activities.ts +++ b/frontend/src/api/activities.ts @@ -1,7 +1,18 @@ // src/api/activities.ts import type { PlayerActivity } from "../types"; +import type { OfflineActivityLogResponse } from "../types/api"; import { apiFetch } from "../utils/api"; +export interface OfflineActivityLogPayload { + /** Name for a newly-created task; ignored (and optional) when `task` is set. */ + name?: string; + description?: string; + skill?: number; + task?: number; + started_at: string; + completed_at: string; +} + export function fetchActivities(): Promise { return (async () => { const allResults: PlayerActivity[] = []; @@ -45,3 +56,12 @@ export function deleteActivity(id: number): Promise { method: "DELETE", }); } + +export function logOfflineActivity( + data: OfflineActivityLogPayload +): Promise { + return apiFetch("/player-activities/log_offline/", { + method: "POST", + body: JSON.stringify(data), + }); +} diff --git a/frontend/src/components/ActivitiesPanel/ActivitiesPanel.module.scss b/frontend/src/components/ActivitiesPanel/ActivitiesPanel.module.scss index a1f3229b..520b000d 100644 --- a/frontend/src/components/ActivitiesPanel/ActivitiesPanel.module.scss +++ b/frontend/src/components/ActivitiesPanel/ActivitiesPanel.module.scss @@ -14,13 +14,37 @@ flex-direction: column; } -.dateTabs { +.toolbar { display: flex; + align-items: center; gap: sp.$spacing-sm; margin-bottom: sp.$spacing-md; + width: 100%; +} + +.dateTabs { + display: flex; + gap: sp.$spacing-sm; flex-wrap: wrap; justify-content: center; - width: 100%; + flex: 1; +} + +.addOfflineButton { + @include c.apply-button-variant(primary); + flex-shrink: 0; + border: none; + border-radius: 50%; + width: 2.25rem; + height: 2.25rem; + min-width: 2.25rem; + padding: 0; + font-size: 1.25rem; + line-height: 1; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; } .dateButton { diff --git a/frontend/src/components/ActivitiesPanel/ActivitiesPanel.test.tsx b/frontend/src/components/ActivitiesPanel/ActivitiesPanel.test.tsx index f1c92d91..99bfd72f 100644 --- a/frontend/src/components/ActivitiesPanel/ActivitiesPanel.test.tsx +++ b/frontend/src/components/ActivitiesPanel/ActivitiesPanel.test.tsx @@ -2,8 +2,17 @@ import { render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { TooltipProvider } from "../Tooltip/Tooltip"; import ActivitiesPanel from "./ActivitiesPanel"; +function renderActivitiesPanel() { + return render( + + + + ); +} + const mockUseActivities = vi.fn(); const mockUseDeleteActivity = vi.fn(); const mockUseUpdateActivity = vi.fn(); @@ -39,7 +48,7 @@ describe("ActivitiesPanel", () => { it("renders activities and delegates edit through PlayerItemList", async () => { const user = userEvent.setup(); - render(); + renderActivitiesPanel(); expect(screen.getByText("Write docs")).toBeInTheDocument(); @@ -59,7 +68,7 @@ describe("ActivitiesPanel", () => { it("delegates delete confirmation through PlayerItemList", async () => { const user = userEvent.setup(); - render(); + renderActivitiesPanel(); await user.click(screen.getByRole("button", { name: "Open activity Write docs" })); await user.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Delete" })); diff --git a/frontend/src/components/ActivitiesPanel/ActivitiesPanel.tsx b/frontend/src/components/ActivitiesPanel/ActivitiesPanel.tsx index 39ef6a07..72e3c820 100644 --- a/frontend/src/components/ActivitiesPanel/ActivitiesPanel.tsx +++ b/frontend/src/components/ActivitiesPanel/ActivitiesPanel.tsx @@ -6,6 +6,8 @@ import { formatDurationShort, pluralize } from "../../utils/formatUtils"; import Button from "../Button/Button"; import PlayerItemList from "../PlayerItemList/PlayerItemList"; +import Tooltip from "../Tooltip/Tooltip"; +import LogOfflineActivityModal from "../LogOfflineActivityModal/LogOfflineActivityModal"; import styles from "./ActivitiesPanel.module.scss"; type DateCategory = "today" | "yesterday" | "older"; @@ -68,6 +70,7 @@ export default function ActivitiesPanel(): React.ReactElement | null { const deleteActivity = useDeleteActivity(); const updateActivity = useUpdateActivity(); const [activeTab, setActiveTab] = useState("today"); + const [isLogModalOpen, setIsLogModalOpen] = useState(false); const bucketed = useMemo(() => bucketActivities(activities ?? []), [activities]); @@ -127,8 +130,8 @@ export default function ActivitiesPanel(): React.ReactElement | null { return (
    - {hasActivities && ( - <> +
    + {hasActivities && (
    {dateTabs.map(({ key, label }) => (
    + )} + + + +
    + {isLogModalOpen && ( + setIsLogModalOpen(false)} /> + )} + + {hasActivities && ( + <> {hasTabActivities ? (
    {Object.entries(activitiesByDay).map(([dateKey, dayActivities]) => { diff --git a/frontend/src/components/Input/Input.tsx b/frontend/src/components/Input/Input.tsx index b95a9176..c0b9343b 100644 --- a/frontend/src/components/Input/Input.tsx +++ b/frontend/src/components/Input/Input.tsx @@ -23,6 +23,7 @@ function EyeOffIcon() { interface InputProps { id: string; label?: string; + ariaLabel?: string; type?: string; value?: string; onChange?: (value: string | boolean) => void; @@ -34,6 +35,8 @@ interface InputProps { checked?: boolean; minLength?: number; maxLength?: number; + min?: string | number; + max?: string | number; className?: string; inputClassName?: string; disabled?: boolean; @@ -44,6 +47,7 @@ interface InputProps { export default function Input({ id, label, + ariaLabel, type = 'text', value, onChange, @@ -55,6 +59,8 @@ export default function Input({ checked, minLength, maxLength, + min, + max, className, inputClassName, disabled = false, @@ -91,13 +97,15 @@ export default function Input({ onBlur={onBlur} onKeyDown={onKeyDown} placeholder={placeholder} + aria-label={!label ? ariaLabel : undefined} aria-invalid={!!error} aria-describedby={describedBy} aria-required={required} autoComplete={autoComplete} - required={required} minLength={minLength} maxLength={maxLength} + min={min} + max={max} disabled={disabled} /> ); diff --git a/frontend/src/components/LogOfflineActivityModal/LogOfflineActivityModal.module.scss b/frontend/src/components/LogOfflineActivityModal/LogOfflineActivityModal.module.scss new file mode 100644 index 00000000..8c7e4788 --- /dev/null +++ b/frontend/src/components/LogOfflineActivityModal/LogOfflineActivityModal.module.scss @@ -0,0 +1,126 @@ +@use '../../styles/semantic/spacing' as sp; +@use '../../styles/semantic/typography' as t; +@use '../../styles/semantic/colors' as c; +@use '../../styles/utilities/mixins' as m; + +.form { + display: flex; + flex-direction: column; + gap: sp.$spacing-md; + width: 100%; +} + +.field { + display: flex; + flex-direction: column; + gap: sp.$spacing-xs; +} + +.label { + @include t.apply-text-style(t.$text-body); + font-weight: 600; +} + +.required { + color: c.$color-error; +} + +.row { + display: flex; + gap: sp.$spacing-sm; + width: 100%; + + > * { + flex: 1; + min-width: 0; + } +} + +.durationPartField { + flex: 0 0 auto; + width: 4.5rem; + max-width: 4.5rem; + + input[type='number'] { + appearance: textfield; + -moz-appearance: textfield; + + &::-webkit-outer-spin-button, + &::-webkit-inner-spin-button { + appearance: none; + -webkit-appearance: none; + margin: 0; + } + } +} + +.taskInput { + width: 100%; +} + +.durationPreview { + @include t.apply-text-style(t.$text-body); + opacity: 0.75; + margin: 0; +} + +.errorText { + @include t.apply-text-style(t.$text-body); + color: c.$color-error; + margin: 0; +} + +.eligibilityBanner { + @include t.apply-text-style(t.$text-body); + margin: 0; + padding: sp.$spacing-sm sp.$spacing-md; + border-radius: sp.$border-radius; + border: 1px solid transparent; + + &.info { + background: rgba(c.$color-status-info, 0.1); + border-color: rgba(c.$color-status-info, 0.4); + } + + &.success { + background: rgba(c.$color-status-success, 0.1); + border-color: rgba(c.$color-status-success, 0.4); + } + + &.warning { + background: rgba(c.$color-warning, 0.12); + border-color: rgba(c.$color-warning, 0.4); + } +} + +.actions { + display: flex; + justify-content: flex-end; + gap: sp.$spacing-sm; + margin-top: sp.$spacing-sm; +} + +.confirmation { + display: flex; + flex-direction: column; + gap: sp.$spacing-md; + width: 100%; + text-align: center; +} + +.confirmationHeadline { + @include t.apply-text-style(t.$text-body); + margin: 0; +} + +.confirmationActions { + display: flex; + justify-content: center; + gap: sp.$spacing-sm; +} + +@include m.respond-to(sm, down) { + .row { + flex-direction: column; + } +} diff --git a/frontend/src/components/LogOfflineActivityModal/LogOfflineActivityModal.test.tsx b/frontend/src/components/LogOfflineActivityModal/LogOfflineActivityModal.test.tsx new file mode 100644 index 00000000..b8e3763b --- /dev/null +++ b/frontend/src/components/LogOfflineActivityModal/LogOfflineActivityModal.test.tsx @@ -0,0 +1,122 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { TooltipProvider } from "../Tooltip/Tooltip"; +import LogOfflineActivityModal from "./LogOfflineActivityModal"; + +const logMutate = vi.fn(); +const fetchPlayerAndCharacter = vi.fn(); +let gameValue: Record; + +vi.mock("../../hooks/useActivities", () => ({ + useLogOfflineActivity: () => ({ mutate: logMutate, isPending: false }), +})); + +vi.mock("../../hooks/useGame", () => ({ + useGame: () => gameValue, +})); + +// Stub the autocomplete input so these tests don't depend on the search cache. +vi.mock("../EntitySearchInput/EntitySearchInput", () => ({ + default: ({ + value, + onChange, + placeholder, + }: { + value: string; + onChange?: (v: string) => void; + placeholder?: string; + }) => ( + onChange?.(event.target.value)} + /> + ), +})); + +function renderModal(onClose = vi.fn()) { + return render( + + + + ); +} + +describe("LogOfflineActivityModal", () => { + beforeEach(() => { + logMutate.mockReset(); + fetchPlayerAndCharacter.mockReset(); + gameValue = { player: { is_premium: true }, fetchPlayerAndCharacter }; + }); + + it("blocks submission and shows errors when required fields are missing", async () => { + const user = userEvent.setup(); + renderModal(); + + await user.click(screen.getByRole("button", { name: "Log activity" })); + + expect(await screen.findByText("Enter or select a task.")).toBeInTheDocument(); + expect(screen.getByText("Enter a duration greater than zero.")).toBeInTheDocument(); + expect(logMutate).not.toHaveBeenCalled(); + }); + + it("submits a valid entry and shows the XP confirmation", async () => { + const user = userEvent.setup(); + renderModal(); + + await user.type(screen.getByLabelText("Task"), "Write docs"); + await user.type(screen.getByLabelText("Minutes"), "30"); + + await user.click(screen.getByRole("button", { name: "Log activity" })); + + await waitFor(() => expect(logMutate).toHaveBeenCalledTimes(1)); + const [payload, callbacks] = logMutate.mock.calls[0]; + expect(payload.name).toBe("Write docs"); + expect(payload.started_at < payload.completed_at).toBe(true); + + callbacks.onSuccess({ + success: true, + message: "Activity logged", + activity: { name: "Write docs" }, + xp_gained: 42, + xp_eligible_seconds: 1800, + level_ups: [], + }); + + expect(await screen.findByText("+42 XP awarded")).toBeInTheDocument(); + expect(fetchPlayerAndCharacter).toHaveBeenCalled(); + }); + + it("surfaces the backend error message on failure", async () => { + const user = userEvent.setup(); + renderModal(); + + await user.type(screen.getByLabelText("Task"), "Write docs"); + await user.type(screen.getByLabelText("Minutes"), "30"); + await user.click(screen.getByRole("button", { name: "Log activity" })); + + await waitFor(() => expect(logMutate).toHaveBeenCalledTimes(1)); + const [, callbacks] = logMutate.mock.calls[0]; + + callbacks.onError(new Error(JSON.stringify({ success: false, message: "Daily limit reached." }))); + + expect(await screen.findByText("Daily limit reached.")).toBeInTheDocument(); + }); + + it("warns free-tier users that XP won't be awarded", async () => { + gameValue = { player: { is_premium: false }, fetchPlayerAndCharacter }; + const user = userEvent.setup(); + renderModal(); + + await user.type(screen.getByLabelText("Minutes"), "10"); + + expect( + await screen.findByText( + "This will be recorded, but offline activities only earn XP for Premium accounts." + ) + ).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/LogOfflineActivityModal/LogOfflineActivityModal.tsx b/frontend/src/components/LogOfflineActivityModal/LogOfflineActivityModal.tsx new file mode 100644 index 00000000..bd3f6c83 --- /dev/null +++ b/frontend/src/components/LogOfflineActivityModal/LogOfflineActivityModal.tsx @@ -0,0 +1,171 @@ +import classNames from "classnames"; + +import Modal from "../Modal/Modal"; +import Button from "../Button/Button"; +import Input from "../Input/Input"; +import EntitySearchInput from "../EntitySearchInput/EntitySearchInput"; +import { formatDurationShort } from "../../utils/formatUtils"; +import { useLogOfflineActivityForm } from "./useLogOfflineActivityForm"; +import styles from "./LogOfflineActivityModal.module.scss"; + +interface LogOfflineActivityModalProps { + onClose: () => void; +} + +export default function LogOfflineActivityModal({ onClose }: LogOfflineActivityModalProps) { + const { + taskName, + handleTaskNameChange, + handleTaskSelect, + durationHours, + setDurationHours, + durationMinutes, + setDurationMinutes, + totalDurationMinutes, + completionDate, + handleCompletionDateChange, + completionTime, + handleCompletionTimeChange, + maxCompletionDate, + fieldErrors, + submitError, + result, + eligibility, + isSubmitting, + handleSubmit, + reset, + } = useLogOfflineActivityForm(); + + if (result) { + return ( + +
    +

    + “{result.activity.name}” has been recorded. +

    + {result.xp_gained > 0 ? ( +

    + +{result.xp_gained} XP awarded +

    + ) : ( +

    + No XP was awarded for this activity. +

    + )} +
    + + +
    +
    +
    + ); + } + + return ( + +
    +
    + + Task * + + + {fieldErrors.task && ( +

    {fieldErrors.task}

    + )} +
    + +
    + + Duration * + +
    + setDurationHours(value as string)} + className={styles.durationPartField} + /> + setDurationMinutes((value as string).slice(0, 2))} + maxLength={2} + className={styles.durationPartField} + /> +
    + {fieldErrors.duration && ( +

    {fieldErrors.duration}

    + )} +
    + +
    + Completed +
    + handleCompletionDateChange(value as string)} + max={maxCompletionDate} + /> + handleCompletionTimeChange(value as string)} + /> +
    + {fieldErrors.completedAt && ( +

    {fieldErrors.completedAt}

    + )} +
    + + {!Number.isNaN(totalDurationMinutes) && totalDurationMinutes > 0 && ( +

    + Logging {formatDurationShort(Math.round(totalDurationMinutes * 60))} +

    + )} + + {eligibility && ( +

    + {eligibility.message} +

    + )} + + {submitError && ( +

    {submitError}

    + )} + +
    + + +
    +
    +
    + ); +} diff --git a/frontend/src/components/LogOfflineActivityModal/useLogOfflineActivityForm.ts b/frontend/src/components/LogOfflineActivityModal/useLogOfflineActivityForm.ts new file mode 100644 index 00000000..03d24d1d --- /dev/null +++ b/frontend/src/components/LogOfflineActivityModal/useLogOfflineActivityForm.ts @@ -0,0 +1,242 @@ +import { useCallback, useMemo, useState } from "react"; + +import { useLogOfflineActivity } from "../../hooks/useActivities"; +import { useGame } from "../../hooks/useGame"; +import type { SearchEntity } from "../EntitySearchInput/useEntitySearchInput"; +import type { OfflineActivityLogResponse } from "../../types"; + +// Must match OFFLINE_XP_ELIGIBLE_BACKDATE_WINDOW in progression/services.py — +// duplicated here only for the client-side "will this earn XP" hint; the +// backend remains the authority on whether XP is actually awarded. +const XP_ELIGIBLE_BACKDATE_DAYS = 7; + +function pad(n: number): string { + return String(n).padStart(2, "0"); +} + +function todayDateValue(): string { + const now = new Date(); + return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`; +} + +function nowTimeValue(): string { + const now = new Date(); + return `${pad(now.getHours())}:${pad(now.getMinutes())}`; +} + +function extractErrorMessage(error: unknown): string { + if (error instanceof Error && error.message) { + try { + const parsed = JSON.parse(error.message) as Record; + if (typeof parsed.message === "string") return parsed.message; + + const firstFieldError = Object.values(parsed).find( + (value): value is string[] => + Array.isArray(value) && typeof value[0] === "string" + ); + if (firstFieldError) return firstFieldError[0]; + } catch { + // Not a JSON error body — fall back to the raw message below. + } + return error.message; + } + return "Something went wrong logging this activity. Please try again."; +} + +export interface FieldErrors { + task?: string; + duration?: string; + completedAt?: string; +} + +export function useLogOfflineActivityForm(onLogged?: () => void) { + const { player, fetchPlayerAndCharacter } = useGame(); + const isPremium = Boolean(player?.is_premium); + const logOfflineActivity = useLogOfflineActivity(); + + const [taskName, setTaskName] = useState(""); + const [selectedTaskId, setSelectedTaskId] = useState(null); + const [durationHours, setDurationHours] = useState(""); + const [durationMinutes, setDurationMinutes] = useState(""); + const [completionDate, setCompletionDate] = useState(todayDateValue); + const [completionTime, setCompletionTime] = useState(nowTimeValue); + // Fixed at mount: caps the native date picker so future dates can't be + // selected at all, rather than allowing them and erroring afterwards. + const [maxCompletionDate] = useState(todayDateValue); + const [fieldErrors, setFieldErrors] = useState({}); + const [submitError, setSubmitError] = useState(null); + const [result, setResult] = useState(null); + // Read once at mount (a lazy initializer, not a render-phase call) and + // refreshed by the date/time change handlers below — those are event + // handlers, where reading the clock is fine. + const [nowMs, setNowMs] = useState(() => Date.now()); + + const handleCompletionDateChange = useCallback((value: string) => { + setCompletionDate(value); + setNowMs(Date.now()); + }, []); + + const handleCompletionTimeChange = useCallback((value: string) => { + setCompletionTime(value); + setNowMs(Date.now()); + }, []); + + const handleTaskNameChange = useCallback((value: string) => { + setTaskName(value); + setSelectedTaskId(null); + }, []); + + const handleTaskSelect = useCallback((entity: SearchEntity) => { + setTaskName(entity.name); + setSelectedTaskId(typeof entity.id === "number" ? entity.id : Number(entity.id)); + }, []); + + const totalDurationMinutes = useMemo(() => { + const hours = Number(durationHours || 0); + const minutes = Number(durationMinutes || 0); + if (Number.isNaN(hours) || Number.isNaN(minutes)) return NaN; + return hours * 60 + minutes; + }, [durationHours, durationMinutes]); + + const completedAt = useMemo(() => { + if (!completionDate || !completionTime) return null; + const date = new Date(`${completionDate}T${completionTime}`); + return Number.isNaN(date.getTime()) ? null : date; + }, [completionDate, completionTime]); + + const eligibility = useMemo(() => { + if (!completedAt) return null; + + if (!isPremium) { + return { + tone: "info" as const, + message: "This will be recorded, but offline activities only earn XP for Premium accounts.", + }; + } + + const ageDays = (nowMs - completedAt.getTime()) / (24 * 60 * 60 * 1000); + if (ageDays > XP_ELIGIBLE_BACKDATE_DAYS) { + return { + tone: "warning" as const, + message: `This is more than ${XP_ELIGIBLE_BACKDATE_DAYS} days old, so it will be recorded but won't earn XP.`, + }; + } + + return { + tone: "success" as const, + message: "This activity is eligible for XP, subject to your daily logging limits.", + }; + }, [completedAt, isPremium, nowMs]); + + const validate = useCallback((): FieldErrors => { + const errors: FieldErrors = {}; + + if (!taskName.trim()) { + errors.task = "Enter or select a task."; + } + + const hours = durationHours ? Number(durationHours) : 0; + const minutes = durationMinutes ? Number(durationMinutes) : 0; + if (Number.isNaN(hours) || hours < 0) { + errors.duration = "Hours must be a positive number."; + } else if (Number.isNaN(minutes) || minutes < 0 || minutes > 59) { + errors.duration = "Minutes must be between 0 and 59."; + } else if (totalDurationMinutes <= 0) { + errors.duration = "Enter a duration greater than zero."; + } + + if (!completionDate || !completionTime) { + errors.completedAt = "Enter a completion date and time."; + } else if (!completedAt) { + errors.completedAt = "Enter a valid completion date and time."; + } else if (completedAt.getTime() > Date.now()) { + errors.completedAt = "Completion date/time can't be in the future."; + } + + return errors; + }, [ + completedAt, + completionDate, + completionTime, + durationHours, + durationMinutes, + taskName, + totalDurationMinutes, + ]); + + const reset = useCallback(() => { + setTaskName(""); + setSelectedTaskId(null); + setDurationHours(""); + setDurationMinutes(""); + setCompletionDate(todayDateValue()); + setCompletionTime(nowTimeValue()); + setNowMs(Date.now()); + setFieldErrors({}); + setSubmitError(null); + setResult(null); + }, []); + + const handleSubmit = useCallback( + (event: React.FormEvent) => { + event.preventDefault(); + setSubmitError(null); + + const errors = validate(); + setFieldErrors(errors); + if (Object.keys(errors).length > 0 || !completedAt) return; + + const durationSeconds = Math.round(totalDurationMinutes * 60); + const startedAt = new Date(completedAt.getTime() - durationSeconds * 1000); + + logOfflineActivity.mutate( + { + ...(selectedTaskId ? { task: selectedTaskId } : { name: taskName.trim() }), + started_at: startedAt.toISOString(), + completed_at: completedAt.toISOString(), + }, + { + onSuccess: (data) => { + setResult(data); + if (data.xp_gained > 0) fetchPlayerAndCharacter(); + onLogged?.(); + }, + onError: (error) => setSubmitError(extractErrorMessage(error)), + } + ); + }, + [ + completedAt, + fetchPlayerAndCharacter, + logOfflineActivity, + onLogged, + selectedTaskId, + taskName, + totalDurationMinutes, + validate, + ] + ); + + return { + taskName, + handleTaskNameChange, + handleTaskSelect, + durationHours, + setDurationHours, + durationMinutes, + setDurationMinutes, + totalDurationMinutes, + completionDate, + handleCompletionDateChange, + completionTime, + handleCompletionTimeChange, + maxCompletionDate, + fieldErrors, + submitError, + result, + eligibility, + isSubmitting: logOfflineActivity.isPending, + handleSubmit, + reset, + }; +} diff --git a/frontend/src/components/Modal/Modal.module.scss b/frontend/src/components/Modal/Modal.module.scss index 0ca97f77..6022ecb9 100644 --- a/frontend/src/components/Modal/Modal.module.scss +++ b/frontend/src/components/Modal/Modal.module.scss @@ -53,8 +53,7 @@ overflow-y: auto; overflow-x: hidden; min-height: 0; - padding-top: sp.$spacing-md; - padding-bottom: sp.$spacing-md; + padding: sp.$spacing-md sp.$spacing-sm; @include tc.two-column-layout; justify-content: flex-start; diff --git a/frontend/src/hooks/useActivities.ts b/frontend/src/hooks/useActivities.ts index 17f6718c..0e05fc4d 100644 --- a/frontend/src/hooks/useActivities.ts +++ b/frontend/src/hooks/useActivities.ts @@ -1,7 +1,7 @@ // src/hooks/useActivities.ts import { useMutation, useQueryClient, useQuery } from "@tanstack/react-query"; -import { updateActivity, deleteActivity, fetchActivities, createActivity } from "../api/activities"; +import { updateActivity, deleteActivity, fetchActivities, createActivity, logOfflineActivity } from "../api/activities"; import type { PlayerActivity } from "../types"; @@ -26,6 +26,20 @@ export function useCreateActivity() { } +export function useLogOfflineActivity() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: logOfflineActivity, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["activities"] }); + // A task may have been auto-created (or its total time updated) by the log. + queryClient.invalidateQueries({ queryKey: ["tasks"] }); + }, + }); +} + + export function useUpdateActivity() { const queryClient = useQueryClient(); diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index cb76f9a5..839d9e45 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -97,6 +97,16 @@ export interface AnnouncementReadMutationResponse { unread_count: number; } +/** Response from POST /player-activities/log_offline/ */ +export interface OfflineActivityLogResponse { + success: boolean; + message: string; + activity: PlayerActivity; + xp_gained: number; + xp_eligible_seconds: number; + level_ups: number[]; +} + // Forward references resolved in domain.ts import type { Player } from "./domain"; import type { Character } from "./domain"; @@ -104,4 +114,5 @@ import type { Announcement } from "./domain"; import type { ActivityTimerApiData } from "./timers"; import type { PopulationCentre } from "./domain"; import type { XpModifier } from "./domain"; +import type { PlayerActivity } from "./domain"; import type { LoginState } from "./enums"; diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 2a2ff69e..35676180 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -19,6 +19,7 @@ export type { AnnouncementListResponse, AnnouncementUnreadCountResponse, AnnouncementReadMutationResponse, + OfflineActivityLogResponse, } from "./api"; // Enums and literal union types