Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
f2cb17e
Dynamic badges/achievements system with tiered milestones (#127)
JanMikes Apr 16, 2026
b1f4a85
Fix null player_id from team JSON in backfill query
JanMikes Apr 16, 2026
f77ce04
Add integration tests for badge query layer
JanMikes Apr 16, 2026
b948542
Separate badge persistence from email notification
JanMikes Apr 16, 2026
c479eca
Show only the highest tier per badge type on profile and email
JanMikes Apr 17, 2026
66e6008
Brainstorming
JanMikes Jul 12, 2026
8beed03
Tests: Fix PHPStan and coding standard violations
JanMikes Jul 13, 2026
e5334b5
XP/Levels: Executable implementation plan for the coding agent
JanMikes Jul 13, 2026
aee8f0a
XP/Levels: Plan continues on badges branch, flag retrofit for shipped…
JanMikes Jul 13, 2026
cac2637
XP/Levels: Plan review fixes — ledger decomposition, earned_at semant…
JanMikes Jul 13, 2026
fdee91e
XP/Levels: Rename opt-out to experienceSystemOptedOut, add design qua…
JanMikes Jul 13, 2026
eeb6267
XP/Levels P0: XpFeatureGate admin-only flag, retrofit onto shipped ba…
JanMikes Jul 13, 2026
7e3bba1
XP/Levels P1: XP domain core — ledger entity, level curve, pure calcu…
JanMikes Jul 13, 2026
0f80147
XP/Levels P2: live wiring — award on add, chain rebuild on edit, comp…
JanMikes Jul 13, 2026
166ea15
XP/Levels P3: 11 new achievements — metrics, conditions, tests, EN co…
JanMikes Jul 13, 2026
26bbaa2
XP/Levels P4: solve-loop UI — receipt, celebrations, rings, badge rev…
JanMikes Jul 13, 2026
4b9fd39
XP/Levels: EN copy drafts for explainer + fair-play pages (pending Ja…
JanMikes Jul 13, 2026
4fe34b1
XP/Levels P5: pages — achievements catalog+holders, XP leaderboard, a…
JanMikes Jul 13, 2026
349c0a4
XP/Levels P6: weekly content digest — pipeline, blocks, preferences, …
JanMikes Jul 13, 2026
68b919b
XP/Levels P7: launch tooling — backfill orchestration, distribution v…
JanMikes Jul 13, 2026
d28ea6a
XP/Levels P8: hardening, i18n, docs — leak test, anti-abuse tests, 5-…
JanMikes Jul 13, 2026
75e43ca
XP/Levels: denormalize achievement points + leaderboard indexes
JanMikes Jul 13, 2026
70655ae
XP/Levels: correct pending-migration count in launch checklist (7)
JanMikes Jul 16, 2026
ddff254
Badges: ComfyUI generation pipeline research — model stacks, MPS rule…
JanMikes Jul 16, 2026
26d49a0
Badges: ComfyUI environment ready — models downloaded, nodes verified
JanMikes Jul 17, 2026
2907c74
Badges: validated ComfyUI generation pipeline — Klein 9B + determinis…
JanMikes Jul 17, 2026
0c10424
Rebase on main: resolve semantic conflicts with API V1 expansion
JanMikes Aug 19, 2026
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
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ Feature design documents and implementation plans are in `docs/features/`. Each
- **Stripe Payments**: `docs/features/stripe.md` — Stripe integration for premium membership
- **Opt-Out Features**: `docs/features/opt-out.md` — Streak and ranking opt-out for players
- **Competitions Management**: `docs/features/competitions-management/` — Community-driven event creation with admin approval, round management, puzzle assignment, table layout planning, and live stopwatch. Linking solving times to events (the add/edit-time "Competition / event" picker: selectable set = `IsCompetitionPubliclyVisible::SQL_CONDITION` incl. series editions, include-current rule on the edit form, server-side validation, `GetSelectableCompetitions` + `CompetitionChoicesBuilder`) — see README §"Linking solving times to events"
- **XP / Levels / Achievements**: `docs/features/xp-levels/` — XP ledger + level curve (1–50), 16 tiered achievements with Achievement Points, weekly content digest, launch runbook. Feature-flagged (`xp-system`, admin-only) until launch; living doc in `docs/features/xp-levels/README.md`
- **Referral Program**: `docs/features/referral-program.md` — Members earn 10% of referred subscription revenue. No separate entity — `player.referralProgramJoinedAt` + `player.referralProgramSuspended`. Code = player code. Cookie-based + code-input attribution. Payouts per currency, manual admin payout marking
- **Activity Analytics**: `docs/features/activity-analytics.md` — daily player presence (`player_activity_day`, UTC days, 24-month prune) + immortal per-locale aggregates (`activity_daily_summary`); written by `PlayerActivitySubscriber` on kernel.terminate with Redis dedup; crons `myspeedpuzzling:snapshot-activity-summary` + `myspeedpuzzling:prune-player-activity` (daily)
- **Auth Hardening**: `docs/features/auth-hardening/README.md` — DB auth audit trail (`auth_audit_log` table, `RecordAuthAuditEvent` via `AuthAuditRecorder` — never breaks login), user-facing `/account/recent-activity` page, 24-month prune cron `myspeedpuzzling:prune-auth-audit-log`, GDPR deletion now removes `UserAccount`. Social login (PR 2): Google/Apple/Facebook via league provider libraries, `oauth_identity` table (D13), per-provider authenticators on `main`, cache-backed OAuth state (`social_login_state_cache` pool — Apple's form_post callback has no session), 5 settled linking rules in `SocialAccountResolver`, rule-4 interstitial `/register/social`, "Connected sign-in methods" on edit-profile, ≥1-sign-in-method invariant in `UnlinkOauthIdentityHandler`; flags `SOCIAL_LOGIN_{GOOGLE,FACEBOOK,APPLE}_ENABLED` (OFF) + `SOCIAL_LOGIN_ADMIN_ONLY` (ON) — see `docs/features/feature_flags.md`
Expand Down
41 changes: 41 additions & 0 deletions assets/controllers/badge_reveal_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { Controller } from '@hotwired/stimulus';

/**
* First-click badge reveal: flips the medallion with a small confetti burst and
* persists the reveal. Fire-and-forget POST — the flip is optimistic.
*/
export default class extends Controller {
static values = {
url: String,
};

reveal() {
if (this.element.classList.contains('is-revealed')) {
return;
}

this.element.classList.add('is-revealed');

fetch(this.urlValue, { method: 'POST' });

this.burstConfetti();
}

burstConfetti() {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
return;
}

const confetti = document.createElement('span');
confetti.className = 'xp-confetti xp-confetti-burst';
confetti.setAttribute('aria-hidden', 'true');

for (let i = 0; i < 10; i++) {
confetti.appendChild(document.createElement('i'));
}

this.element.appendChild(confetti);

setTimeout(() => confetti.remove(), 3000);
}
}
34 changes: 34 additions & 0 deletions assets/controllers/xp_count_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { Controller } from '@hotwired/stimulus';

/**
* One-shot XP counter spin for the launch reveal page: counts from 0 to the
* server-rendered value. Reduced motion → the real number shows instantly
* (it is already in the markup for no-JS visitors anyway).
*/
export default class extends Controller {
static values = {
amount: Number,
duration: { type: Number, default: 1800 },
};

connect() {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches || this.amountValue <= 0) {
return;
}

const start = performance.now();
const formatter = new Intl.NumberFormat('en-US');

const tick = (now) => {
const progress = Math.min((now - start) / this.durationValue, 1);
const eased = 1 - Math.pow(1 - progress, 3);
this.element.textContent = formatter.format(Math.round(eased * this.amountValue));

if (progress < 1) {
requestAnimationFrame(tick);
}
};

requestAnimationFrame(tick);
}
}
24 changes: 24 additions & 0 deletions assets/controllers/xp_reveal_finish_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { Controller } from '@hotwired/stimulus';

/**
* Launch-reveal "continue" button: persists the one-time-seen marker via the
* dismiss-hint endpoint, then moves on to the profile.
*/
export default class extends Controller {
static values = {
url: String,
redirect: String,
};

async finish() {
try {
await fetch(this.urlValue, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'type=xp_launch_reveal',
});
} finally {
window.location.assign(this.redirectValue);
}
}
}
1 change: 1 addition & 0 deletions assets/styles/_components.scss
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
@import 'components/breadcrumb';
@import 'components/pagination';
@import 'components/badge';
@import 'components/xp';
@import 'components/alert';
@import 'components/list-group';
@import 'components/close';
Expand Down
108 changes: 108 additions & 0 deletions assets/styles/components/_badge.scss
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,111 @@
box-shadow: 0 .5rem 1.125rem -.275rem rgba($black, .25);
}
}


