From 847da193f0b74e14c2846626812908739b81bf2d Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Tue, 23 Jun 2026 09:28:49 +0200 Subject: [PATCH 1/5] feat: mark initiative fields that count toward udfyldningsgrad --- assets/styles/app.css | 19 +++++++++++++++++++ src/Form/InitiativeType.php | 15 +++++++++++++++ templates/form/fields.html.twig | 7 +++++++ translations/messages.da.yaml | 1 + translations/messages.en.yaml | 1 + 5 files changed, 43 insertions(+) diff --git a/assets/styles/app.css b/assets/styles/app.css index 343db82..872a657 100644 --- a/assets/styles/app.css +++ b/assets/styles/app.css @@ -1052,6 +1052,25 @@ h3 { color: var(--itk-slate-700); } +.completion-star { + display: inline-block; + margin-left: var(--itk-space-1); + color: gold; + font-size: 1.1em; + line-height: 1; + vertical-align: text-top; + -webkit-text-stroke: 0.75px #000; + paint-order: stroke fill; + cursor: help; +} + +.completion-star--gone { + width: 0; + margin-left: 0; + opacity: 0; + overflow: hidden; +} + .form-row .help-text, .form-row .form-help { font-size: var(--itk-text-xs); diff --git a/src/Form/InitiativeType.php b/src/Form/InitiativeType.php index fc3ae2e..5dd7ea7 100644 --- a/src/Form/InitiativeType.php +++ b/src/Form/InitiativeType.php @@ -22,6 +22,8 @@ use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\Extension\Core\Type\UrlType; use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\Form\FormInterface; +use Symfony\Component\Form\FormView; use Symfony\Component\OptionsResolver\OptionsResolver; /** @@ -180,6 +182,19 @@ public function buildForm(FormBuilderInterface $builder, array $options): void ]); } + /** + * Flag the fields that count towards {@see Initiative::getCompletionPercentage()} + * so the form theme can mark them. Keeps the list in one place. + */ + public function finishView(FormView $view, FormInterface $form, array $options): void + { + foreach (Initiative::COMPLETION_FIELDS as $field) { + if (isset($view[$field])) { + $view[$field]->vars['completion_field'] = true; + } + } + } + public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults([ diff --git a/templates/form/fields.html.twig b/templates/form/fields.html.twig index 7585252..83c0b77 100644 --- a/templates/form/fields.html.twig +++ b/templates/form/fields.html.twig @@ -1,5 +1,12 @@ {% use 'form_div_layout.html.twig' %} +{% block form_label_content %} + {{- parent() -}} + {%- if completion_field|default(false) -%} + + {%- endif -%} +{% endblock %} + {% block form_row %} {% set row_class = 'form-row' %}
diff --git a/translations/messages.da.yaml b/translations/messages.da.yaml index 6d14999..81a766f 100644 --- a/translations/messages.da.yaml +++ b/translations/messages.da.yaml @@ -126,6 +126,7 @@ initiative: published: Offentliggjort published_help: Kun offentliggjorte initiativer vises på det offentlige API. terms_help: Adskil flere værdier med komma. + completion_field_hint: Dette felt tæller med i udfyldningsgraden. images: Billeder attachments: Filer image_file: Billedfil diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml index b4c8999..a59808a 100644 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -126,6 +126,7 @@ initiative: published: Published published_help: Only published initiatives are exposed on the public API. terms_help: Separate multiple values with commas. + completion_field_hint: This field counts towards the completion rate. images: Images attachments: Files image_file: Image file From 2e95991ec4cd6df43715af92d4c9f77a2d034887 Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Tue, 23 Jun 2026 09:28:58 +0200 Subject: [PATCH 2/5] feat: animate completion stars flying into a progress trophy --- .../controllers/form_progress_controller.js | 225 +++++++++++++++++- assets/styles/app.css | 25 ++ templates/initiative/_form.html.twig | 1 + 3 files changed, 245 insertions(+), 6 deletions(-) diff --git a/assets/controllers/form_progress_controller.js b/assets/controllers/form_progress_controller.js index 0b4a94c..792f9bd 100644 --- a/assets/controllers/form_progress_controller.js +++ b/assets/controllers/form_progress_controller.js @@ -4,17 +4,26 @@ import { Controller } from "@hotwired/stimulus"; * Drives the initiative form's completion bar. Completion = filled / total over a * fixed set of fields (the `fields` value, shared with the server so the bar and * the list percentage always agree), grouped by field key so a multi-input field - * (links, funding) counts once. The bar's width and hue (red -> green) track the - * ratio. Used on both the new and edit forms. + * (links, funding) counts once. Used on both the new and edit forms. + * + * Stars: each starred (completion) field shows a star by its label. Filling the + * field makes that star fly into the "trophy" star at the end of the bar; clearing + * the field sends a star back from the trophy to the label. The bar and the trophy + * advance only once a forward star lands on the trophy, and deduct the moment a + * star leaves the trophy on the way back — so the motion and the numbers stay in + * sync. `shownCount` is what the bar currently shows, which trails the true field + * state during flight. */ export default class extends Controller { - static targets = ["fill", "label"]; + static targets = ["fill", "label", "star"]; static values = { fields: { type: Array, default: [] }, }; connect() { + this.filledKeys = null; + this.shownCount = 0; this.recompute(); } @@ -25,18 +34,109 @@ export default class extends Controller { const wanted = new Set(this.fieldsValue); const filled = new Set(); + const starForKey = new Map(); for (const el of this.element.querySelectorAll( "input, select, textarea", )) { const key = this.fieldKey(el); - if (key && wanted.has(key) && this.isFilled(el)) { + if (!key || !wanted.has(key)) { + continue; + } + if (!starForKey.has(key)) { + starForKey.set( + key, + el.closest(".form-row")?.querySelector(".completion-star") ?? + null, + ); + } + if (this.isFilled(el)) { filled.add(key); } } - const total = wanted.size; - const ratio = total ? filled.size / total : 0; + this.total = wanted.size; + + // First pass (page load): reflect the current state with no animation. + if (!this.filledKeys) { + this.shownCount = filled.size; + this.render(); + for (const [key, star] of starForKey) { + star?.classList.toggle("completion-star--gone", filled.has(key)); + } + this.filledKeys = filled; + + return; + } + + for (const [key, star] of starForKey) { + const isFilled = filled.has(key); + const wasFilled = this.filledKeys.has(key); + if (isFilled && !wasFilled) { + this.sendStarToTrophy(star); + } else if (!isFilled && wasFilled) { + this.sendStarToLabel(star); + } + } + + this.filledKeys = filled; + } + + // Field filled: the label star flies up; the bar/trophy advance only on arrival. + sendStarToTrophy(star) { + if (!this.hasStarTarget || !star) { + return; + } + + const fromRect = this.realRect(star); + star.classList.add("completion-star--gone"); + + this.flyBetween( + fromRect, + this.starTarget.getBoundingClientRect(), + () => { + this.shownCount += 1; + this.render(); + this.popTrophy(); + }, + 38, + ); + } + + // Field cleared: deduct from the trophy now, then a star flies back to the label. + sendStarToLabel(star) { + if (!this.hasStarTarget || !star) { + return; + } + + this.shownCount -= 1; + this.render(); + + const fromRect = this.starTarget.getBoundingClientRect(); + this.flyBetween(fromRect, this.realRect(star), () => { + star.classList.remove("completion-star--gone"); + }); + } + + // The label star's real on-screen box, even while collapsed (--gone), so a + // returning star lands exactly where the star will sit, not at the text end. + realRect(star) { + const gone = star.classList.contains("completion-star--gone"); + if (gone) { + star.classList.remove("completion-star--gone"); + } + const rect = star.getBoundingClientRect(); + if (gone) { + star.classList.add("completion-star--gone"); + } + + return rect; + } + + render() { + const total = this.total || 0; + const count = Math.max(0, Math.min(this.shownCount, total)); + const ratio = total ? count / total : 0; const percent = Math.round(ratio * 100); this.fillTarget.style.width = `${percent}%`; @@ -49,6 +149,119 @@ export default class extends Controller { if (this.hasLabelTarget) { this.labelTarget.textContent = `${percent}%`; } + + this.updateTrophy(ratio); + } + + updateTrophy(ratio) { + if (!this.hasStarTarget) { + return; + } + + // Only the size tracks progress; colour stays gold like the label stars. + this.starTarget.style.fontSize = `${(1 + ratio * 0.85).toFixed(2)}em`; + } + + // `lift` (px) gives the forward flight a slow celebratory rise before it + // launches fast into the trophy; 0 keeps the plain arc used on the way back. + flyBetween(fromRect, toRect, onArrive, lift = 0) { + const startX = fromRect.left + fromRect.width / 2; + const startY = fromRect.top + fromRect.height / 2; + const dx = toRect.left + toRect.width / 2 - startX; + const dy = toRect.top + toRect.height / 2 - startY; + + if (this.prefersReducedMotion) { + onArrive(); + + return; + } + + const star = document.createElement("span"); + star.className = "flying-star"; + star.textContent = "★"; + star.setAttribute("aria-hidden", "true"); + star.style.left = `${startX}px`; + star.style.top = `${startY}px`; + document.body.appendChild(star); + + const arrive = { + transform: `translate(calc(-50% + ${dx}px), calc(-50% + ${dy}px)) scale(0.85) rotate(360deg)`, + opacity: 0, + }; + + let keyframes; + let duration; + if (lift > 0) { + keyframes = [ + { + transform: "translate(-50%, -50%) scale(1.3) rotate(0deg)", + opacity: 1, + easing: "cubic-bezier(0.16, 1, 0.3, 1)", + }, + { + transform: `translate(-50%, calc(-50% - ${lift}px)) scale(1.45) rotate(35deg)`, + opacity: 1, + offset: 0.5, + easing: "cubic-bezier(0.7, 0, 0.84, 0)", + }, + { + transform: `translate(calc(-50% + ${dx * 0.94}px), calc(-50% + ${dy * 0.94}px)) scale(1) rotate(345deg)`, + opacity: 1, + offset: 0.94, + }, + arrive, + ]; + duration = 1000; + } else { + const apex = dy < 0 ? -14 : -8; + keyframes = [ + { + transform: "translate(-50%, -50%) scale(1.3) rotate(0deg)", + opacity: 1, + }, + { + transform: `translate(calc(-50% + ${dx * 0.5}px), calc(-50% + ${dy * 0.5 + apex}px)) scale(1.15) rotate(180deg)`, + opacity: 1, + offset: 0.5, + }, + { + transform: `translate(calc(-50% + ${dx * 0.92}px), calc(-50% + ${dy * 0.92}px)) scale(1) rotate(330deg)`, + opacity: 1, + offset: 0.9, + }, + arrive, + ]; + duration = 760; + } + + const flight = star.animate(keyframes, { + duration, + easing: "cubic-bezier(0.3, 0, 0.5, 1)", + }); + + flight.onfinish = () => { + star.remove(); + onArrive(); + }; + } + + popTrophy() { + if (!this.hasStarTarget || this.prefersReducedMotion) { + return; + } + + this.starTarget.animate( + [ + { transform: "scale(1)" }, + { transform: "scale(1.55)" }, + { transform: "scale(1)" }, + ], + { duration: 360, easing: "ease-out" }, + ); + } + + get prefersReducedMotion() { + return window.matchMedia("(prefers-reduced-motion: reduce)").matches; } // "initiative[links][0]" -> "links", "initiative[funding][]" -> "funding". diff --git a/assets/styles/app.css b/assets/styles/app.css index 872a657..680fdfd 100644 --- a/assets/styles/app.css +++ b/assets/styles/app.css @@ -1487,6 +1487,31 @@ textarea { font-variant-numeric: tabular-nums; } +.form-progress__star { + flex: none; + display: inline-block; + position: relative; + top: -0.1em; + margin-left: calc(-1 * var(--itk-space-2)); + font-size: var(--itk-text-md); + line-height: 1; + color: gold; + -webkit-text-stroke: 0.75px #000; + paint-order: stroke fill; + transition: font-size 240ms ease; +} + +.flying-star { + position: fixed; + z-index: 1000; + pointer-events: none; + color: gold; + font-size: var(--itk-text-lg); + line-height: 1; + -webkit-text-stroke: 0.75px #000; + paint-order: stroke fill; +} + .completion { display: flex; align-items: center; diff --git a/templates/initiative/_form.html.twig b/templates/initiative/_form.html.twig index 3d2986c..a909e3e 100644 --- a/templates/initiative/_form.html.twig +++ b/templates/initiative/_form.html.twig @@ -33,6 +33,7 @@
+ 0%
From e6398fd0fecf89e3bb156969a5401a8899af1780 Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Tue, 23 Jun 2026 09:29:54 +0200 Subject: [PATCH 3/5] Updated CHANGELOG --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b518e6d..80186fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +* [PR-6](https://github.com/itk-dev/itk-project-database/pull/6) + Mark udfyldningsgrad fields with a star that flies into a progress trophy * [PR-5](https://github.com/itk-dev/itk-project-database/pull/5) Improve initiative list, search and filters using partial page rendering * [PR-4](https://github.com/itk-dev/itk-project-database/pull/4) From 5ed3214c1754573eb4f06252610886700083fb87 Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Tue, 23 Jun 2026 09:43:10 +0200 Subject: [PATCH 4/5] Coding standards --- assets/controllers/form_progress_controller.js | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/assets/controllers/form_progress_controller.js b/assets/controllers/form_progress_controller.js index 792f9bd..165b2df 100644 --- a/assets/controllers/form_progress_controller.js +++ b/assets/controllers/form_progress_controller.js @@ -46,8 +46,9 @@ export default class extends Controller { if (!starForKey.has(key)) { starForKey.set( key, - el.closest(".form-row")?.querySelector(".completion-star") ?? - null, + el + .closest(".form-row") + ?.querySelector(".completion-star") ?? null, ); } if (this.isFilled(el)) { @@ -62,7 +63,10 @@ export default class extends Controller { this.shownCount = filled.size; this.render(); for (const [key, star] of starForKey) { - star?.classList.toggle("completion-star--gone", filled.has(key)); + star?.classList.toggle( + "completion-star--gone", + filled.has(key), + ); } this.filledKeys = filled; From cbd825b2014147e0c8872e371bbb9a7ed2209ed4 Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Tue, 23 Jun 2026 13:36:33 +0200 Subject: [PATCH 5/5] Removed reference to author --- templates/initiative/_results.html.twig | 1 - 1 file changed, 1 deletion(-) diff --git a/templates/initiative/_results.html.twig b/templates/initiative/_results.html.twig index d7714f7..cc2866f 100644 --- a/templates/initiative/_results.html.twig +++ b/templates/initiative/_results.html.twig @@ -57,7 +57,6 @@ {{ initiative.timePeriodStart|date('d.m.Y') }}{% if initiative.timePeriodEnd %} – {{ initiative.timePeriodEnd|date('d.m.Y') }}{% endif %} {% else %}—{% endif %} - {{ initiative.author ?: '—' }}