Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* [PR-19](https://github.com/itk-dev/itk-project-database/pull/19)
Auto-upload files with a progress bar and image preview, view images in an
in-page lightbox, and refresh the media field styling.
* [PR-17](https://github.com/itk-dev/itk-project-database/pull/17)
Fix the mascot nudges that never appeared, nudge users to finish incomplete
contacts with a link to their edit page.
* [PR-16](https://github.com/itk-dev/itk-project-database/pull/16)
Turn the strategies and tags fields into a searchable,
shared tag pool where new entries are capitalised and reused as suggestions.
Expand Down
38 changes: 34 additions & 4 deletions assets/controllers/mascot_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,14 @@ import { Controller } from "@hotwired/stimulus";
* pointer until it tags it back. Messages arrive already translated.
*/
export default class extends Controller {
static targets = ["bubble", "text", "cta", "play", "finish"];
static targets = [
"bubble",
"text",
"cta",
"play",
"finish",
"finishContact",
];

static values = {
messages: { type: Array, default: [] },
Expand All @@ -20,6 +27,7 @@ export default class extends Controller {
giveup: String,
escaped: String,
finishTexts: { type: Array, default: [] },
finishContactTexts: { type: Array, default: [] },
enabled: { type: Boolean, default: true },
farewell: String,
welcome: String,
Expand Down Expand Up @@ -193,13 +201,16 @@ export default class extends Controller {
this.scheduleNext(this.intervalValue);
}

// Clicking the avatar only does something during the play-catch game; a plain
// idle click is intentionally inert (no message, no animation).
// Clicking the avatar plays catch mid-game; an idle click pops a fresh
// message and resets the cadence so the next auto-message isn't right behind.
poke() {
if ("invited" === this.mode) {
this.startFlee();
} else if ("flee" === this.mode) {
this.caught();
} else if ("idle" === this.mode) {
this.speak();
this.scheduleNext(this.intervalValue);
}
}

Expand All @@ -212,13 +223,29 @@ export default class extends Controller {
return;
}

// A contact created on the fly (name only) gets a gentle reminder to
// finish it, linking straight to its edit page.
const contactTexts = this.finishContactTextsValue;
if (
this.hasFinishContactTarget &&
contactTexts.length > 0 &&
Math.random() < 0.15
) {
this.say(
contactTexts[Math.floor(Math.random() * contactTexts.length)],
"finishContact",
);

return;
}

// Now and then, nudge the user to finish their least-complete initiative,
// picking one of the finish lines at random for variety.
const finishTexts = this.finishTextsValue;
if (
this.hasFinishTarget &&
finishTexts.length > 0 &&
Math.random() < 0.4
Math.random() < 0.15
) {
this.say(
finishTexts[Math.floor(Math.random() * finishTexts.length)],
Expand Down Expand Up @@ -388,6 +415,9 @@ export default class extends Controller {
if (this.hasFinishTarget) {
this.finishTarget.hidden = "finish" !== action;
}
if (this.hasFinishContactTarget) {
this.finishContactTarget.hidden = "finishContact" !== action;
}
this.bubbleTarget.hidden = false;
window.requestAnimationFrame(() => {
this.bubbleTarget.classList.add("is-visible");
Expand Down
27 changes: 27 additions & 0 deletions assets/styles/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -2050,6 +2050,33 @@ body.is-lightbox-open {
overflow: visible;
}

.mascot__gold-stop {
animation: mascot-gold 9s ease-in-out infinite;
}

.mascot__gold-stop:nth-child(2) {
animation-delay: -3s;
}

.mascot__gold-stop:nth-child(3) {
animation-delay: -6s;
}

@keyframes mascot-gold {
0% {
stop-color: #ffe488;
}
33% {
stop-color: #fcc24a;
}
66% {
stop-color: #eaa326;
}
100% {
stop-color: #ffe488;
}
}

@keyframes mascot-bob {
0%,
100% {
Expand Down
21 changes: 21 additions & 0 deletions src/Repository/ContactRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace App\Repository;

use App\Entity\Contact;
use App\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;

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

return $contact;
}

/**
* The user's most recent contact that still lacks an email — typically one
* they created on the fly from an initiative's contact picker (name only).
* Used by the mascot to nudge them to fill in the rest.
*/
public function findIncompleteByCreator(User $user): ?Contact
{
// createdBy is a ManyToOne to the UserInterface (resolved to User via
// resolve_target_entities); binding the entity to a ULID FK doesn't match,
// so compare the raw FK against the user's id with the ulid type applied.
return $this->createQueryBuilder('c')
->andWhere('IDENTITY(c.createdBy) = :user')
->andWhere("(c.email IS NULL OR c.email = '')")
->setParameter('user', $user->getId(), 'ulid')
->orderBy('c.createdAt', 'DESC')
->setMaxResults(1)
->getQuery()
->getOneOrNullResult();
}
}
18 changes: 11 additions & 7 deletions src/Repository/InitiativeRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace App\Repository;

use App\Entity\Initiative;
use App\Entity\User;
use App\Enum\EndorsementAuthor;
use App\Enum\Funding;
use App\Enum\InitiativeType;
Expand All @@ -14,7 +15,6 @@
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Contracts\Translation\TranslatorInterface;

/**
Expand Down Expand Up @@ -165,12 +165,15 @@ public function countAll(): int
->getSingleScalarResult();
}

public function countByCreator(UserInterface $user): int
public function countByCreator(User $user): int
{
// createdBy is a ManyToOne to the UserInterface (resolved to User via
// resolve_target_entities); binding the entity to a ULID FK doesn't match,
// so compare the raw FK against the user's id with the ulid type applied.
return (int) $this->createQueryBuilder('i')
->select('COUNT(i.id)')
->andWhere('i.createdBy = :user')
->setParameter('user', $user)
->andWhere('IDENTITY(i.createdBy) = :user')
->setParameter('user', $user->getId(), 'ulid')
->getQuery()
->getSingleScalarResult();
}
Expand All @@ -180,11 +183,12 @@ public function countByCreator(UserInterface $user): int
* null if none are outstanding. Completion is computed in PHP (not a stored
* column), so this scans only the creator's 50 most recent initiatives.
*/
public function findUnfinishedByCreator(UserInterface $user): ?Initiative
public function findUnfinishedByCreator(User $user): ?Initiative
{
// See countByCreator: match the raw ULID FK, not the entity.
$initiatives = $this->createQueryBuilder('i')
->andWhere('i.createdBy = :user')
->setParameter('user', $user)
->andWhere('IDENTITY(i.createdBy) = :user')
->setParameter('user', $user->getId(), 'ulid')
->orderBy('i.createdAt', 'DESC')
->setMaxResults(50)
->getQuery()
Expand Down
8 changes: 6 additions & 2 deletions src/Twig/MascotExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@

namespace App\Twig;

use App\Entity\Contact;
use App\Entity\Initiative;
use App\Entity\User;
use App\Repository\ContactRepository;
use App\Repository\InitiativeRepository;
use Symfony\Bundle\SecurityBundle\Security;
use Twig\Extension\AbstractExtension;
Expand All @@ -21,6 +23,7 @@ class MascotExtension extends AbstractExtension
public function __construct(
private readonly Security $security,
private readonly InitiativeRepository $initiatives,
private readonly ContactRepository $contacts,
) {
}

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

/**
* @return array{count: int, unfinished: Initiative|null}
* @return array{count: int, unfinished: Initiative|null, incompleteContact: Contact|null}
*/
public function context(): array
{
$user = $this->security->getUser();
if (!$user instanceof User) {
return ['count' => 0, 'unfinished' => null];
return ['count' => 0, 'unfinished' => null, 'incompleteContact' => null];
}

return [
'count' => $this->initiatives->countByCreator($user),
'unfinished' => $this->initiatives->findUnfinishedByCreator($user),
'incompleteContact' => $this->contacts->findIncompleteByCreator($user),
];
}
}
22 changes: 21 additions & 1 deletion templates/base.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,15 @@
'mascot.finish.strong'|trans(finishParams),
] %}
{% endif %}
{% set contactMessages = [] %}
{% if ctx.incompleteContact %}
{% set contactParams = {'%name%': ctx.incompleteContact.name} %}
{% set contactMessages = [
'mascot.contact.details'|trans(contactParams),
'mascot.contact.quick'|trans(contactParams),
'mascot.contact.name_only'|trans(contactParams),
] %}
{% endif %}
<div class="mascot{{ app.user.mascotEnabled ? '' : ' mascot--away' }}" data-controller="mascot"
data-action="mouseenter->mascot#pause mouseleave->mascot#resume"
data-mascot-enabled-value="{{ app.user.mascotEnabled ? 'true' : 'false' }}"
Expand All @@ -121,6 +130,7 @@
data-mascot-giveup-value="{{ 'mascot.play.giveup'|trans }}"
data-mascot-escaped-value="{{ 'mascot.play.escaped'|trans }}"
{% if ctx.unfinished %}data-mascot-finish-texts-value="{{ finishMessages|json_encode }}"{% endif %}
{% if ctx.incompleteContact %}data-mascot-finish-contact-texts-value="{{ contactMessages|json_encode }}"{% endif %}
data-mascot-messages-value="{{ messages|json_encode }}">
<div class="mascot__bubble" data-mascot-target="bubble" role="status" aria-live="polite" hidden>
<button type="button" class="mascot__close" data-action="mascot#close" aria-label="{{ 'mascot.close'|trans }}">×</button>
Expand All @@ -129,11 +139,21 @@
{% if ctx.unfinished %}
<a class="mascot__cta" data-mascot-target="finish" href="{{ path('app_initiative_edit', {id: ctx.unfinished.id}) }}" hidden>{{ 'mascot.finish_cta'|trans }} →</a>
{% endif %}
{% if ctx.incompleteContact %}
<a class="mascot__cta" data-mascot-target="finishContact" href="{{ path('admin_contact_edit', {id: ctx.incompleteContact.id}) }}" hidden>{{ 'mascot.finish_cta'|trans }} →</a>
{% endif %}
<button type="button" class="mascot__play" data-mascot-target="play" data-action="mascot#startFlee" hidden>{{ 'mascot.play.start'|trans }} →</button>
</div>
<button type="button" class="mascot__avatar" data-action="mascot#poke" aria-label="{{ 'mascot.label'|trans }}">
<svg viewBox="0 0 24 24" aria-hidden="true">
<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"/>
<defs>
<linearGradient id="mascotGold" x1="0" y1="0" x2="0.5" y2="1">
<stop class="mascot__gold-stop" offset="0%" stop-color="#ffe488"/>
<stop class="mascot__gold-stop" offset="50%" stop-color="#fcc24a"/>
<stop class="mascot__gold-stop" offset="100%" stop-color="#eaa326"/>
</linearGradient>
</defs>
<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"/>
<circle cx="9.6" cy="10.2" r="0.95" fill="#1f1300"/>
<circle cx="14.4" cy="10.2" r="0.95" fill="#1f1300"/>
<path d="M9.7 12.5c.7.8 3.9.8 4.6 0" fill="none" stroke="#1f1300" stroke-width="1" stroke-linecap="round"/>
Expand Down
2 changes: 1 addition & 1 deletion tests/Twig/MascotExtensionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ public function testContextIsEmptyWhenNoUserIsAuthenticated(): void
\assert($extension instanceof MascotExtension);

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

public function testRegistersTheMascotContextFunction(): void
Expand Down
4 changes: 4 additions & 0 deletions translations/messages.da.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ mascot:
ending: "“%title%” er %percent% % færdig. Giv den en værdig afslutning! 🏁"
strong: "“%title%” er %percent% % færdig — lad os tage den sidste bid! 💪"
finish_cta: Gør færdig
contact:
details: "Kontaktpersonen “%name%” som du har oprettet mangler stadig oplysninger — Vil du gøre den færdig?"
quick: "Du tilføjede “%name%” i farten — vil du udfylde resten? ✏️"
name_only: "“%name%” har kun et navn endnu. Skal vi tilføje detaljerne?"
msg:
idea: "Har du en ny idé? Så er det her, den hører til. ✨"
roll: "Du er godt i gang — hvad bliver det næste?"
Expand Down
4 changes: 4 additions & 0 deletions translations/messages.en.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ mascot:
ending: "“%title%” is %percent%% done. Give it the ending it deserves! 🏁"
strong: "“%title%” is %percent%% there — let’s finish strong! 💪"
finish_cta: Finish it
contact:
details: "“%name%” is still missing details — shall we finish the contact?"
quick: "You added “%name%” on the fly — want to fill in the rest? ✏️"
name_only: "“%name%” only has a name so far. Shall we add the details?"
msg:
idea: "Got a fresh idea? Let’s give it a home. ✨"
roll: "You’re on a roll — what’s next?"
Expand Down
Loading