// Achievement badge medallions
// --------------------------------------------------

$badge-tier-bronze: #cd7f32;
$badge-tier-silver: #c0c0c0;
$badge-tier-gold: #ffd700;
$badge-tier-platinum: #e5e4e2;
$badge-tier-diamond: #b9f2ff;
$badge-tier-supporter: $primary;

.badge-medallion {
width: 72px;
height: 72px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
color: rgba(40, 40, 40, .95);
font-weight: 700;
font-size: 1.25rem;
box-shadow: 0 4px 14px -2px rgba(0, 0, 0, .25), inset 0 -4px 6px rgba(0, 0, 0, .12);
border: 3px solid rgba(255, 255, 255, .7);
transition: transform .2s ease, box-shadow .2s ease;

&:hover {
transform: translateY(-1px);
box-shadow: 0 8px 20px -2px rgba(0, 0, 0, .3), inset 0 -4px 6px rgba(0, 0, 0, .12);
}
}

.badge-medallion-sm {
width: 48px;
height: 48px;
font-size: .95rem;
}

.badge-medallion-tier {
font-family: Georgia, serif;
letter-spacing: .02em;
text-shadow: 0 1px 0 rgba(255, 255, 255, .45);
}

.badge-tier-bronze {
background: radial-gradient(circle at 35% 30%, lighten($badge-tier-bronze, 18%), $badge-tier-bronze 70%);
color: #3a1d00;
}

