forge-openclaw-plugin 0.2.3 → 0.2.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +114 -6
- package/dist/assets/board-CzgvdLO8.js +6 -0
- package/dist/assets/board-CzgvdLO8.js.map +1 -0
- package/dist/assets/favicon-BCHm9dUV.ico +0 -0
- package/dist/assets/index-8d_oM8fL.js +27 -0
- package/dist/assets/index-8d_oM8fL.js.map +1 -0
- package/dist/assets/index-D4A_bq8m.css +1 -0
- package/dist/assets/motion-STUd1O46.js +10 -0
- package/dist/assets/motion-STUd1O46.js.map +1 -0
- package/dist/assets/plus-jakarta-sans-latin-ext-wght-normal-DmpS2jIq.woff2 +0 -0
- package/dist/assets/plus-jakarta-sans-latin-wght-normal-eXO_dkmS.woff2 +0 -0
- package/dist/assets/plus-jakarta-sans-vietnamese-wght-normal-qRpaaN48.woff2 +0 -0
- package/dist/assets/sora-latin-ext-wght-normal-CawQDOvP.woff2 +0 -0
- package/dist/assets/sora-latin-wght-normal-DdqRvwsR.woff2 +0 -0
- package/dist/assets/space-grotesk-latin-500-normal-CNSSEhBt.woff +0 -0
- package/dist/assets/space-grotesk-latin-500-normal-lFbtlQH6.woff2 +0 -0
- package/dist/assets/space-grotesk-latin-700-normal-CwsQ-cCU.woff +0 -0
- package/dist/assets/space-grotesk-latin-700-normal-RjhwGPKo.woff2 +0 -0
- package/dist/assets/space-grotesk-latin-ext-500-normal-3dgZTiw9.woff +0 -0
- package/dist/assets/space-grotesk-latin-ext-500-normal-DUe3BAxM.woff2 +0 -0
- package/dist/assets/space-grotesk-latin-ext-700-normal-BQnZhY3m.woff2 +0 -0
- package/dist/assets/space-grotesk-latin-ext-700-normal-HVCqSBdx.woff +0 -0
- package/dist/assets/space-grotesk-vietnamese-500-normal-BTqKIpxg.woff +0 -0
- package/dist/assets/space-grotesk-vietnamese-500-normal-BmEvtly_.woff2 +0 -0
- package/dist/assets/space-grotesk-vietnamese-700-normal-DMty7AZE.woff2 +0 -0
- package/dist/assets/space-grotesk-vietnamese-700-normal-Duxec5Rn.woff +0 -0
- package/dist/assets/table-CtNlETLc.js +23 -0
- package/dist/assets/table-CtNlETLc.js.map +1 -0
- package/dist/assets/ui-ThzkR_oW.js +46 -0
- package/dist/assets/ui-ThzkR_oW.js.map +1 -0
- package/dist/assets/vendor-CRS-psbw.css +1 -0
- package/dist/assets/vendor-DyHAI6nk.js +423 -0
- package/dist/assets/vendor-DyHAI6nk.js.map +1 -0
- package/dist/assets/viz-BJuBCz_G.js +34 -0
- package/dist/assets/viz-BJuBCz_G.js.map +1 -0
- package/dist/favicon.ico +0 -0
- package/dist/favicon.png +0 -0
- package/dist/index.html +29 -0
- package/dist/openclaw/api-client.d.ts +8 -0
- package/dist/openclaw/api-client.js +31 -4
- package/dist/openclaw/local-runtime.d.ts +3 -0
- package/dist/openclaw/local-runtime.js +135 -0
- package/dist/openclaw/parity.d.ts +4 -4
- package/dist/openclaw/parity.js +23 -33
- package/dist/openclaw/plugin-entry-shared.d.ts +5 -3
- package/dist/openclaw/plugin-entry-shared.js +52 -10
- package/dist/openclaw/routes.d.ts +12 -3
- package/dist/openclaw/routes.js +156 -924
- package/dist/openclaw/tools.js +242 -1100
- package/dist/server/app.js +2450 -0
- package/dist/server/db.js +313 -0
- package/dist/server/e2e-server.js +20 -0
- package/dist/server/errors.js +15 -0
- package/dist/server/index.js +16 -0
- package/dist/server/managers/base.js +17 -0
- package/dist/server/managers/contracts.js +47 -0
- package/dist/server/managers/platform/api-gateway-manager.js +11 -0
- package/dist/server/managers/platform/audit-manager.js +15 -0
- package/dist/server/managers/platform/authentication-manager.js +56 -0
- package/dist/server/managers/platform/authorization-manager.js +56 -0
- package/dist/server/managers/platform/background-job-manager.js +10 -0
- package/dist/server/managers/platform/configuration-manager.js +33 -0
- package/dist/server/managers/platform/database-manager.js +14 -0
- package/dist/server/managers/platform/event-bus-manager.js +7 -0
- package/dist/server/managers/platform/external-service-manager.js +11 -0
- package/dist/server/managers/platform/health-manager.js +7 -0
- package/dist/server/managers/platform/migration-manager.js +8 -0
- package/dist/server/managers/platform/search-index-manager.js +4 -0
- package/dist/server/managers/platform/secrets-manager.js +19 -0
- package/dist/server/managers/platform/session-manager.js +121 -0
- package/dist/server/managers/platform/storage-manager.js +16 -0
- package/dist/server/managers/platform/token-manager.js +37 -0
- package/dist/server/managers/platform/transaction-manager.js +8 -0
- package/dist/server/managers/platform/trusted-network.js +39 -0
- package/dist/server/managers/runtime.js +56 -0
- package/dist/server/managers/type-guards.js +4 -0
- package/dist/server/openapi.js +3512 -0
- package/dist/server/psyche-types.js +395 -0
- package/dist/server/repositories/activity-events.js +157 -0
- package/dist/server/repositories/collaboration.js +497 -0
- package/dist/server/repositories/comments.js +176 -0
- package/dist/server/repositories/deleted-entities.js +192 -0
- package/dist/server/repositories/domains.js +30 -0
- package/dist/server/repositories/event-log.js +64 -0
- package/dist/server/repositories/goals.js +159 -0
- package/dist/server/repositories/projects.js +214 -0
- package/dist/server/repositories/psyche.js +1356 -0
- package/dist/server/repositories/rewards.js +675 -0
- package/dist/server/repositories/settings.js +399 -0
- package/dist/server/repositories/tags.js +160 -0
- package/dist/server/repositories/task-runs.js +488 -0
- package/dist/server/repositories/tasks.js +413 -0
- package/dist/server/services/context.js +214 -0
- package/dist/server/services/dashboard.js +170 -0
- package/dist/server/services/entity-crud.js +576 -0
- package/dist/server/services/gamification.js +215 -0
- package/dist/server/services/insights.js +91 -0
- package/dist/server/services/projects.js +75 -0
- package/dist/server/services/psyche.js +63 -0
- package/dist/server/services/relations.js +28 -0
- package/dist/server/services/reviews.js +88 -0
- package/dist/server/services/run-recovery.js +13 -0
- package/dist/server/services/tagging.js +49 -0
- package/dist/server/services/task-run-watchdog.js +92 -0
- package/dist/server/services/work-time.js +176 -0
- package/dist/server/types.js +999 -0
- package/dist/server/web.js +91 -0
- package/openclaw.plugin.json +22 -10
- package/package.json +17 -4
- package/server/migrations/001_core.sql +333 -0
- package/server/migrations/002_psyche.sql +241 -0
- package/server/migrations/003_timer_execution.sql +18 -0
- package/server/migrations/004_psyche_linked_entities.sql +5 -0
- package/server/migrations/005_adaptive_schemas.sql +157 -0
- package/server/migrations/006_psyche_auth_setting.sql +4 -0
- package/server/migrations/007_deleted_entities.sql +16 -0
- package/skills/forge-openclaw/SKILL.md +189 -275
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { achievementSignalSchema, gamificationOverviewSchema, gamificationProfileSchema, milestoneRewardSchema } from "../types.js";
|
|
2
|
+
import { getTotalXp, getWeeklyXp } from "../repositories/rewards.js";
|
|
3
|
+
const XP_PER_LEVEL = 120;
|
|
4
|
+
function startOfWeek(date) {
|
|
5
|
+
const clone = new Date(date);
|
|
6
|
+
const day = clone.getDay();
|
|
7
|
+
const delta = day === 0 ? -6 : 1 - day;
|
|
8
|
+
clone.setDate(clone.getDate() + delta);
|
|
9
|
+
clone.setHours(0, 0, 0, 0);
|
|
10
|
+
return clone;
|
|
11
|
+
}
|
|
12
|
+
function dayKey(isoDate) {
|
|
13
|
+
return isoDate.slice(0, 10);
|
|
14
|
+
}
|
|
15
|
+
function calculateStreak(tasks, now) {
|
|
16
|
+
const completedDays = new Set(tasks
|
|
17
|
+
.flatMap((task) => (task.status === "done" && task.completedAt !== null ? [dayKey(task.completedAt)] : [])));
|
|
18
|
+
if (completedDays.size === 0) {
|
|
19
|
+
return 0;
|
|
20
|
+
}
|
|
21
|
+
let streak = 0;
|
|
22
|
+
const cursor = new Date(now);
|
|
23
|
+
cursor.setHours(0, 0, 0, 0);
|
|
24
|
+
while (completedDays.has(cursor.toISOString().slice(0, 10))) {
|
|
25
|
+
streak += 1;
|
|
26
|
+
cursor.setDate(cursor.getDate() - 1);
|
|
27
|
+
}
|
|
28
|
+
return streak;
|
|
29
|
+
}
|
|
30
|
+
function calculateLevel(totalXp) {
|
|
31
|
+
const level = Math.floor(totalXp / XP_PER_LEVEL) + 1;
|
|
32
|
+
const currentLevelFloor = (level - 1) * XP_PER_LEVEL;
|
|
33
|
+
return {
|
|
34
|
+
level,
|
|
35
|
+
currentLevelXp: totalXp - currentLevelFloor,
|
|
36
|
+
nextLevelXp: XP_PER_LEVEL
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
function latestCompletionForTasks(tasks) {
|
|
40
|
+
return tasks
|
|
41
|
+
.flatMap((task) => (task.completedAt ? [task.completedAt] : []))
|
|
42
|
+
.sort((left, right) => Date.parse(right) - Date.parse(left))[0] ?? null;
|
|
43
|
+
}
|
|
44
|
+
export function buildGamificationProfile(goals, tasks, now = new Date()) {
|
|
45
|
+
const weekStart = startOfWeek(now).toISOString();
|
|
46
|
+
const doneTasks = tasks.filter((task) => task.status === "done");
|
|
47
|
+
const totalXp = getTotalXp();
|
|
48
|
+
const weeklyXp = getWeeklyXp(weekStart);
|
|
49
|
+
const focusTasks = tasks.filter((task) => task.status === "focus" || task.status === "in_progress").length;
|
|
50
|
+
const overdueTasks = tasks.filter((task) => task.status !== "done" && task.dueDate !== null && task.dueDate < now.toISOString().slice(0, 10)).length;
|
|
51
|
+
const alignedDonePoints = doneTasks
|
|
52
|
+
.filter((task) => task.goalId !== null && task.tagIds.length > 0)
|
|
53
|
+
.reduce((sum, task) => sum + task.points, 0);
|
|
54
|
+
const streakDays = calculateStreak(tasks, now);
|
|
55
|
+
const levelState = calculateLevel(totalXp);
|
|
56
|
+
const goalScores = goals
|
|
57
|
+
.map((goal) => ({
|
|
58
|
+
goalId: goal.id,
|
|
59
|
+
goalTitle: goal.title,
|
|
60
|
+
earnedXp: doneTasks.filter((task) => task.goalId === goal.id).reduce((sum, task) => sum + task.points, 0)
|
|
61
|
+
}))
|
|
62
|
+
.sort((left, right) => right.earnedXp - left.earnedXp);
|
|
63
|
+
const topGoal = goalScores.find((goal) => goal.earnedXp > 0) ?? null;
|
|
64
|
+
return gamificationProfileSchema.parse({
|
|
65
|
+
totalXp,
|
|
66
|
+
level: levelState.level,
|
|
67
|
+
currentLevelXp: levelState.currentLevelXp,
|
|
68
|
+
nextLevelXp: levelState.nextLevelXp,
|
|
69
|
+
weeklyXp,
|
|
70
|
+
streakDays,
|
|
71
|
+
comboMultiplier: Number((1 + Math.min(0.75, streakDays * 0.05)).toFixed(2)),
|
|
72
|
+
momentumScore: Math.max(0, Math.min(100, Math.round(weeklyXp / 6 + alignedDonePoints / 20 + focusTasks * 5 - overdueTasks * 9))),
|
|
73
|
+
topGoalId: topGoal?.goalId ?? null,
|
|
74
|
+
topGoalTitle: topGoal?.goalTitle ?? null
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
export function buildAchievementSignals(goals, tasks, now = new Date()) {
|
|
78
|
+
const profile = buildGamificationProfile(goals, tasks, now);
|
|
79
|
+
const doneTasks = tasks.filter((task) => task.status === "done");
|
|
80
|
+
const alignedDoneTasks = doneTasks.filter((task) => task.goalId !== null && task.tagIds.length > 0);
|
|
81
|
+
const focusTasks = tasks.filter((task) => task.status === "focus" || task.status === "in_progress");
|
|
82
|
+
const highValueGoals = goals.filter((goal) => doneTasks.some((task) => task.goalId === goal.id));
|
|
83
|
+
return [
|
|
84
|
+
{
|
|
85
|
+
id: "streak-operator",
|
|
86
|
+
title: "Streak Operator",
|
|
87
|
+
summary: "Maintain consecutive days of meaningful completions.",
|
|
88
|
+
tier: profile.streakDays >= 7 ? "gold" : "silver",
|
|
89
|
+
progressLabel: `${Math.min(profile.streakDays, 7)}/7 days`,
|
|
90
|
+
unlocked: profile.streakDays >= 7,
|
|
91
|
+
unlockedAt: profile.streakDays >= 7 ? latestCompletionForTasks(doneTasks) : null
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
id: "aligned-maker",
|
|
95
|
+
title: "Aligned Maker",
|
|
96
|
+
summary: "Complete work that is explicitly tied to a goal and tagged context.",
|
|
97
|
+
tier: alignedDoneTasks.length >= 5 ? "gold" : "bronze",
|
|
98
|
+
progressLabel: `${Math.min(alignedDoneTasks.length, 5)}/5 aligned completions`,
|
|
99
|
+
unlocked: alignedDoneTasks.length >= 5,
|
|
100
|
+
unlockedAt: alignedDoneTasks.length >= 5 ? latestCompletionForTasks(alignedDoneTasks) : null
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
id: "momentum-engine",
|
|
104
|
+
title: "Momentum Engine",
|
|
105
|
+
summary: "Push weekly XP high enough that momentum becomes visible.",
|
|
106
|
+
tier: profile.weeklyXp >= 240 ? "gold" : profile.weeklyXp >= 120 ? "silver" : "bronze",
|
|
107
|
+
progressLabel: `${Math.min(profile.weeklyXp, 240)}/240 weekly xp`,
|
|
108
|
+
unlocked: profile.weeklyXp >= 240,
|
|
109
|
+
unlockedAt: profile.weeklyXp >= 240 ? latestCompletionForTasks(doneTasks) : null
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
id: "path-keeper",
|
|
113
|
+
title: "Path Keeper",
|
|
114
|
+
summary: "Keep multiple life arcs alive instead of overfitting one lane.",
|
|
115
|
+
tier: highValueGoals.length >= 3 ? "platinum" : "silver",
|
|
116
|
+
progressLabel: `${Math.min(highValueGoals.length, 3)}/3 active arcs with wins`,
|
|
117
|
+
unlocked: highValueGoals.length >= 3,
|
|
118
|
+
unlockedAt: highValueGoals.length >= 3 ? latestCompletionForTasks(doneTasks) : null
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
id: "focus-lane",
|
|
122
|
+
title: "Focus Lane Live",
|
|
123
|
+
summary: "Sustain a protected execution lane instead of browsing a backlog.",
|
|
124
|
+
tier: focusTasks.length > 0 ? "silver" : "bronze",
|
|
125
|
+
progressLabel: `${Math.min(focusTasks.length, 1)}/1 live directives`,
|
|
126
|
+
unlocked: focusTasks.length > 0,
|
|
127
|
+
unlockedAt: focusTasks.length > 0 ? now.toISOString() : null
|
|
128
|
+
}
|
|
129
|
+
].map((achievement) => achievementSignalSchema.parse(achievement));
|
|
130
|
+
}
|
|
131
|
+
export function buildMilestoneRewards(goals, tasks, now = new Date()) {
|
|
132
|
+
const profile = buildGamificationProfile(goals, tasks, now);
|
|
133
|
+
const doneTasks = tasks.filter((task) => task.status === "done");
|
|
134
|
+
const topGoal = profile.topGoalId ? goals.find((goal) => goal.id === profile.topGoalId) ?? null : null;
|
|
135
|
+
const topGoalXp = topGoal ? doneTasks.filter((task) => task.goalId === topGoal.id).reduce((sum, task) => sum + task.points, 0) : 0;
|
|
136
|
+
const completedToday = doneTasks.filter((task) => task.completedAt?.slice(0, 10) === now.toISOString().slice(0, 10)).length;
|
|
137
|
+
return [
|
|
138
|
+
{
|
|
139
|
+
id: "next-level",
|
|
140
|
+
title: "Next level threshold",
|
|
141
|
+
summary: "Keep pushing until the next level unlocks a stronger sense of ascent.",
|
|
142
|
+
rewardLabel: `Level ${profile.level + 1}`,
|
|
143
|
+
progressLabel: `${profile.currentLevelXp}/${profile.nextLevelXp} xp`,
|
|
144
|
+
current: profile.currentLevelXp,
|
|
145
|
+
target: profile.nextLevelXp,
|
|
146
|
+
completed: profile.currentLevelXp >= profile.nextLevelXp
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
id: "weekly-sprint",
|
|
150
|
+
title: "Weekly sprint heat",
|
|
151
|
+
summary: "Cross the weekly XP line that keeps the system feeling alive.",
|
|
152
|
+
rewardLabel: "Momentum bonus",
|
|
153
|
+
progressLabel: `${Math.min(profile.weeklyXp, 240)}/240 weekly xp`,
|
|
154
|
+
current: profile.weeklyXp,
|
|
155
|
+
target: 240,
|
|
156
|
+
completed: profile.weeklyXp >= 240
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
id: "daily-mass",
|
|
160
|
+
title: "Daily mass threshold",
|
|
161
|
+
summary: "Make the day feel consequential with multiple completed tasks.",
|
|
162
|
+
rewardLabel: "Quest chest +90 xp",
|
|
163
|
+
progressLabel: `${Math.min(completedToday, 3)}/3 completions today`,
|
|
164
|
+
current: completedToday,
|
|
165
|
+
target: 3,
|
|
166
|
+
completed: completedToday >= 3
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
id: "goal-project",
|
|
170
|
+
title: "Project reward track",
|
|
171
|
+
summary: topGoal ? `Keep advancing the leading life goal through a concrete project path.` : "No leading life goal is established yet.",
|
|
172
|
+
rewardLabel: topGoal ? `${topGoal.title} milestone` : "Establish a lead goal",
|
|
173
|
+
progressLabel: topGoal ? `${Math.min(topGoalXp, topGoal.targetPoints)}/${topGoal.targetPoints} goal xp` : "0/1 lead arcs",
|
|
174
|
+
current: topGoal ? topGoalXp : 0,
|
|
175
|
+
target: topGoal ? topGoal.targetPoints : 1,
|
|
176
|
+
completed: topGoal ? topGoalXp >= topGoal.targetPoints : false
|
|
177
|
+
}
|
|
178
|
+
].map((reward) => milestoneRewardSchema.parse(reward));
|
|
179
|
+
}
|
|
180
|
+
export function buildXpMomentumPulse(goals, tasks, now = new Date()) {
|
|
181
|
+
const profile = buildGamificationProfile(goals, tasks, now);
|
|
182
|
+
const achievements = buildAchievementSignals(goals, tasks, now);
|
|
183
|
+
const milestoneRewards = buildMilestoneRewards(goals, tasks, now);
|
|
184
|
+
const nextMilestone = milestoneRewards.find((reward) => !reward.completed) ?? milestoneRewards[0] ?? null;
|
|
185
|
+
const unlockedAchievements = achievements.filter((achievement) => achievement.unlocked).length;
|
|
186
|
+
const status = profile.momentumScore >= 80 ? "surging" : profile.momentumScore >= 60 ? "steady" : "recovering";
|
|
187
|
+
const headline = status === "surging"
|
|
188
|
+
? `${profile.streakDays}-day streak online. Forge is compounding.`
|
|
189
|
+
: status === "steady"
|
|
190
|
+
? `Momentum is stable. One sharp push keeps the engine hot.`
|
|
191
|
+
: `Recovery window open. A small real win will restart the climb.`;
|
|
192
|
+
const detail = nextMilestone !== null
|
|
193
|
+
? `${nextMilestone.title} is the clean next unlock. ${nextMilestone.progressLabel}.`
|
|
194
|
+
: `Level ${profile.level} is active with ${profile.weeklyXp} weekly XP already recorded.`;
|
|
195
|
+
const celebrationLabel = unlockedAchievements > 0
|
|
196
|
+
? `${unlockedAchievements} achievement${unlockedAchievements === 1 ? "" : "s"} unlocked`
|
|
197
|
+
: profile.weeklyXp >= 120
|
|
198
|
+
? `Weekly sprint heat is building`
|
|
199
|
+
: `Next celebration comes from a real completion or repair`;
|
|
200
|
+
return {
|
|
201
|
+
status,
|
|
202
|
+
headline,
|
|
203
|
+
detail,
|
|
204
|
+
celebrationLabel,
|
|
205
|
+
nextMilestoneId: nextMilestone?.id ?? null,
|
|
206
|
+
nextMilestoneLabel: nextMilestone?.rewardLabel ?? "Keep building visible momentum"
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
export function buildGamificationOverview(goals, tasks, now = new Date()) {
|
|
210
|
+
return gamificationOverviewSchema.parse({
|
|
211
|
+
profile: buildGamificationProfile(goals, tasks, now),
|
|
212
|
+
achievements: buildAchievementSignals(goals, tasks, now),
|
|
213
|
+
milestoneRewards: buildMilestoneRewards(goals, tasks, now)
|
|
214
|
+
});
|
|
215
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { listActivityEvents } from "../repositories/activity-events.js";
|
|
2
|
+
import { listInsights } from "../repositories/collaboration.js";
|
|
3
|
+
import { listGoals } from "../repositories/goals.js";
|
|
4
|
+
import { listTasks } from "../repositories/tasks.js";
|
|
5
|
+
import { getOverviewContext } from "./context.js";
|
|
6
|
+
import { buildGamificationProfile } from "./gamification.js";
|
|
7
|
+
import { insightsPayloadSchema } from "../types.js";
|
|
8
|
+
function dayKey(date) {
|
|
9
|
+
return date.toISOString().slice(0, 10);
|
|
10
|
+
}
|
|
11
|
+
function addDays(date, days) {
|
|
12
|
+
const clone = new Date(date);
|
|
13
|
+
clone.setDate(clone.getDate() + days);
|
|
14
|
+
return clone;
|
|
15
|
+
}
|
|
16
|
+
function buildHeatmap(tasks, now) {
|
|
17
|
+
const cells = [];
|
|
18
|
+
for (let index = 29; index >= 0; index -= 1) {
|
|
19
|
+
const current = addDays(now, -index);
|
|
20
|
+
const currentKey = dayKey(current);
|
|
21
|
+
const completed = tasks.filter((task) => task.completedAt?.slice(0, 10) === currentKey).length;
|
|
22
|
+
const focus = tasks.filter((task) => task.updatedAt.slice(0, 10) === currentKey && (task.status === "focus" || task.status === "in_progress")).length;
|
|
23
|
+
cells.push({
|
|
24
|
+
id: currentKey,
|
|
25
|
+
label: current.toLocaleDateString("en-US", { month: "short", day: "numeric" }),
|
|
26
|
+
completed,
|
|
27
|
+
focus,
|
|
28
|
+
intensity: Math.min(4, completed + focus)
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
return cells;
|
|
32
|
+
}
|
|
33
|
+
export function getInsightsPayload(now = new Date()) {
|
|
34
|
+
const goals = listGoals();
|
|
35
|
+
const tasks = listTasks();
|
|
36
|
+
const gamification = buildGamificationProfile(goals, tasks, now);
|
|
37
|
+
const overview = getOverviewContext(now);
|
|
38
|
+
const activity = listActivityEvents({ limit: 60 });
|
|
39
|
+
const trends = Array.from({ length: 6 }, (_, offset) => {
|
|
40
|
+
const bucketStart = addDays(now, -(5 - offset) * 5);
|
|
41
|
+
const bucketEnd = addDays(bucketStart, 4);
|
|
42
|
+
const completedTasks = tasks.filter((task) => {
|
|
43
|
+
const completedAt = task.completedAt;
|
|
44
|
+
return completedAt !== null && completedAt >= bucketStart.toISOString() && completedAt <= bucketEnd.toISOString();
|
|
45
|
+
});
|
|
46
|
+
const updatedTasks = tasks.filter((task) => task.updatedAt >= bucketStart.toISOString() && task.updatedAt <= bucketEnd.toISOString());
|
|
47
|
+
return {
|
|
48
|
+
label: bucketStart.toLocaleDateString("en-US", { month: "short", day: "numeric" }),
|
|
49
|
+
xp: completedTasks.reduce((sum, task) => sum + task.points, 0),
|
|
50
|
+
focusScore: Math.min(100, updatedTasks.length * 12 + completedTasks.length * 20)
|
|
51
|
+
};
|
|
52
|
+
});
|
|
53
|
+
const domainBalance = overview.domainBalance.map((domain) => ({
|
|
54
|
+
label: domain.label,
|
|
55
|
+
value: Math.min(100, domain.completedPoints + domain.activeTaskCount * 8),
|
|
56
|
+
color: domain.color,
|
|
57
|
+
note: domain.momentumLabel
|
|
58
|
+
}));
|
|
59
|
+
const hottestGoal = overview.activeGoals[0] ?? null;
|
|
60
|
+
const blockedTasks = tasks.filter((task) => task.status === "blocked").length;
|
|
61
|
+
const overdueTasks = tasks.filter((task) => task.status !== "done" && task.dueDate !== null && task.dueDate < dayKey(now)).length;
|
|
62
|
+
const feed = listInsights({ limit: 8 });
|
|
63
|
+
return insightsPayloadSchema.parse({
|
|
64
|
+
generatedAt: now.toISOString(),
|
|
65
|
+
status: {
|
|
66
|
+
systemStatus: gamification.momentumScore >= 80 ? "Optimal Flow" : gamification.momentumScore >= 60 ? "Stable Build" : "Needs Recovery",
|
|
67
|
+
streakDays: gamification.streakDays,
|
|
68
|
+
momentumScore: gamification.momentumScore
|
|
69
|
+
},
|
|
70
|
+
momentumHeatmap: buildHeatmap(tasks, now),
|
|
71
|
+
executionTrends: trends,
|
|
72
|
+
domainBalance,
|
|
73
|
+
coaching: {
|
|
74
|
+
title: hottestGoal ? `Protect ${hottestGoal.title}` : "Rebuild momentum",
|
|
75
|
+
summary: blockedTasks > 0
|
|
76
|
+
? `${blockedTasks} blocked task${blockedTasks === 1 ? "" : "s"} are leaking velocity out of the system.`
|
|
77
|
+
: overdueTasks > 0
|
|
78
|
+
? `${overdueTasks} overdue task${overdueTasks === 1 ? "" : "s"} are the main drag vector right now.`
|
|
79
|
+
: "Recent evidence shows enough movement to push the next arc more aggressively.",
|
|
80
|
+
recommendation: blockedTasks > 0
|
|
81
|
+
? "Clear one blocked task before adding more new work."
|
|
82
|
+
: hottestGoal
|
|
83
|
+
? `Queue the next decisive move for ${hottestGoal.title} and keep a deep-work lane active.`
|
|
84
|
+
: "Pick one life goal, one project, and one task to stabilize the next 24 hours.",
|
|
85
|
+
ctaLabel: "Trigger coaching insight"
|
|
86
|
+
},
|
|
87
|
+
evidenceDigest: activity.slice(0, 5),
|
|
88
|
+
feed,
|
|
89
|
+
openCount: feed.filter((insight) => insight.status === "open").length
|
|
90
|
+
});
|
|
91
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { listActivityEvents } from "../repositories/activity-events.js";
|
|
2
|
+
import { getGoalById, listGoals } from "../repositories/goals.js";
|
|
3
|
+
import { listProjects } from "../repositories/projects.js";
|
|
4
|
+
import { listTasks } from "../repositories/tasks.js";
|
|
5
|
+
import { emptyTaskTimeSummary } from "./work-time.js";
|
|
6
|
+
import { projectBoardPayloadSchema, projectSummarySchema } from "../types.js";
|
|
7
|
+
function projectTaskSummary(tasks) {
|
|
8
|
+
const completedTasks = tasks.filter((task) => task.status === "done");
|
|
9
|
+
const activeTasks = tasks.filter((task) => task.status !== "done");
|
|
10
|
+
const totalTasks = tasks.length;
|
|
11
|
+
const earnedPoints = completedTasks.reduce((sum, task) => sum + task.points, 0);
|
|
12
|
+
const progress = totalTasks === 0 ? 0 : Math.min(100, Math.round((completedTasks.length / totalTasks) * 100));
|
|
13
|
+
const nextTask = activeTasks.find((task) => task.status === "focus" || task.status === "in_progress") ??
|
|
14
|
+
activeTasks.find((task) => task.status === "backlog") ??
|
|
15
|
+
activeTasks[0] ??
|
|
16
|
+
null;
|
|
17
|
+
const momentumLabel = completedTasks.length === 0
|
|
18
|
+
? activeTasks.length > 0
|
|
19
|
+
? "Building momentum"
|
|
20
|
+
: "Waiting for the first task"
|
|
21
|
+
: activeTasks.some((task) => task.status === "blocked")
|
|
22
|
+
? "Needs intervention"
|
|
23
|
+
: progress >= 70
|
|
24
|
+
? "Closing strong"
|
|
25
|
+
: "Making steady progress";
|
|
26
|
+
return {
|
|
27
|
+
activeTaskCount: activeTasks.length,
|
|
28
|
+
completedTaskCount: completedTasks.length,
|
|
29
|
+
totalTasks,
|
|
30
|
+
earnedPoints,
|
|
31
|
+
progress,
|
|
32
|
+
nextTaskId: nextTask?.id ?? null,
|
|
33
|
+
nextTaskTitle: nextTask?.title ?? null,
|
|
34
|
+
momentumLabel,
|
|
35
|
+
time: tasks.reduce((summary, task) => ({
|
|
36
|
+
totalTrackedSeconds: summary.totalTrackedSeconds + task.time.totalTrackedSeconds,
|
|
37
|
+
totalCreditedSeconds: Math.round((summary.totalCreditedSeconds + task.time.totalCreditedSeconds) * 100) / 100,
|
|
38
|
+
activeRunCount: summary.activeRunCount + task.time.activeRunCount,
|
|
39
|
+
hasCurrentRun: summary.hasCurrentRun || task.time.hasCurrentRun,
|
|
40
|
+
currentRunId: summary.currentRunId ?? task.time.currentRunId
|
|
41
|
+
}), emptyTaskTimeSummary())
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
export function listProjectSummaries(filters = {}) {
|
|
45
|
+
const goals = new Map(listGoals().map((goal) => [goal.id, goal]));
|
|
46
|
+
const tasks = listTasks();
|
|
47
|
+
return listProjects(filters).map((project) => {
|
|
48
|
+
const goal = goals.get(project.goalId);
|
|
49
|
+
const projectTasks = tasks.filter((task) => task.projectId === project.id);
|
|
50
|
+
return projectSummarySchema.parse({
|
|
51
|
+
...project,
|
|
52
|
+
goalTitle: goal?.title ?? "Unknown life goal",
|
|
53
|
+
...projectTaskSummary(projectTasks)
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
export function getProjectSummary(projectId) {
|
|
58
|
+
return listProjectSummaries().find((project) => project.id === projectId);
|
|
59
|
+
}
|
|
60
|
+
export function getProjectBoard(projectId) {
|
|
61
|
+
const project = getProjectSummary(projectId);
|
|
62
|
+
if (!project) {
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
const goal = getGoalById(project.goalId);
|
|
66
|
+
if (!goal) {
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
return projectBoardPayloadSchema.parse({
|
|
70
|
+
project,
|
|
71
|
+
goal,
|
|
72
|
+
tasks: listTasks({ projectId }),
|
|
73
|
+
activity: listActivityEvents({ entityType: "project", entityId: projectId, limit: 20 }).concat(listActivityEvents({ entityType: "task", limit: 100 }).filter((event) => listTasks({ projectId }).some((task) => task.id === event.entityId)).slice(0, 20))
|
|
74
|
+
});
|
|
75
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { getDomainBySlug } from "../repositories/domains.js";
|
|
2
|
+
import { listComments } from "../repositories/comments.js";
|
|
3
|
+
import { listInsights } from "../repositories/collaboration.js";
|
|
4
|
+
import { listBehaviorPatterns, listBehaviors, listBeliefEntries, listModeProfiles, listPsycheValues, listSchemaCatalog, listTriggerReports } from "../repositories/psyche.js";
|
|
5
|
+
import { psycheOverviewPayloadSchema } from "../psyche-types.js";
|
|
6
|
+
const PSYCHE_ENTITY_TYPE_SET = new Set([
|
|
7
|
+
"psyche_value",
|
|
8
|
+
"behavior_pattern",
|
|
9
|
+
"behavior",
|
|
10
|
+
"belief_entry",
|
|
11
|
+
"mode_profile",
|
|
12
|
+
"trigger_report"
|
|
13
|
+
]);
|
|
14
|
+
export function getPsycheOverview() {
|
|
15
|
+
const domain = getDomainBySlug("psyche");
|
|
16
|
+
if (!domain) {
|
|
17
|
+
throw new Error("Psyche domain is not available");
|
|
18
|
+
}
|
|
19
|
+
const values = listPsycheValues();
|
|
20
|
+
const patterns = listBehaviorPatterns();
|
|
21
|
+
const behaviors = listBehaviors();
|
|
22
|
+
const beliefs = listBeliefEntries();
|
|
23
|
+
const modes = listModeProfiles();
|
|
24
|
+
const reports = listTriggerReports(5);
|
|
25
|
+
const schemaCatalog = listSchemaCatalog();
|
|
26
|
+
const comments = listComments({ limit: 200 });
|
|
27
|
+
const openInsights = listInsights({ limit: 100 }).filter((insight) => insight.entityType && PSYCHE_ENTITY_TYPE_SET.has(insight.entityType)).length;
|
|
28
|
+
const unresolvedComments = comments.filter((comment) => PSYCHE_ENTITY_TYPE_SET.has(comment.entityType)).length;
|
|
29
|
+
const committedActions = [
|
|
30
|
+
...values.flatMap((value) => value.committedActions),
|
|
31
|
+
...behaviors.filter((behavior) => behavior.kind === "committed").map((behavior) => behavior.title),
|
|
32
|
+
...reports.flatMap((report) => report.nextMoves)
|
|
33
|
+
];
|
|
34
|
+
const schemaPressure = schemaCatalog
|
|
35
|
+
.filter((schema) => schema.schemaType === "maladaptive")
|
|
36
|
+
.map((schema) => {
|
|
37
|
+
const activationCount = beliefs.filter((belief) => belief.schemaId === schema.id).length +
|
|
38
|
+
behaviors.filter((behavior) => behavior.linkedSchemaIds.includes(schema.id)).length +
|
|
39
|
+
reports.filter((report) => report.schemaLinks.includes(schema.id) || report.schemaLinks.includes(schema.slug)).length;
|
|
40
|
+
return {
|
|
41
|
+
schemaId: schema.id,
|
|
42
|
+
title: schema.title,
|
|
43
|
+
activationCount
|
|
44
|
+
};
|
|
45
|
+
})
|
|
46
|
+
.filter((entry) => entry.activationCount > 0)
|
|
47
|
+
.sort((left, right) => right.activationCount - left.activationCount)
|
|
48
|
+
.slice(0, 6);
|
|
49
|
+
return psycheOverviewPayloadSchema.parse({
|
|
50
|
+
generatedAt: new Date().toISOString(),
|
|
51
|
+
domain,
|
|
52
|
+
values,
|
|
53
|
+
patterns,
|
|
54
|
+
behaviors,
|
|
55
|
+
beliefs,
|
|
56
|
+
modes,
|
|
57
|
+
reports,
|
|
58
|
+
schemaPressure,
|
|
59
|
+
openInsights,
|
|
60
|
+
unresolvedComments,
|
|
61
|
+
committedActions
|
|
62
|
+
});
|
|
63
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { getGoalById } from "../repositories/goals.js";
|
|
2
|
+
import { listTagsByIds } from "../repositories/tags.js";
|
|
3
|
+
import { HttpError } from "../errors.js";
|
|
4
|
+
function assertKnownTags(tagIds) {
|
|
5
|
+
if (tagIds.length === 0) {
|
|
6
|
+
return;
|
|
7
|
+
}
|
|
8
|
+
const knownTagIds = new Set(listTagsByIds(tagIds).map((tag) => tag.id));
|
|
9
|
+
const missingTagIds = [...new Set(tagIds)].filter((tagId) => !knownTagIds.has(tagId));
|
|
10
|
+
if (missingTagIds.length > 0) {
|
|
11
|
+
throw new HttpError(404, "tag_not_found", `Unknown tag ids: ${missingTagIds.join(", ")}`);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export function assertGoalExists(goalId) {
|
|
15
|
+
if (!goalId) {
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
if (!getGoalById(goalId)) {
|
|
19
|
+
throw new HttpError(404, "goal_not_found", `Goal ${goalId} does not exist`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export function assertTaskRelations(input) {
|
|
23
|
+
assertGoalExists(input.goalId);
|
|
24
|
+
assertKnownTags(input.tagIds ?? []);
|
|
25
|
+
}
|
|
26
|
+
export function assertGoalRelations(input) {
|
|
27
|
+
assertKnownTags(input.tagIds ?? []);
|
|
28
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { listActivityEvents } from "../repositories/activity-events.js";
|
|
2
|
+
import { listGoals } from "../repositories/goals.js";
|
|
3
|
+
import { listTasks } from "../repositories/tasks.js";
|
|
4
|
+
import { buildGamificationProfile } from "./gamification.js";
|
|
5
|
+
import { weeklyReviewPayloadSchema } from "../types.js";
|
|
6
|
+
function startOfWeek(date) {
|
|
7
|
+
const clone = new Date(date);
|
|
8
|
+
const day = clone.getDay();
|
|
9
|
+
const delta = day === 0 ? -6 : 1 - day;
|
|
10
|
+
clone.setDate(clone.getDate() + delta);
|
|
11
|
+
clone.setHours(0, 0, 0, 0);
|
|
12
|
+
return clone;
|
|
13
|
+
}
|
|
14
|
+
function addDays(date, days) {
|
|
15
|
+
const clone = new Date(date);
|
|
16
|
+
clone.setDate(clone.getDate() + days);
|
|
17
|
+
return clone;
|
|
18
|
+
}
|
|
19
|
+
function formatRange(start, end) {
|
|
20
|
+
return `${start.toLocaleDateString("en-US", { month: "short", day: "numeric" })} - ${end.toLocaleDateString("en-US", { month: "short", day: "numeric" })}`;
|
|
21
|
+
}
|
|
22
|
+
function dailyBuckets(tasks, start) {
|
|
23
|
+
return Array.from({ length: 7 }, (_, index) => {
|
|
24
|
+
const current = addDays(start, index);
|
|
25
|
+
const dayLabel = current.toLocaleDateString("en-US", { weekday: "short" }).toUpperCase();
|
|
26
|
+
const dayIso = current.toISOString().slice(0, 10);
|
|
27
|
+
const completed = tasks.filter((task) => task.completedAt?.slice(0, 10) === dayIso);
|
|
28
|
+
const totalXp = completed.reduce((sum, task) => sum + task.points, 0);
|
|
29
|
+
return {
|
|
30
|
+
label: dayLabel,
|
|
31
|
+
xp: totalXp,
|
|
32
|
+
focusHours: completed.length * 2 + completed.filter((task) => task.effort !== "light").length
|
|
33
|
+
};
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
export function getWeeklyReviewPayload(now = new Date()) {
|
|
37
|
+
const goals = listGoals();
|
|
38
|
+
const tasks = listTasks();
|
|
39
|
+
const gamification = buildGamificationProfile(goals, tasks, now);
|
|
40
|
+
const weekStart = startOfWeek(now);
|
|
41
|
+
const weekEnd = addDays(weekStart, 6);
|
|
42
|
+
const weekTasks = tasks.filter((task) => task.updatedAt >= weekStart.toISOString() && task.updatedAt <= addDays(weekEnd, 1).toISOString());
|
|
43
|
+
const completedTasks = weekTasks.filter((task) => task.completedAt !== null);
|
|
44
|
+
const buckets = dailyBuckets(tasks, weekStart);
|
|
45
|
+
const totalXp = completedTasks.reduce((sum, task) => sum + task.points, 0);
|
|
46
|
+
const peakBucket = [...buckets].sort((left, right) => right.xp - left.xp)[0] ?? buckets[0];
|
|
47
|
+
const activity = listActivityEvents({ limit: 20 }).slice(0, 4);
|
|
48
|
+
const wins = activity.length > 0
|
|
49
|
+
? activity.map((event) => ({
|
|
50
|
+
id: event.id,
|
|
51
|
+
title: event.title,
|
|
52
|
+
summary: event.description || "Structured proof of movement.",
|
|
53
|
+
rewardXp: typeof event.metadata.points === "number" ? event.metadata.points : 40
|
|
54
|
+
}))
|
|
55
|
+
: completedTasks.slice(0, 3).map((task) => ({
|
|
56
|
+
id: task.id,
|
|
57
|
+
title: task.title,
|
|
58
|
+
summary: task.description || "Completed work converted into evidence.",
|
|
59
|
+
rewardXp: task.points
|
|
60
|
+
}));
|
|
61
|
+
return weeklyReviewPayloadSchema.parse({
|
|
62
|
+
generatedAt: now.toISOString(),
|
|
63
|
+
windowLabel: formatRange(weekStart, weekEnd),
|
|
64
|
+
momentumSummary: {
|
|
65
|
+
totalXp,
|
|
66
|
+
focusHours: buckets.reduce((sum, bucket) => sum + bucket.focusHours, 0),
|
|
67
|
+
efficiencyScore: Math.min(100, gamification.momentumScore + completedTasks.length * 3),
|
|
68
|
+
peakWindow: peakBucket.label
|
|
69
|
+
},
|
|
70
|
+
chart: buckets,
|
|
71
|
+
wins,
|
|
72
|
+
calibration: goals.slice(0, 3).map((goal, index) => ({
|
|
73
|
+
id: goal.id,
|
|
74
|
+
title: goal.title,
|
|
75
|
+
mode: index === 0 ? "accelerate" : index === 1 ? "maintain" : "recover",
|
|
76
|
+
note: index === 0
|
|
77
|
+
? "This arc has enough evidence to push harder next cycle."
|
|
78
|
+
: index === 1
|
|
79
|
+
? "Keep the current load and prevent drift."
|
|
80
|
+
: "Reduce friction and re-sequence the next steps."
|
|
81
|
+
})),
|
|
82
|
+
reward: {
|
|
83
|
+
title: "Review Completion Bonus",
|
|
84
|
+
summary: "Finalizing the review locks the current cycle into evidence.",
|
|
85
|
+
rewardXp: 250
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { recoverTimedOutTaskRuns } from "../repositories/task-runs.js";
|
|
2
|
+
export function reconcileExpiredTaskRuns(options = {}) {
|
|
3
|
+
const now = options.now ?? new Date();
|
|
4
|
+
const recoveredRuns = recoverTimedOutTaskRuns({ now, limit: options.limit });
|
|
5
|
+
return {
|
|
6
|
+
recoveredAt: now.toISOString(),
|
|
7
|
+
recoveredCount: recoveredRuns.length,
|
|
8
|
+
recoveredRunIds: recoveredRuns.map((run) => run.id)
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
export function recoverExpiredTaskRunsOnStartup(options = {}) {
|
|
12
|
+
return reconcileExpiredTaskRuns(options);
|
|
13
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { getGoalById } from "../repositories/goals.js";
|
|
2
|
+
import { listTags, listTagsByIds } from "../repositories/tags.js";
|
|
3
|
+
const keywordHints = {
|
|
4
|
+
health: ["Health", "Vitality"],
|
|
5
|
+
train: ["Health", "Vitality"],
|
|
6
|
+
workout: ["Health", "Vitality"],
|
|
7
|
+
review: ["Reflection", "Craft"],
|
|
8
|
+
plan: ["Momentum", "Admin"],
|
|
9
|
+
admin: ["Admin"],
|
|
10
|
+
write: ["Craft", "Deep Work"],
|
|
11
|
+
draft: ["Craft", "Deep Work"],
|
|
12
|
+
relationship: ["Relationships"],
|
|
13
|
+
date: ["Relationships"],
|
|
14
|
+
focus: ["Deep Work"]
|
|
15
|
+
};
|
|
16
|
+
function uniqueTags(tags) {
|
|
17
|
+
const seen = new Set();
|
|
18
|
+
return tags.filter((tag) => {
|
|
19
|
+
if (seen.has(tag.id)) {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
seen.add(tag.id);
|
|
23
|
+
return true;
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
export function suggestTags(input) {
|
|
27
|
+
const tagCatalog = listTags();
|
|
28
|
+
const selected = new Set(input.selectedTagIds);
|
|
29
|
+
const terms = `${input.title} ${input.description}`.toLowerCase();
|
|
30
|
+
const suggestions = [];
|
|
31
|
+
for (const [keyword, tagNames] of Object.entries(keywordHints)) {
|
|
32
|
+
if (!terms.includes(keyword)) {
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
for (const tagName of tagNames) {
|
|
36
|
+
const tag = tagCatalog.find((entry) => entry.name === tagName);
|
|
37
|
+
if (tag && !selected.has(tag.id)) {
|
|
38
|
+
suggestions.push(tag);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (input.goalId) {
|
|
43
|
+
const goal = getGoalById(input.goalId);
|
|
44
|
+
if (goal) {
|
|
45
|
+
suggestions.push(...listTagsByIds(goal.tagIds).filter((tag) => !selected.has(tag.id)));
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return uniqueTags(suggestions).slice(0, 6);
|
|
49
|
+
}
|