palette tooltip: contextual description per lock state; earned date below Unlocked in green — TDD
Some checks failed
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline failed

- _palettes_for_user: shoptalk→description; "available by default" / "explore to unlock" / "recognized via {Title}"; unlocked_date key carries earned_at ISO for Note-unlocked
- template: data-shoptalk→data-description; data-unlocked-date now holds ISO datetime (was literal "Default")
- dashboard.js: lockText drops "— Default"; dateLine renders "Apr 22, 2026 · 3:05 AM" after Unlocked line
- _palette-picker.scss: tt-shoptalk→tt-description in display:block list; tt-date added
- _tooltips.scss: .tt-date mirrors .tt-expiry w. --priGn colour

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-04-26 18:59:10 -04:00
parent c78ecb61bf
commit 5aaff6240b
6 changed files with 67 additions and 16 deletions

View File

@@ -35,15 +35,24 @@ const bindPaletteSwatches = () => {
if (!portal) return; if (!portal) return;
const label = swatch.dataset.label || ''; const label = swatch.dataset.label || '';
const locked = swatch.dataset.locked === 'true'; const locked = swatch.dataset.locked === 'true';
const date = swatch.dataset.unlockedDate || ''; const description = swatch.dataset.description || '';
const shoptalk = swatch.dataset.shoptalk || ''; const unlockedDate = swatch.dataset.unlockedDate || '';
const lockIcon = locked ? 'fa-lock' : 'fa-lock-open'; const lockIcon = locked ? 'fa-lock' : 'fa-lock-open';
const lockText = locked ? 'Locked' : `Unlocked${date}`.trim(); const lockText = locked ? 'Locked' : 'Unlocked';
let dateLine = '';
if (unlockedDate) {
const dt = new Date(unlockedDate);
const dateStr = dt.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
const timeStr = dt.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
dateLine = `<p class="tt-date">${dateStr} · ${timeStr}</p>`;
}
portal.innerHTML = ` portal.innerHTML = `
<h4 class="tt-title">${label}</h4> <h4 class="tt-title">${label}</h4>
${shoptalk ? `<p class="tt-shoptalk"><em>${shoptalk}</em></p>` : ''} ${description ? `<p class="tt-description"><em>${description}</em></p>` : ''}
<p class="tt-lock"><i class="fa-solid ${lockIcon}"></i> ${lockText}</p>`; <p class="tt-lock"><i class="fa-solid ${lockIcon}"></i> ${lockText}</p>
${dateLine}`;
const rect = swatch.getBoundingClientRect(); const rect = swatch.getBoundingClientRect();
portal.style.display = 'block'; portal.style.display = 'block';

View File

@@ -358,7 +358,7 @@ class NotePaletteContextTest(TestCase):
bardo = next(p for p in palettes if p["name"] == "palette-bardo") bardo = next(p for p in palettes if p["name"] == "palette-bardo")
self.assertFalse(bardo["locked"]) self.assertFalse(bardo["locked"])
def test_note_palette_shoptalk_contains_note_title(self): def test_note_palette_description_contains_note_title(self):
Note.objects.create( Note.objects.create(
user=self.user, slug="stargazer", earned_at=timezone.now(), user=self.user, slug="stargazer", earned_at=timezone.now(),
palette="palette-bardo", palette="palette-bardo",
@@ -366,7 +366,41 @@ class NotePaletteContextTest(TestCase):
response = self.client.get("/") response = self.client.get("/")
palettes = response.context["palettes"] palettes = response.context["palettes"]
bardo = next(p for p in palettes if p["name"] == "palette-bardo") bardo = next(p for p in palettes if p["name"] == "palette-bardo")
self.assertIn("Stargazer", bardo["shoptalk"]) self.assertIn("Stargazer", bardo["description"])
def test_default_palette_description_is_available_by_default(self):
response = self.client.get("/")
palettes = response.context["palettes"]
default = next(p for p in palettes if p["name"] == "palette-default")
self.assertEqual(default["description"], "available by default")
def test_locked_palette_description_is_explore_to_unlock(self):
response = self.client.get("/")
palettes = response.context["palettes"]
bardo = next(p for p in palettes if p["name"] == "palette-bardo")
self.assertEqual(bardo["description"], "explore to unlock")
def test_note_palette_description_is_recognized_via(self):
Note.objects.create(
user=self.user, slug="stargazer", earned_at=timezone.now(),
palette="palette-bardo",
)
response = self.client.get("/")
palettes = response.context["palettes"]
bardo = next(p for p in palettes if p["name"] == "palette-bardo")
self.assertEqual(bardo["description"], "recognized via Stargazer")
def test_note_palette_entry_includes_unlocked_date_iso(self):
earned = timezone.now()
Note.objects.create(
user=self.user, slug="stargazer", earned_at=earned,
palette="palette-bardo",
)
response = self.client.get("/")
palettes = response.context["palettes"]
bardo = next(p for p in palettes if p["name"] == "palette-bardo")
self.assertIn("unlocked_date", bardo)
self.assertEqual(bardo["unlocked_date"], earned.isoformat())
def test_note_without_palette_field_keeps_swatch_locked(self): def test_note_without_palette_field_keeps_swatch_locked(self):
Note.objects.create( Note.objects.create(

View File

@@ -51,7 +51,10 @@ PALETTES = _PALETTE_DEFS
def _palettes_for_user(user): def _palettes_for_user(user):
if not (user and user.is_authenticated): if not (user and user.is_authenticated):
return [dict(p, shoptalk="Placeholder") for p in _PALETTE_DEFS] return [
dict(p, description="available by default" if not p["locked"] else "explore to unlock")
for p in _PALETTE_DEFS
]
granted = { granted = {
r.palette: r r.palette: r
for r in Note.objects.filter(user=user, palette__isnull=False).exclude(palette="") for r in Note.objects.filter(user=user, palette__isnull=False).exclude(palette="")
@@ -63,9 +66,12 @@ def _palettes_for_user(user):
if r and p["locked"]: if r and p["locked"]:
entry["locked"] = False entry["locked"] = False
title = _NOTE_TITLES.get(r.slug, r.slug.capitalize()) title = _NOTE_TITLES.get(r.slug, r.slug.capitalize())
entry["shoptalk"] = f"{title} · {r.earned_at.strftime('%b %d, %Y').replace(' 0', ' ')}" entry["description"] = f"recognized via {title}"
entry["unlocked_date"] = r.earned_at.isoformat()
elif not p["locked"]:
entry["description"] = "available by default"
else: else:
entry["shoptalk"] = "Placeholder" entry["description"] = "explore to unlock"
result.append(entry) result.append(entry)
return result return result

View File

@@ -80,7 +80,8 @@
#id_tooltip_portal { #id_tooltip_portal {
// Override .tt { display: none } — portal content is shown/hidden by JS // Override .tt { display: none } — portal content is shown/hidden by JS
.tt-title, .tt-title,
.tt-shoptalk, .tt-description,
.tt-date,
.tt-lock { .tt-lock {
display: block; display: block;
} }

View File

@@ -10,6 +10,7 @@
.tt-description { padding: 0.125rem; font-size: 0.75rem; } .tt-description { padding: 0.125rem; font-size: 0.75rem; }
.tt-shoptalk { font-size: 0.75rem; opacity: 0.75; } .tt-shoptalk { font-size: 0.75rem; opacity: 0.75; }
.tt-expiry { font-size: 1rem; color: rgba(var(--priRd), 1); } .tt-expiry { font-size: 1rem; color: rgba(var(--priRd), 1); }
.tt-date { font-size: 1rem; color: rgba(var(--priGn), 1); }
} }
.token-tooltip, .token-tooltip,

View File

@@ -12,8 +12,8 @@
data-palette="{{ palette.name }}" data-palette="{{ palette.name }}"
data-label="{{ palette.label }}" data-label="{{ palette.label }}"
data-locked="{{ palette.locked|yesno:'true,false' }}" data-locked="{{ palette.locked|yesno:'true,false' }}"
data-unlocked-date="{% if not palette.locked %}Default{% endif %}" data-unlocked-date="{{ palette.unlocked_date|default:'' }}"
data-shoptalk="{{ palette.shoptalk }}" data-description="{{ palette.description }}"
> >
{% if not palette.locked %} {% if not palette.locked %}
<button type="button" class="btn btn-confirm palette-ok" hidden>OK</button> <button type="button" class="btn btn-confirm palette-ok" hidden>OK</button>