.badge-tier-silver {
background: radial-gradient(circle at 35% 30%, lighten($badge-tier-silver, 12%), $badge-tier-silver 70%);
color: #2a2a2a;
}

.badge-tier-gold {
background: radial-gradient(circle at 35% 30%, lighten($badge-tier-gold, 10%), $badge-tier-gold 70%);
color: #3f2d00;
}

.badge-tier-platinum {
background: radial-gradient(circle at 35% 30%, lighten($badge-tier-platinum, 4%), $badge-tier-platinum 70%);
color: #2a2a2a;
}

.badge-tier-diamond {
background: radial-gradient(circle at 35% 30%, lighten($badge-tier-diamond, 4%), $badge-tier-diamond 70%);
color: #004854;
}

.badge-tier-supporter {
background: radial-gradient(circle at 35% 30%, lighten($badge-tier-supporter, 15%), $badge-tier-supporter 70%);
color: #fff;
}

.badge-locked {
.badge-medallion {
filter: grayscale(100%);
opacity: .45;
box-shadow: none;

&:hover {
transform: none;
}
}
}

.badge-new-pill {
position: absolute;
top: -6px;
right: -8px;
font-size: .65rem;
padding: .2em .45em;
box-shadow: 0 2px 6px rgba(0, 0, 0, .25);
animation: badge-new-pulse 1.6s ease-in-out infinite;
}

@keyframes badge-new-pulse {
0%, 100% {
transform: scale(1);
}
50% {
transform: scale(1.08);
}
}

.badge-earned {
min-width: 90px;
}
Loading