Skip to content

Commit 9b861c2

Browse files
Merge pull request #17 from itk-dev/feature/7792-mascot-dialogue-bug-fix
feat: Mascot message improvements
2 parents c5a90ec + a5e6b3e commit 9b861c2

10 files changed

Lines changed: 132 additions & 15 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
* [PR-19](https://github.com/itk-dev/itk-project-database/pull/19)
1111
Auto-upload files with a progress bar and image preview, view images in an
1212
in-page lightbox, and refresh the media field styling.
13+
* [PR-17](https://github.com/itk-dev/itk-project-database/pull/17)
14+
Fix the mascot nudges that never appeared, nudge users to finish incomplete
15+
contacts with a link to their edit page.
1316
* [PR-16](https://github.com/itk-dev/itk-project-database/pull/16)
1417
Turn the strategies and tags fields into a searchable,
1518
shared tag pool where new entries are capitalised and reused as suggestions.

assets/controllers/mascot_controller.js

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,14 @@ import { Controller } from "@hotwired/stimulus";
88
* pointer until it tags it back. Messages arrive already translated.
99
*/
1010
export default class extends Controller {
11-
static targets = ["bubble", "text", "cta", "play", "finish"];
11+
static targets = [
12+
"bubble",
13+
"text",
14+
"cta",
15+
"play",
16+
"finish",
17+
"finishContact",
18+
];
1219

1320
static values = {
1421
messages: { type: Array, default: [] },
@@ -20,6 +27,7 @@ export default class extends Controller {
2027
giveup: String,
2128
escaped: String,
2229
finishTexts: { type: Array, default: [] },
30+
finishContactTexts: { type: Array, default: [] },
2331
enabled: { type: Boolean, default: true },
2432
farewell: String,
2533
welcome: String,
@@ -193,13 +201,16 @@ export default class extends Controller {
193201
this.scheduleNext(this.intervalValue);
194202
}
195203

196-
// Clicking the avatar only does something during the play-catch game; a plain
197-
// idle click is intentionally inert (no message, no animation).
204+
// Clicking the avatar plays catch mid-game; an idle click pops a fresh
205+
// message and resets the cadence so the next auto-message isn't right behind.
198206
poke() {
199207
if ("invited" === this.mode) {
200208
this.startFlee();
201209
} else if ("flee" === this.mode) {
202210
this.caught();
211+
} else if ("idle" === this.mode) {
212+
this.speak();
213+
this.scheduleNext(this.intervalValue);
203214
}
204215
}
205216

@@ -212,13 +223,29 @@ export default class extends Controller {
212223
return;
213224
}
214225

226+
// A contact created on the fly (name only) gets a gentle reminder to
227+
// finish it, linking straight to its edit page.
228+
const contactTexts = this.finishContactTextsValue;
229+
if (
230+
this.hasFinishContactTarget &&
231+
contactTexts.length > 0 &&
232+
Math.random() < 0.15
233+
) {
234+
this.say(
235+
contactTexts[Math.floor(Math.random() * contactTexts.length)],
236+
"finishContact",
237+
);
238+
239+
return;
240+
}
241+
215242
// Now and then, nudge the user to finish their least-complete initiative,
216243
// picking one of the finish lines at random for variety.
217244
const finishTexts = this.finishTextsValue;
218245
if (
219246
this.hasFinishTarget &&
220247
finishTexts.length > 0 &&
221-
Math.random() < 0.4
248+
Math.random() < 0.15
222249
) {
223250
this.say(
224251
finishTexts[Math.floor(Math.random() * finishTexts.length)],
@@ -388,6 +415,9 @@ export default class extends Controller {
388415
if (this.hasFinishTarget) {
389416
this.finishTarget.hidden = "finish" !== action;
390417
}
418+
if (this.hasFinishContactTarget) {
419+
this.finishContactTarget.hidden = "finishContact" !== action;
420+
}
391421
this.bubbleTarget.hidden = false;
392422
window.requestAnimationFrame(() => {
393423
this.bubbleTarget.classList.add("is-visible");

assets/styles/app.css

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2050,6 +2050,33 @@ body.is-lightbox-open {
20502050
overflow: visible;
20512051
}
20522052

2053+
.mascot__gold-stop {
2054+
animation: mascot-gold 9s ease-in-out infinite;
2055+
}
2056+
2057+
.mascot__gold-stop:nth-child(2) {
2058+
animation-delay: -3s;
2059+
}
2060+
2061+
.mascot__gold-stop:nth-child(3) {
2062+
animation-delay: -6s;
2063+
}
2064+
2065+
@keyframes mascot-gold {
2066+
0% {
2067+
stop-color: #ffe488;
2068+
}
2069+
33% {
2070+
stop-color: #fcc24a;
2071+
}
2072+
66% {
2073+
stop-color: #eaa326;
2074+
}
2075+
100% {
2076+
stop-color: #ffe488;
2077+
}
2078+
}
2079+
20532080
@keyframes mascot-bob {
20542081
0%,
20552082
100% {

src/Repository/ContactRepository.php

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
namespace App\Repository;
66

77
use App\Entity\Contact;
8+
use App\Entity\User;
89
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
910
use Doctrine\Persistence\ManagerRegistry;
1011

@@ -55,4 +56,24 @@ public function findOrCreate(string $name): Contact
5556

5657
return $contact;
5758
}
59+
60+
/**
61+
* The user's most recent contact that still lacks an email — typically one
62+
* they created on the fly from an initiative's contact picker (name only).
63+
* Used by the mascot to nudge them to fill in the rest.
64+
*/
65+
public function findIncompleteByCreator(User $user): ?Contact
66+
{
67+
// createdBy is a ManyToOne to the UserInterface (resolved to User via
68+
// resolve_target_entities); binding the entity to a ULID FK doesn't match,
69+
// so compare the raw FK against the user's id with the ulid type applied.
70+
return $this->createQueryBuilder('c')
71+
->andWhere('IDENTITY(c.createdBy) = :user')
72+
->andWhere("(c.email IS NULL OR c.email = '')")
73+
->setParameter('user', $user->getId(), 'ulid')
74+
->orderBy('c.createdAt', 'DESC')
75+
->setMaxResults(1)
76+
->getQuery()
77+
->getOneOrNullResult();
78+
}
5879
}

src/Repository/InitiativeRepository.php

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
namespace App\Repository;
66

77
use App\Entity\Initiative;
8+
use App\Entity\User;
89
use App\Enum\EndorsementAuthor;
910
use App\Enum\Funding;
1011
use App\Enum\InitiativeType;
@@ -14,7 +15,6 @@
1415
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
1516
use Doctrine\ORM\QueryBuilder;
1617
use Doctrine\Persistence\ManagerRegistry;
17-
use Symfony\Component\Security\Core\User\UserInterface;
1818
use Symfony\Contracts\Translation\TranslatorInterface;
1919

2020
/**
@@ -165,12 +165,15 @@ public function countAll(): int
165165
->getSingleScalarResult();
166166
}
167167

168-
public function countByCreator(UserInterface $user): int
168+
public function countByCreator(User $user): int
169169
{
170+
// createdBy is a ManyToOne to the UserInterface (resolved to User via
171+
// resolve_target_entities); binding the entity to a ULID FK doesn't match,
172+
// so compare the raw FK against the user's id with the ulid type applied.
170173
return (int) $this->createQueryBuilder('i')
171174
->select('COUNT(i.id)')
172-
->andWhere('i.createdBy = :user')
173-
->setParameter('user', $user)
175+
->andWhere('IDENTITY(i.createdBy) = :user')
176+
->setParameter('user', $user->getId(), 'ulid')
174177
->getQuery()
175178
->getSingleScalarResult();
176179
}
@@ -180,11 +183,12 @@ public function countByCreator(UserInterface $user): int
180183
* null if none are outstanding. Completion is computed in PHP (not a stored
181184
* column), so this scans only the creator's 50 most recent initiatives.
182185
*/
183-
public function findUnfinishedByCreator(UserInterface $user): ?Initiative
186+
public function findUnfinishedByCreator(User $user): ?Initiative
184187
{
188+
// See countByCreator: match the raw ULID FK, not the entity.
185189
$initiatives = $this->createQueryBuilder('i')
186-
->andWhere('i.createdBy = :user')
187-
->setParameter('user', $user)
190+
->andWhere('IDENTITY(i.createdBy) = :user')
191+
->setParameter('user', $user->getId(), 'ulid')
188192
->orderBy('i.createdAt', 'DESC')
189193
->setMaxResults(50)
190194
->getQuery()

src/Twig/MascotExtension.php

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@
44

55
namespace App\Twig;
66

7+
use App\Entity\Contact;
78
use App\Entity\Initiative;
89
use App\Entity\User;
10+
use App\Repository\ContactRepository;
911
use App\Repository\InitiativeRepository;
1012
use Symfony\Bundle\SecurityBundle\Security;
1113
use Twig\Extension\AbstractExtension;
@@ -21,6 +23,7 @@ class MascotExtension extends AbstractExtension
2123
public function __construct(
2224
private readonly Security $security,
2325
private readonly InitiativeRepository $initiatives,
26+
private readonly ContactRepository $contacts,
2427
) {
2528
}
2629

@@ -32,18 +35,19 @@ public function getFunctions(): array
3235
}
3336

3437
/**
35-
* @return array{count: int, unfinished: Initiative|null}
38+
* @return array{count: int, unfinished: Initiative|null, incompleteContact: Contact|null}
3639
*/
3740
public function context(): array
3841
{
3942
$user = $this->security->getUser();
4043
if (!$user instanceof User) {
41-
return ['count' => 0, 'unfinished' => null];
44+
return ['count' => 0, 'unfinished' => null, 'incompleteContact' => null];
4245
}
4346

4447
return [
4548
'count' => $this->initiatives->countByCreator($user),
4649
'unfinished' => $this->initiatives->findUnfinishedByCreator($user),
50+
'incompleteContact' => $this->contacts->findIncompleteByCreator($user),
4751
];
4852
}
4953
}

templates/base.html.twig

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,15 @@
109109
'mascot.finish.strong'|trans(finishParams),
110110
] %}
111111
{% endif %}
112+
{% set contactMessages = [] %}
113+
{% if ctx.incompleteContact %}
114+
{% set contactParams = {'%name%': ctx.incompleteContact.name} %}
115+
{% set contactMessages = [
116+
'mascot.contact.details'|trans(contactParams),
117+
'mascot.contact.quick'|trans(contactParams),
118+
'mascot.contact.name_only'|trans(contactParams),
119+
] %}
120+
{% endif %}
112121
<div class="mascot{{ app.user.mascotEnabled ? '' : ' mascot--away' }}" data-controller="mascot"
113122
data-action="mouseenter->mascot#pause mouseleave->mascot#resume"
114123
data-mascot-enabled-value="{{ app.user.mascotEnabled ? 'true' : 'false' }}"
@@ -121,6 +130,7 @@
121130
data-mascot-giveup-value="{{ 'mascot.play.giveup'|trans }}"
122131
data-mascot-escaped-value="{{ 'mascot.play.escaped'|trans }}"
123132
{% if ctx.unfinished %}data-mascot-finish-texts-value="{{ finishMessages|json_encode }}"{% endif %}
133+
{% if ctx.incompleteContact %}data-mascot-finish-contact-texts-value="{{ contactMessages|json_encode }}"{% endif %}
124134
data-mascot-messages-value="{{ messages|json_encode }}">
125135
<div class="mascot__bubble" data-mascot-target="bubble" role="status" aria-live="polite" hidden>
126136
<button type="button" class="mascot__close" data-action="mascot#close" aria-label="{{ 'mascot.close'|trans }}">×</button>
@@ -129,11 +139,21 @@
129139
{% if ctx.unfinished %}
130140
<a class="mascot__cta" data-mascot-target="finish" href="{{ path('app_initiative_edit', {id: ctx.unfinished.id}) }}" hidden>{{ 'mascot.finish_cta'|trans }} →</a>
131141
{% endif %}
142+
{% if ctx.incompleteContact %}
143+
<a class="mascot__cta" data-mascot-target="finishContact" href="{{ path('admin_contact_edit', {id: ctx.incompleteContact.id}) }}" hidden>{{ 'mascot.finish_cta'|trans }} →</a>
144+
{% endif %}
132145
<button type="button" class="mascot__play" data-mascot-target="play" data-action="mascot#startFlee" hidden>{{ 'mascot.play.start'|trans }} →</button>
133146
</div>
134147
<button type="button" class="mascot__avatar" data-action="mascot#poke" aria-label="{{ 'mascot.label'|trans }}">
135148
<svg viewBox="0 0 24 24" aria-hidden="true">
136-
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="#f5b800" stroke="#1f1300" stroke-width="1.1" stroke-linejoin="round"/>
149+
<defs>
150+
<linearGradient id="mascotGold" x1="0" y1="0" x2="0.5" y2="1">
151+
<stop class="mascot__gold-stop" offset="0%" stop-color="#ffe488"/>
152+
<stop class="mascot__gold-stop" offset="50%" stop-color="#fcc24a"/>
153+
<stop class="mascot__gold-stop" offset="100%" stop-color="#eaa326"/>
154+
</linearGradient>
155+
</defs>
156+
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="url(#mascotGold)" stroke="#1f1300" stroke-width="1.1" stroke-linejoin="round"/>
137157
<circle cx="9.6" cy="10.2" r="0.95" fill="#1f1300"/>
138158
<circle cx="14.4" cy="10.2" r="0.95" fill="#1f1300"/>
139159
<path d="M9.7 12.5c.7.8 3.9.8 4.6 0" fill="none" stroke="#1f1300" stroke-width="1" stroke-linecap="round"/>

tests/Twig/MascotExtensionTest.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ public function testContextIsEmptyWhenNoUserIsAuthenticated(): void
1616
\assert($extension instanceof MascotExtension);
1717

1818
// No user is logged in, so the mascot has no personal numbers to show.
19-
self::assertSame(['count' => 0, 'unfinished' => null], $extension->context());
19+
self::assertSame(['count' => 0, 'unfinished' => null, 'incompleteContact' => null], $extension->context());
2020
}
2121

2222
public function testRegistersTheMascotContextFunction(): void

translations/messages.da.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ mascot:
1717
ending: "“%title%” er %percent% % færdig. Giv den en værdig afslutning! 🏁"
1818
strong: "“%title%” er %percent% % færdig — lad os tage den sidste bid! 💪"
1919
finish_cta: Gør færdig
20+
contact:
21+
details: "Kontaktpersonen “%name%” som du har oprettet mangler stadig oplysninger — Vil du gøre den færdig?"
22+
quick: "Du tilføjede “%name%” i farten — vil du udfylde resten? ✏️"
23+
name_only: "“%name%” har kun et navn endnu. Skal vi tilføje detaljerne?"
2024
msg:
2125
idea: "Har du en ny idé? Så er det her, den hører til. ✨"
2226
roll: "Du er godt i gang — hvad bliver det næste?"

translations/messages.en.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ mascot:
1717
ending: "“%title%” is %percent%% done. Give it the ending it deserves! 🏁"
1818
strong: "“%title%” is %percent%% there — let’s finish strong! 💪"
1919
finish_cta: Finish it
20+
contact:
21+
details: "“%name%” is still missing details — shall we finish the contact?"
22+
quick: "You added “%name%” on the fly — want to fill in the rest? ✏️"
23+
name_only: "“%name%” only has a name so far. Shall we add the details?"
2024
msg:
2125
idea: "Got a fresh idea? Let’s give it a home. ✨"
2226
roll: "You’re on a roll — what’s next?"

0 commit comments

Comments
 (0)