Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
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
13 changes: 12 additions & 1 deletion .github/instructions/quests.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,22 @@ The course plan panel lists the course's activities as cards, in rows. Each card
3. **Ongoing** — 🟣 purple card with an "Ongoing" overlay tag on the top right in white text; same text-not-color-only rationale; the purple matches the ongoing map pin (V6).
4. **Needs more participants to start** — 🔘 light gray card at 30% opacity: still clickable but de-emphasized. Tapping it explains why ("Uh oh, you need to invite N people…").

## Per-course activity pinning

The design — what a pin means, why it lives in course state and never the quest plan, attribution-level semantics — is the org doc's [Per-course activity pinning](../../../.github/.github/instructions/quests-and-learning-objectives.instructions.md#per-course-activity-pinning). This doc records the client mechanics ([client#7748](https://github.com/pangeachat/client/issues/7748)):

- The pin travels on the course space's teacher-mode state (`TeacherModeModel.pinnedActivitiesByObjective`: Mission id → pinned `activity_id` content ids). Null, a missing Mission key, or an empty list all mean unrestricted.
- Restriction is a **pure copy** at the outline boundary (`QuestOutline.restrictedTo`) — never a mutation of the quest-outline cache, which is shared across courses referencing the same quest; that copy is what lets the same quest run restricted in one course and open in another.
- **One rule, one home**: `effectivePinnedActivityIds` carries the fail-open rule (no pin, empty pin, or an all-stale pin → unrestricted, so a pin can never make a Mission unsatisfiable). Both the outline restriction and the course-scoped map's marker filter call it. The world-scoped map is deliberately never filtered — everything stays playable everywhere.
- The resolver is **pin-unaware by construction**: star attribution and the effective-threshold clamp both derive from the outline's per-Mission activity sets, so a filtered outline scopes attribution and clamps against the pinned set with no resolver changes.
- Previews and non-joined contexts pass no pins — there is no learner progress to scope, and fail-open is the default everywhere.
- The teacher editing surface is deferred to the admin panel ([admin-dash#30](https://github.com/pangeachat/admin-dash/issues/30)); until it ships, pins are written to course room state directly.

## Future Work

File GitHub issues for these and link them here (use the `update-future-work` skill).

- A persisted per-Mission star total (server-side rollup) once reading every session room client-side becomes too costly at catalog scale.
- Teacher-set **hard** restrictions (an opt-in gate on top of the soft default), if classroom demand appears — deliberately not built today (see the org doc).
- Teacher-set **hard** restrictions (an opt-in gate on top of the soft default), if classroom demand appears — deliberately not built today (see the org doc). Distinct from per-course activity pinning (above), which is built and restricts *which activities satisfy*, not *when Missions are reachable*.
- Implement the joinable/open activity card design — [pangeachat/client#7669](https://github.com/pangeachat/client/issues/7669).
- Design hint indicating an activity needs more people to start — [pangeachat/client#6810](https://github.com/pangeachat/client/issues/6810).
13 changes: 11 additions & 2 deletions lib/features/quests/quest_objectives_loader.dart
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,14 @@ class QuestObjectivesLoader {
}
}

Future<void> loadOutline(String? questId) async {
/// [pinnedActivitiesByObjective] is the course's per-Mission activity pin
/// (room.teacherMode) — passed by callers with a joined course room in hand;
/// null (previews, no room) means unrestricted, the fail-open default.
/// Applied as a pure copy so the shared quest-outline cache is untouched.
Future<void> loadOutline(
String? questId, {
Map<String, List<String>>? pinnedActivitiesByObjective,
}) async {
if (_disposed) return;

_loadGeneration++;
Expand All @@ -91,7 +98,9 @@ class QuestObjectivesLoader {

_updateQuest(AsyncLoading(), loadGen);
final outlineResult = await QuestRepo.outline(questId);
final outline = outlineResult.result;
final outline = outlineResult.result?.restrictedTo(
pinnedActivitiesByObjective,
);

if (_disposed) return;

Expand Down
51 changes: 51 additions & 0 deletions lib/features/quests/repo/quest_repo.dart
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,21 @@ import 'package:fluffychat/widgets/matrix.dart';

class MissingQuestException implements Exception {}

/// The single home of the per-course activity-pin rule (org quests doc,
/// client#7748): which of a Mission's [available] activity ids count when the
/// course pins [pinned] for it. Fail-open by design — no pin, an empty pin, or
/// a pin whose ids are all stale (no intersection) yields [available]
/// unchanged, so a pin can never make a Mission unsatisfiable. Consumers:
/// [QuestOutline.restrictedTo] and the course-scoped map's marker filter.
Set<String> effectivePinnedActivityIds(
Set<String> available,
List<String>? pinned,
) {
if (pinned == null || pinned.isEmpty) return available;
final intersection = available.intersection(pinned.toSet());
return intersection.isEmpty ? available : intersection;
}

/// An activity in a quest outline: its id plus the full plan, so the card
/// renders and a session can open without a refetch.
class QuestActivity {
Expand All @@ -45,6 +60,42 @@ class QuestOutline {
final List<QuestObjectiveGroup> groups;
const QuestOutline({required this.quest, required this.groups});

/// A copy with each pinned Mission's activities filtered to its pinned set —
/// per-course activity pinning (org quests doc; client#7748). A PURE COPY:
/// the outline cache is shared across courses referencing the same quest, so
/// the same quest can run restricted in one course and open in another only
/// because this never mutates the source.
///
/// Fail-open rules (a pin can never make a Mission unsatisfiable): a Mission
/// with no pin entry or an empty pinned list is unrestricted; pinned ids are
/// intersected with the Mission's actual activities, and an intersection
/// that comes up empty (all pins stale) falls back to unrestricted.
QuestOutline restrictedTo(Map<String, List<String>>? pinnedByObjective) {
if (pinnedByObjective == null || pinnedByObjective.isEmpty) return this;
return QuestOutline(
quest: quest,
groups: [
for (final group in groups)
_restrictGroup(group, pinnedByObjective[group.objective.id]),
],
);
}

static QuestObjectiveGroup _restrictGroup(
QuestObjectiveGroup group,
List<String>? pinned,
) {
final available = group.activities.map((a) => a.activityId).toSet();
final allowed = effectivePinnedActivityIds(available, pinned);
if (allowed.length == available.length) return group;
return QuestObjectiveGroup(
objective: group.objective,
activities: group.activities
.where((a) => allowed.contains(a.activityId))
.toList(),
);
}

/// Project this outline into the pure [CourseLoOutline] the progression gate
/// consumes: the quest's ordered objective ids, and per objective the set of
/// activity ids that satisfy it. [starsToUnlock] carries the course's teacher
Expand Down
16 changes: 14 additions & 2 deletions lib/routes/chat/chat_details/chat_details.dart
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,10 @@ class ChatDetailsController extends State<ChatDetails>
activitiesToCompleteOverride: room?.teacherMode.activitiesToUnlockTopic,
);

_objectivesProvider.loadOutline(_questId);
_objectivesProvider.loadOutline(
_questId,
pinnedActivitiesByObjective: _pinnedActivitiesByObjective,
);
_loadSummaries();

if (room?.isSpace == true) {
Expand All @@ -96,7 +99,10 @@ class ChatDetailsController extends State<ChatDetails>
void didUpdateWidget(covariant ChatDetails oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.roomId != widget.roomId) {
_objectivesProvider.loadOutline(_questId);
_objectivesProvider.loadOutline(
_questId,
pinnedActivitiesByObjective: _pinnedActivitiesByObjective,
);
_loadSummaries();
}

Expand All @@ -122,6 +128,12 @@ class ChatDetailsController extends State<ChatDetails>
String? get _questId =>
Matrix.of(context).client.getRoomById(widget.roomId)?.coursePlan?.uuid;

/// The course's per-Mission activity pin (org quests doc, client#7748) —
/// null when unset, which is the unrestricted default.
Map<String, List<String>>? get _pinnedActivitiesByObjective => Matrix.of(
context,
).client.getRoomById(widget.roomId)?.teacherMode.pinnedActivitiesByObjective;

QuestObjectivesLoader get objectivesProvider => _objectivesProvider;

void setDisplaynameAction() async {
Expand Down
24 changes: 24 additions & 0 deletions lib/routes/chat/chat_details/teacher_mode_model.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,37 +8,61 @@ class TeacherModeModel {
/// [activitiesToUnlockTopic] count, which gated on completed activities.
final int? starsToUnlockObjective;

/// Per-course activity pinning: Mission (LO) id → the activity content ids
/// (`activity_id`, environment-stable — never CMS row ids) that satisfy the
/// Mission in this course's context. Null / missing key / empty list mean no
/// restriction — pinning is opt-in per Mission and fails open, so a pin can
/// never make a Mission unsatisfiable (org quests doc). Independent of
/// [enabled], which only toggles the teacher's own viewing mode.
final Map<String, List<String>>? pinnedActivitiesByObjective;

const TeacherModeModel({
required this.enabled,
this.activitiesToUnlockTopic,
this.starsToUnlockObjective,
this.pinnedActivitiesByObjective,
});

TeacherModeModel copyWith({
bool? enabled,
int? activitiesToUnlockTopic,
int? starsToUnlockObjective,
Map<String, List<String>>? pinnedActivitiesByObjective,
}) {
return TeacherModeModel(
enabled: enabled ?? this.enabled,
activitiesToUnlockTopic:
activitiesToUnlockTopic ?? this.activitiesToUnlockTopic,
starsToUnlockObjective:
starsToUnlockObjective ?? this.starsToUnlockObjective,
pinnedActivitiesByObjective:
pinnedActivitiesByObjective ?? this.pinnedActivitiesByObjective,
);
Comment on lines 26 to 40

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Allow copyWith to clear the pin mapping.

copyWith(pinnedActivitiesByObjective: null) currently preserves the old pins, although null is the contract for removing restrictions. Add an explicit clear flag or sentinel.

Proposed fix
 TeacherModeModel copyWith({
   bool? enabled,
   int? activitiesToUnlockTopic,
   int? starsToUnlockObjective,
   Map<String, List<String>>? pinnedActivitiesByObjective,
+  bool clearPinnedActivitiesByObjective = false,
 }) {
   return TeacherModeModel(
     enabled: enabled ?? this.enabled,
     activitiesToUnlockTopic:
         activitiesToUnlockTopic ?? this.activitiesToUnlockTopic,
     starsToUnlockObjective:
         starsToUnlockObjective ?? this.starsToUnlockObjective,
-    pinnedActivitiesByObjective:
-        pinnedActivitiesByObjective ?? this.pinnedActivitiesByObjective,
+    pinnedActivitiesByObjective: clearPinnedActivitiesByObjective
+        ? null
+        : pinnedActivitiesByObjective ?? this.pinnedActivitiesByObjective,
   );
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
TeacherModeModel copyWith({
bool? enabled,
int? activitiesToUnlockTopic,
int? starsToUnlockObjective,
Map<String, List<String>>? pinnedActivitiesByObjective,
}) {
return TeacherModeModel(
enabled: enabled ?? this.enabled,
activitiesToUnlockTopic:
activitiesToUnlockTopic ?? this.activitiesToUnlockTopic,
starsToUnlockObjective:
starsToUnlockObjective ?? this.starsToUnlockObjective,
pinnedActivitiesByObjective:
pinnedActivitiesByObjective ?? this.pinnedActivitiesByObjective,
);
TeacherModeModel copyWith({
bool? enabled,
int? activitiesToUnlockTopic,
int? starsToUnlockObjective,
Map<String, List<String>>? pinnedActivitiesByObjective,
bool clearPinnedActivitiesByObjective = false,
}) {
return TeacherModeModel(
enabled: enabled ?? this.enabled,
activitiesToUnlockTopic:
activitiesToUnlockTopic ?? this.activitiesToUnlockTopic,
starsToUnlockObjective:
starsToUnlockObjective ?? this.starsToUnlockObjective,
pinnedActivitiesByObjective: clearPinnedActivitiesByObjective
? null
: pinnedActivitiesByObjective ?? this.pinnedActivitiesByObjective,
);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/routes/chat/chat_details/teacher_mode_model.dart` around lines 26 - 40,
Update TeacherModeModel.copyWith so callers can explicitly clear
pinnedActivitiesByObjective while preserving the existing value when the
argument is omitted. Add an explicit clear flag or equivalent sentinel, and
apply it only to the pinned mapping without changing the other copyWith fields.

}

Map<String, dynamic> toJson() => {
'enabled': enabled,
'activities_to_unlock_topic': activitiesToUnlockTopic,
'stars_to_unlock_objective': starsToUnlockObjective,
if (pinnedActivitiesByObjective != null)
'pinned_activities_by_objective': pinnedActivitiesByObjective,
};

factory TeacherModeModel.fromJson(Map<String, dynamic> json) {
final rawPins = json['pinned_activities_by_objective'];
return TeacherModeModel(
enabled: json['enabled'] ?? false,
activitiesToUnlockTopic: json['activities_to_unlock_topic'],
starsToUnlockObjective: json['stars_to_unlock_objective'],
pinnedActivitiesByObjective: rawPins is Map
? {
for (final entry in rawPins.entries)
if (entry.key is String && entry.value is List)
entry.key as String: (entry.value as List)
.whereType<String>()
.toList(),
}
: null,
);
}
}
21 changes: 19 additions & 2 deletions lib/routes/courses/own/selected_course_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import 'dart:async';

import 'package:flutter/material.dart';

import 'package:collection/collection.dart';
import 'package:go_router/go_router.dart';
import 'package:matrix/matrix.dart' as sdk;

Expand All @@ -14,6 +15,7 @@ import 'package:fluffychat/features/quests/quest_objectives_loader.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/spaces/client_spaces_extension.dart';
import 'package:fluffychat/routes/chat/chat_details/space_details_content.dart';
import 'package:fluffychat/routes/world/world_map_client_extension.dart';
import 'package:fluffychat/routes/chat/events/constants/pangea_event_types.dart';
import 'package:fluffychat/routes/courses/own/selected_course_view.dart';
import 'package:fluffychat/widgets/matrix.dart';
Expand Down Expand Up @@ -45,20 +47,35 @@ class SelectedCourse extends StatefulWidget {
class SelectedCourseController extends State<SelectedCourse> {
late final QuestObjectivesLoader _objectivesProvider;

/// The joined course room for this course plan, if any — the home of the
/// per-Mission activity pin (org quests doc, client#7748). Null (not joined)
/// means unrestricted, the fail-open default.
Map<String, List<String>>? get _pinnedActivitiesByObjective =>
Matrix.of(context).client.joinedCourseRooms
.firstWhereOrNull((r) => r.coursePlan?.uuid == widget.courseId)
?.teacherMode
.pinnedActivitiesByObjective;

@override
initState() {
super.initState();
_objectivesProvider = QuestObjectivesLoader(
client: Matrix.of(context).client,
);
_objectivesProvider.loadOutline(widget.courseId);
_objectivesProvider.loadOutline(
widget.courseId,
pinnedActivitiesByObjective: _pinnedActivitiesByObjective,
);
}

@override
void didUpdateWidget(covariant SelectedCourse oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.courseId != widget.courseId) {
_objectivesProvider.loadOutline(widget.courseId);
_objectivesProvider.loadOutline(
widget.courseId,
pinnedActivitiesByObjective: _pinnedActivitiesByObjective,
);
}
}

Expand Down
31 changes: 20 additions & 11 deletions lib/routes/world/joined_objective_cache.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import 'package:fluffychat/features/course_plans/courses/course_plan_room_extens
import 'package:fluffychat/features/quests/lo_progression.dart';
import 'package:fluffychat/features/quests/quest_progression_resolver.dart';
import 'package:fluffychat/features/quests/repo/quest_repo.dart';
import 'package:fluffychat/routes/chat/chat_details/teacher_mode_model.dart';
import 'package:fluffychat/routes/world/world_map_client_extension.dart';
import 'package:fluffychat/widgets/future_loading_dialog.dart';

Expand Down Expand Up @@ -88,33 +89,41 @@ class JoinedObjectiveCache {
}

/// [rebuild] from the client's joined courses — each course's quest uuid with
/// its teacher stars-to-unlock override. The single home for that mapping:
/// the world map's pins manager and the course panel's star display both
/// rebuild through here, so every surface resolves identical outlines.
/// its teacher config (stars-to-unlock override + per-Mission activity pins).
/// The single home for that mapping: the world map's pins manager and the
/// course panel's star display both rebuild through here, so every surface
/// resolves identical outlines. Pins are applied as a pure copy per course
/// (never to the shared quest-outline cache), so two courses sharing a quest
/// can restrict differently.
Future<void> rebuildFromJoinedCourses(
Client client, {
void Function(String uuid, Object error, StackTrace stack)? onError,
}) {
final thresholds = <String, int>{
final modes = <String, TeacherModeModel>{
for (final room in client.joinedCourseRooms)
room.coursePlan!.uuid:
room.teacherMode.starsToUnlockObjective ??
kDefaultStarsToUnlockObjective,
room.coursePlan!.uuid: room.teacherMode,
};
return rebuild(
thresholds.keys.toList(),
modes.keys.toList(),
Comment on lines +102 to +107

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Keep course-space identity separate from quest identity. Using coursePlan.uuid as the course key collapses or ambiguously selects rooms even though separate courses sharing a quest may have different pins.

  • lib/routes/world/joined_objective_cache.dart#L102-L107: key cached configurations by room/course-space ID and retain the quest UUID as outline input.
  • lib/routes/courses/own/selected_course_page.dart#L53-L57: select the intended joined room by course-space identity rather than firstWhereOrNull on quest UUID.
  • lib/routes/world/world_map.dart#L401-L404: carry the joined room identity in the course map context so marker restrictions cannot come from another course.
📍 Affects 3 files
  • lib/routes/world/joined_objective_cache.dart#L102-L107 (this comment)
  • lib/routes/courses/own/selected_course_page.dart#L53-L57
  • lib/routes/world/world_map.dart#L401-L404
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/routes/world/joined_objective_cache.dart` around lines 102 - 107, Keep
course-space and quest identities distinct: in
lib/routes/world/joined_objective_cache.dart lines 102-107, key cached teacher
modes by the joined room/course-space ID while retaining the quest UUID as
outline input; in lib/routes/courses/own/selected_course_page.dart lines 53-57,
select the joined room by course-space identity instead of matching the first
room by quest UUID; and in lib/routes/world/world_map.dart lines 401-404,
propagate the joined room identity through the course map context so marker
restrictions use the correct course.

outlineOf: (uuid) => _outlineFromQuest(
uuid,
pinnedByObjective: modes[uuid]?.pinnedActivitiesByObjective,
),
starsToUnlockOf: (uuid) =>
thresholds[uuid] ?? kDefaultStarsToUnlockObjective,
modes[uuid]?.starsToUnlockObjective ?? kDefaultStarsToUnlockObjective,
onError: onError,
);
}

static Future<CourseLoOutline> _outlineFromQuest(String uuid) async {
static Future<CourseLoOutline> _outlineFromQuest(
String uuid, {
Map<String, List<String>>? pinnedByObjective,
}) async {
final outlineResult = await QuestRepo.outline(uuid);
final outline = outlineResult.result;
if (outline == null) {
throw (outlineResult.error ?? MissingQuestException());
}
return outline.toCourseLoOutline();
return outline.restrictedTo(pinnedByObjective).toCourseLoOutline();
}
}
15 changes: 14 additions & 1 deletion lib/routes/world/world_map.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@ import 'dart:async';

import 'package:flutter/material.dart';

import 'package:collection/collection.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:go_router/go_router.dart';
import 'package:latlong2/latlong.dart';
import 'package:matrix/matrix.dart';

import 'package:fluffychat/features/activity_sessions/activity_plan_repo.dart';
import 'package:fluffychat/features/course_plans/courses/course_plan_room_extension.dart';
import 'package:fluffychat/features/languages/language_model.dart';
import 'package:fluffychat/features/navigation/route_facts.dart';
import 'package:fluffychat/features/navigation/workspace_nav.dart';
Expand Down Expand Up @@ -393,7 +395,18 @@ class WorldMapController extends State<WorldMap>
case CourseMapContext():
_ensureScopedCourseOutline(mapContext.coursePlanId);
try {
await _pinsManager.loadCourseScopedPins(mapContext.coursePlanId);
// The joined course's per-Mission activity pin scopes this view's
// markers (org quests doc, client#7748); not joined / unset → null →
// unrestricted.
final courseRoom = Matrix.of(context).client.joinedCourseRooms
.firstWhereOrNull(
(r) => r.coursePlan?.uuid == mapContext.coursePlanId,
);
await _pinsManager.loadCourseScopedPins(
mapContext.coursePlanId,
pinnedActivitiesByObjective:
courseRoom?.teacherMode.pinnedActivitiesByObjective,
);

if (!mounted) return;
setState(() {});
Expand Down
Loading
Loading