Merge pull request #54 from mrteye/jules-3797922055245838917-9136c1df

feat: UI and Event Polish Phase

Completed Phase 6 Final Polish according to the task specification `2026-0826.06.gem.feat.ui.sessions-and-events-final-polish-2305.ph6.md`. Fixes backend rotation vulnerabilities and extension math bugs while implementing high-polish UI elements including bounded 2-row cards, WAI-ARIA compliant delegation tabs, and natural telemetry formatters. Checked against all standard quality gates.
This commit is contained in:
Tyler Gillispie 2026-08-26 23:39:02 -07:00 committed by GitHub
commit 3dcd823e23
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 704 additions and 194 deletions

View File

@ -31,36 +31,50 @@ eventRoutes.post("/api/events/:id/rotate-pin", async (c) => {
const eventId = c.req.param("id");
// Generate new PIN
const randPin = Math.floor(100000 + Math.random() * 900000).toString();
const newPinCode = randPin.substring(0, 3) + "-" + randPin.substring(3);
try {
const eventResult = await sqlWrapper.sql`
UPDATE event_passes
SET pin_code = ${newPinCode}
const event = await sqlWrapper.sql`
SELECT slug, name FROM event_passes
WHERE id = ${eventId}
AND (created_by = ${user.userId} OR ${await isGlobalAdmin(user.userId)})
AND is_active = TRUE
RETURNING pin_code
`;
if (!eventResult || eventResult.length === 0) {
if (!event || event.length === 0) {
return c.json(
{ error: "Event not found, inactive, or unauthorized" },
404,
);
}
// Generate new PIN
const randPin = Math.floor(100000 + Math.random() * 900000).toString();
const newPinCode = randPin.substring(0, 3) + "-" + randPin.substring(3);
// Generate new Slug
const baseSlug = event[0].slug.replace(/-[a-z0-9]{4}$/, "");
const newSuffix = Math.random().toString(36).substring(2, 6);
const newSlug = `${baseSlug}-${newSuffix}`;
const updateResult = await sqlWrapper.sql`
UPDATE event_passes
SET pin_code = ${newPinCode}, slug = ${newSlug}
WHERE id = ${eventId}
RETURNING pin_code, slug
`;
auditWrapper.auditLog(
user.userId,
"event_pin_rotated",
"event_ingress_rotated",
eventId,
{},
getClientIp(c),
);
return c.json({ success: true, pinCode: eventResult[0].pin_code });
return c.json({
success: true,
pinCode: updateResult[0].pin_code,
slug: updateResult[0].slug,
});
} catch (e: any) {
console.error("[Events] Failed to rotate event PIN:", e);
return c.json({ error: "Failed to rotate PIN" }, 500);
@ -206,7 +220,7 @@ eventRoutes.post("/api/events/:id/extend", async (c) => {
try {
const eventResult = await sqlWrapper.sql`
UPDATE event_passes
SET expires_at = expires_at + interval '${extendHours} hours'
SET expires_at = GREATEST(expires_at, NOW()) + interval '${extendHours} hours'
WHERE id = ${eventId} AND (created_by = ${user.userId} OR ${await isGlobalAdmin(
user.userId,
)}) AND is_active = TRUE

View File

@ -232,6 +232,59 @@ Deno.test("Multi-Claim Event Passes & Join Endpoints", async (t) => {
},
);
await t.step(
"POST /api/events/:id/rotate-pin rotates both pin and slug",
async () => {
const valkeyGetStub = stub(valkey, "get", (key: any) => {
if (String(key) === "admin-session") {
return Promise.resolve(
JSON.stringify({ uuid: "admin-uuid", username: "tylerg" }),
);
}
return Promise.resolve(null);
});
const originalSql = sqlWrapper.sql;
let updateEventCalled = false;
let newSlug = "";
sqlWrapper.sql = ((strings: any, ..._values: any[]) => {
const query = Array.isArray(strings)
? strings.join("?")
: String(strings);
if (query.includes("SELECT slug, name FROM event_passes")) {
return Promise.resolve([{ slug: "deno-lab-1a2b", name: "Deno Lab" }]);
}
if (query.includes("UPDATE event_passes") && query.includes("slug =")) {
updateEventCalled = true;
newSlug = _values[1]; // second bound parameter is the slug
return Promise.resolve([{ pin_code: _values[0], slug: newSlug }]);
}
return Promise.resolve([]);
}) as any;
try {
const res = await app.request("/api/events/evt-123/rotate-pin", {
method: "POST",
headers: {
Authorization: "Bearer admin-session",
},
});
assertEquals(res.status, 200);
const json = await res.json();
assert(json.success === true);
assert(updateEventCalled);
assert(json.slug.startsWith("deno-lab-"));
assert(json.slug !== "deno-lab-1a2b");
assertEquals(json.slug, newSlug);
} finally {
sqlWrapper.sql = originalSql;
valkeyGetStub.restore();
}
},
);
await t.step(
"POST /api/join enforces 5 failed attempts rate limit per IP",
async () => {
@ -491,7 +544,7 @@ Deno.test("Multi-Claim Event Passes & Join Endpoints", async (t) => {
: String(strings);
if (
query.includes("UPDATE event_passes") &&
query.includes("expires_at = expires_at + interval")
query.includes("expires_at = GREATEST(expires_at, NOW()) + interval")
) {
updateEventCalled = true;
return Promise.resolve([{

View File

@ -12,28 +12,57 @@
- `server/tests/events.test.ts`
- `ui/ui_scripts.test.ts`
- **Core Objective:** Implement Phase 6 Final Polish & Hardening:
1. Unify nomenclature & IA across pages and drawers (`Sessions & Events`, `Delegate Session`, `Events`, `Sessions`).
2. Implement universal ingress credential rotation (rotates both 6-digit PIN and event slug suffix simultaneously).
3. Fix expired event extend math bug using `GREATEST(expires_at, NOW()) + interval`.
4. Replace broken compact mode with strictly bounded, 2-Row Compact Cards with inline copy pills and zero text/border collisions.
5. Implement standardized natural `ExpiryBadge` format (`[Date] · [Time] · [Urgency Badge]`) and symmetrical inverted start-time telemetry in the Guest Drawer.
6. Upgrade segmented view mode toggles and WAI-ARIA delegation tabs with high-contrast active states.
7. Reset drawer state machine on open and replace verbose/mock buttons with clean `[ OK ]`.
1. Unify nomenclature & IA across pages and drawers (`Sessions & Events`,
`Delegate Session`, `Events`, `Sessions`).
2. Implement universal ingress credential rotation (rotates both 6-digit PIN
and event slug suffix simultaneously).
3. Fix expired event extend math bug using
`GREATEST(expires_at, NOW()) + interval`.
4. Replace broken compact mode with strictly bounded, 2-Row Compact Cards with
inline copy pills and zero text/border collisions.
5. Implement standardized natural `ExpiryBadge` format
(`[Date] · [Time] · [Urgency Badge]`) and symmetrical inverted start-time
telemetry in the Guest Drawer.
6. Upgrade segmented view mode toggles and WAI-ARIA delegation tabs with
high-contrast active states.
7. Reset drawer state machine on open and replace verbose/mock buttons with
clean `[ OK ]`.
- **Dependencies:** None.
- **Additional Important Notes:** Must remain 100% pure React-free Hono SSR JSX. All client interactions in `SessionsScript.tsx` must use native vanilla JavaScript DOM APIs.
- **Additional Important Notes:** Must remain 100% pure React-free Hono SSR JSX.
All client interactions in `SessionsScript.tsx` must use native vanilla
JavaScript DOM APIs.
---
## 2. Architectural Considerations & Risks
- **Risks:**
- **Ingress Rotation Leakage & Active Session Disruption:** When rotating event ingress credentials (`pin_code` and `slug`), existing active guest sessions must remain valid and uninterrupted. Only subsequent unauthenticated join attempts using the old PIN, old Direct Link, or old CLI command must be rejected (404 / Invalid Code).
- **Expired Event Extending Edge Cases:** When an organizer extends an event that expired in the past, `expires_at + interval '1 hour'` would leave the expiry in the past. Using `GREATEST(expires_at, NOW()) + interval '${extendHours} hours'` guarantees the new expiration time is set relative to the current moment.
- **DOM Boundary & Overflow Bleed:** The previous compact mode forced elements into a single unconstrained horizontal flex line, causing text collisions and input boxes bleeding off the desktop screen. The new 2-Row Compact Card must enforce strict CSS bounding (`box-sizing: border-box; max-width: 100%; overflow: hidden; text-overflow: ellipsis;`).
- **State Machine Staleness:** Reopening the delegation drawer must unconditionally reset the form back to State 1 (clean creation form with `Single Session` default tab) rather than leaving the stale success screen visible.
- **Ingress Rotation Leakage & Active Session Disruption:** When rotating
event ingress credentials (`pin_code` and `slug`), existing active guest
sessions must remain valid and uninterrupted. Only subsequent
unauthenticated join attempts using the old PIN, old Direct Link, or old CLI
command must be rejected (404 / Invalid Code).
- **Expired Event Extending Edge Cases:** When an organizer extends an event
that expired in the past, `expires_at + interval '1 hour'` would leave the
expiry in the past. Using
`GREATEST(expires_at, NOW()) + interval '${extendHours} hours'` guarantees
the new expiration time is set relative to the current moment.
- **DOM Boundary & Overflow Bleed:** The previous compact mode forced elements
into a single unconstrained horizontal flex line, causing text collisions
and input boxes bleeding off the desktop screen. The new 2-Row Compact Card
must enforce strict CSS bounding
(`box-sizing: border-box; max-width: 100%; overflow: hidden; text-overflow: ellipsis;`).
- **State Machine Staleness:** Reopening the delegation drawer must
unconditionally reset the form back to State 1 (clean creation form with
`Single Session` default tab) rather than leaving the stale success screen
visible.
- **Alternatives:**
- *Single-Line vs. 2-Row Compact Cards:* Single-line cards inevitably drop essential copy actions or clip text on viewports under 1200px. A structured 2-Row Compact Card (~70px height) preserves 100% of copy handoffs (PIN, Link, CLI) and key metrics (Seats, Status, Expiry) while preventing all layout collisions.
- _Single-Line vs. 2-Row Compact Cards:_ Single-line cards inevitably drop
essential copy actions or clip text on viewports under 1200px. A structured
2-Row Compact Card (~70px height) preserves 100% of copy handoffs (PIN,
Link, CLI) and key metrics (Seats, Status, Expiry) while preventing all
layout collisions.
---
@ -46,17 +75,20 @@
```typescript
const randPin = Math.floor(100000 + Math.random() * 900000).toString();
const newPinCode = randPin.substring(0, 3) + "-" + randPin.substring(3);
// Extract base slug prefix and generate fresh random 4-char suffix
const event = await sqlWrapper.sql`SELECT slug, name FROM event_passes WHERE id = ${eventId}...`;
const event = await sqlWrapper
.sql`SELECT slug, name FROM event_passes WHERE id = ${eventId}...`;
const baseSlug = event[0].slug.replace(/-[a-z0-9]{4}$/, "");
const newSuffix = Math.random().toString(36).substring(2, 6);
const newSlug = `${baseSlug}-${newSuffix}`;
```
- Update database: `UPDATE event_passes SET pin_code = ${newPinCode}, slug = ${newSlug} WHERE id = ${eventId}...`
- Update database:
`UPDATE event_passes SET pin_code = ${newPinCode}, slug = ${newSlug} WHERE id = ${eventId}...`
- Audit log `event_ingress_rotated`.
- Return `{ success: true, pinCode: newPinCode, slug: newSlug }`.
- Note: Existing active attendee sessions (`username LIKE 'guest_...'`) authenticate via session cookies/Valkey tokens and are unaffected.
- Note: Existing active attendee sessions (`username LIKE 'guest_...'`)
authenticate via session cookies/Valkey tokens and are unaffected.
2. **Resilient Event Extension Math (`POST /api/events/:id/extend`):**
- Fix SQL to calculate new expiry from `GREATEST(expires_at, NOW())`:
@ -68,18 +100,24 @@
```
3. **Guest Attendee Telemetry (`GET /api/events/:id/attendees`):**
- Ensure query returns `s.id, s.label, s.is_paused, s.created_at, s.expires_at, s.last_activity_at, s.last_activity_action, u.username, u.display_name`.
- Ensure query returns
`s.id, s.label, s.is_paused, s.created_at, s.expires_at, s.last_activity_at, s.last_activity_action, u.username, u.display_name`.
---
### Phase 2: Page Hierarchy, Nomenclature & Heading Cleanup (`SessionsPage.tsx`)
1. **Page Title:**
- Set top `<h1>` in `SessionsPage.tsx` to **`Sessions & Events`** with subtitle *"Manage logins, mint 1:1 delegated tokens, or launch multi-claim workshop events."*
- Set top `<h1>` in `SessionsPage.tsx` to **`Sessions & Events`** with
subtitle _"Manage logins, mint 1:1 delegated tokens, or launch multi-claim
workshop events."_
2. **Remove Duplicate Headings:**
- Remove redundant `<h2>Event Passes</h2>` from `SessionsPage.tsx`. The section header is exclusively rendered inside `EventCockpitDeck.tsx` as `<h2>Events</h2>` alongside the view toggle.
- Remove redundant `<h2>Event Passes</h2>` from `SessionsPage.tsx`. The
section header is exclusively rendered inside `EventCockpitDeck.tsx` as
`<h2>Events</h2>` alongside the view toggle.
3. **Sessions Section:**
- Retain `<h2>Sessions</h2>` heading with subtitle *"Direct device logins, passkey authentications, and delegated agent tokens."*
- Retain `<h2>Sessions</h2>` heading with subtitle _"Direct device logins,
passkey authentications, and delegated agent tokens."_
---
@ -88,33 +126,46 @@
1. **Section Header & High-Contrast View Toggle:**
- Header: `<h2>Events</h2>`.
- Toggle buttons (`[ 🗂️ Grid ]` and `[ 📋 Compact ]`):
- Active style: `background: var(--primary); color: #ffffff; font-weight: 700; box-shadow: 0 1px 3px rgba(0,0,0,0.3); border-radius: var(--radius-sm);`
- Inactive style: `background: transparent; color: var(--text-muted); opacity: 0.75;`
- Active style:
`background: var(--primary); color: #ffffff; font-weight: 700; box-shadow: 0 1px 3px rgba(0,0,0,0.3); border-radius: var(--radius-sm);`
- Inactive style:
`background: transparent; color: var(--text-muted); opacity: 0.75;`
- Include `aria-pressed="true/false"`.
2. **2-Row Compact Card Layout (`.compact-view .event-card`):**
- Container: Strictly bounded flex/grid (~70px height), `box-sizing: border-box; overflow: hidden; padding: 0.75rem 1rem;`.
- Container: Strictly bounded flex/grid (~70px height),
`box-sizing: border-box; overflow: hidden; padding: 0.75rem 1rem;`.
- **Row 1 (Metadata Header):**
- Left: Event title with ellipsis (`max-width: 280px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-weight: 700; color: var(--text-primary);`).
- Center/Right: Subtle dot `·` + `renderExpiryPill(expiresAt)` + Subtle dot `·` + `[ N/Max Seats ]` + `[ Status Badge ]`.
- Left: Event title with ellipsis
(`max-width: 280px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-weight: 700; color: var(--text-primary);`).
- Center/Right: Subtle dot `·` + `renderExpiryPill(expiresAt)` + Subtle dot
`·` + `[ N/Max Seats ]` + `[ Status Badge ]`.
- **Row 2 (1-Click Handoffs & Action Pinned Right):**
- Left (Handoff Pills): `[ PIN: 241-881 (Copy) ]`, `[ Link (Copy) ]`, `[ CLI (Copy) ]` using compact inline pills (`padding: 2px 6px; font-size: 0.75rem;`).
- Right (Action Buttons): Compact `[ 👥 Guests (N) ]` and `[ 🔄 +1h ]` (or `[ 🔄 Reopen (+1h) ]` if expired).
- **Delete Mock Code:** Completely remove the `[ ▸ Details ]` button and its `alert(...)` placeholder.
- Left (Handoff Pills): `[ PIN: 241-881 (Copy) ]`, `[ Link (Copy) ]`,
`[ CLI (Copy) ]` using compact inline pills
(`padding: 2px 6px; font-size: 0.75rem;`).
- Right (Action Buttons): Compact `[ 👥 Guests (N) ]` and `[ 🔄 +1h ]` (or
`[ 🔄 Reopen (+1h) ]` if expired).
- **Delete Mock Code:** Completely remove the `[ ▸ Details ]` button and its
`alert(...)` placeholder.
3. **Grid Card Polish:**
- Replace detached CLI `<details>` box with a unified **Integrated Expanding CLI Component**:
- Replace detached CLI `<details>` box with a unified **Integrated Expanding
CLI Component**:
- Single-line snippet when collapsed with `[ Copy ]` and `[ ▾ ]` toggle.
- Expands downward into multiline highlighted command block on toggle click.
- Expands downward into multiline highlighted command block on toggle
click.
- Fix double arrow marker bug (`list-style: none;`).
- Standardize `[ 🔄 Rotate Credentials ]` button to invoke multi-field rotation.
- Standardize `[ 🔄 Rotate Credentials ]` button to invoke multi-field
rotation.
---
### Phase 4: Standardized Natural Expiry & Start-Time Telemetry (`SessionsScript.tsx` & Drawers)
1. **Natural Expiry Formatter Helper (`formatNaturalExpiry(expiresAt)`):**
- Natural Date String: `Today` (if $<24$h), `Tomorrow` (if $<48$h), `MMM D` (if same year), `MMM D, YYYY` (if different year).
- Natural Date String: `Today` (if $<24$h), `Tomorrow` (if $<48$h), `MMM D`
(if same year), `MMM D, YYYY` (if different year).
- Exact Time: `h:mm A` (e.g. `10:39 PM`).
- Scaled Urgency Badge:
- $<1$h: Amber `[ 45m left ]`
@ -126,11 +177,17 @@
- Tooltip: `title="${fullISODate}"` on hover/long-press.
- Format: `[Date] · [Time] · [ Colored Urgency Badge ]`.
2. **Inverted Symmetrical Start-Time Formatter (`formatNaturalJoinTime(createdAt)`):**
2. **Inverted Symmetrical Start-Time Formatter
(`formatNaturalJoinTime(createdAt)`):**
- In `EventGuestsDrawer.tsx`, render:
- **Header:** Bold `Seat #[N]` + `🟢 Active / ⏸️ Paused` badge + `[ ⏸️ Pause ]` + `[ 🗑️ Revoke ]`.
- **Line 1:** `Joined Today · 2:15 PM · ` <span style="background:rgba(34,197,94,0.15);color:#16a34a;padding:2px 6px;border-radius:4px;font-size:0.75rem;font-weight:600;">Active 7h 54m</span>.
- **Line 2:** `Last Action: ${lastAction || 'ForwardAuth Ingress'} · ${timeAgo} · 💻 Web` (or `📟 CLI`).
- **Header:** Bold `Seat #[N]` + `🟢 Active / ⏸️ Paused` badge +
`[ ⏸️ Pause ]` + `[ 🗑️ Revoke ]`.
- **Line 1:** `Joined Today · 2:15 PM ·`
<span style="background:rgba(34,197,94,0.15);color:#16a34a;padding:2px 6px;border-radius:4px;font-size:0.75rem;font-weight:600;">Active
7h 54m</span>.
- **Line 2:**
`Last Action: ${lastAction || 'ForwardAuth Ingress'} · ${timeAgo} · 💻 Web`
(or `📟 CLI`).
- Remove redundant `guest_slug_seat` visual text (keep in tooltip only).
---
@ -138,22 +195,27 @@
### Phase 5: Delegation Drawer Overhaul (`WorkshopDrawer.tsx` & `SessionsScript.tsx`)
1. **Drawer Nomenclature & WAI-ARIA High-Contrast Tabs:**
- Drawer Header: `<h2>Delegate Session</h2>` with subtitle *"Mint a 1:1 delegated token or launch a multi-seat workshop event."*
- Drawer Header: `<h2>Delegate Session</h2>` with subtitle _"Mint a 1:1
delegated token or launch a multi-seat workshop event."_
- Tabs (`role="tablist"`):
- **Tab 1 (`role="tab"`):** `Single Session`
- Subtitle hint: *For agents, CI/CD, or 1:1 delegation*
- Subtitle hint: _For agents, CI/CD, or 1:1 delegation_
- **Tab 2 (`role="tab"`):** `Multi-Claim Event`
- Subtitle hint: *For workshops, teams & guest pools*
- High-Contrast Active State: Solid `var(--primary)` background with white text (`#ffffff`), `aria-selected="true"`.
- Inactive State: Translucent muted background (`opacity: 0.75`), `aria-selected="false"`.
- Subtitle hint: _For workshops, teams & guest pools_
- High-Contrast Active State: Solid `var(--primary)` background with white
text (`#ffffff`), `aria-selected="true"`.
- Inactive State: Translucent muted background (`opacity: 0.75`),
`aria-selected="false"`.
2. **Handoff State & Button Minimalist Polish:**
- Standardize all 3 copy buttons to uniform `btn-outline` (`Copy PIN`, `Copy URL`, `Copy 1-Liner`).
- Standardize all 3 copy buttons to uniform `btn-outline` (`Copy PIN`,
`Copy URL`, `Copy 1-Liner`).
- Replace `"Dismiss"` with a clean, minimal button **`[ OK ]`**.
3. **State Machine Reset on Open:**
- In `SessionsScript.tsx:openDelegateDrawer()`:
- Reset `#eventCreateState` to `display: block` and `#eventHandoffState` to `display: none`.
- Reset `#eventCreateState` to `display: block` and `#eventHandoffState` to
`display: none`.
- Clear form inputs (`eventName`, `eventSlug`, `eventPinCode`, etc.).
- Reset tab selection to `Single Session`.
@ -162,6 +224,7 @@
## 4. Verification Plan
### Automated Tests
1. **Universal Credential Rotation Test (`server/tests/events.test.ts`):**
- Create event pass -> Call `POST /api/events/:id/rotate-pin`.
- Verify response returns new `pinCode` AND new `slug`.
@ -173,11 +236,13 @@
- Call `POST /api/events/:id/extend` with `extendHours: 1`.
- Verify `expires_at > NOW()` (approximately `NOW() + 1 hour`).
3. **UI Script & Formatter Tests (`ui/ui_scripts.test.ts`):**
- Unit test `formatNaturalExpiry` across all time horizons (<1h, <24h, 28d, 142d -> `4.5 mos`, 420d -> `1.2 yrs`, expired).
- Unit test `formatNaturalExpiry` across all time horizons (<1h, <24h, 28d,
142d -> `4.5 mos`, 420d -> `1.2 yrs`, expired).
- Test `formatNaturalJoinTime` inverted duration math.
- Test drawer state machine reset logic.
### Quality Gate Commands
```bash
deno fmt --check
deno task lint

View File

@ -37,11 +37,11 @@ export const SessionsPage = ({
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 1.5rem; flex-wrap: wrap; gap: 1rem;">
<div>
<h1 style="font-size: 1.75rem; font-weight: 700; margin: 0 0 0.5rem 0; color: var(--text-primary);">
Sessions & Passes
Sessions & Events
</h1>
<p style="color: var(--text-secondary); margin: 0; font-size: 0.95rem;">
Manage logins, mint 1:1 ephemeral links & CLI tokens, or launch
multi-claim workshop event passes.
Manage logins, mint 1:1 delegated tokens, or launch multi-claim
workshop events.
</p>
</div>
@ -57,9 +57,6 @@ export const SessionsPage = ({
</div>
</div>
<h2 style="font-size: 1.25rem; margin-top: 2rem; margin-bottom: 1rem; color: var(--text-primary);">
Event Passes
</h2>
<EventCockpitDeck eventPasses={eventPasses} />
{/* Delegate Session Drawer */}
@ -71,12 +68,12 @@ export const SessionsPage = ({
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 0.5rem;">
<div style="width: 100%;">
<div style="display: flex; justify-content: space-between; align-items: center;">
<h3
<h2
id="delegateDrawerTitle"
style="margin: 0 0 0.25rem 0; color: var(--text-primary);"
style="margin: 0 0 0.25rem 0; color: var(--text-primary); font-size: 1.25rem;"
>
Create New Pass / Session
</h3>
Delegate Session
</h2>
<button
type="button"
onclick="closeDelegateDrawer()"
@ -85,23 +82,45 @@ export const SessionsPage = ({
&times;
</button>
</div>
<p style="color: var(--text-secondary); margin: 0; font-size: 0.9rem;">
Mint a 1:1 delegated token or launch a multi-seat workshop event.
</p>
<div style="display: flex; background: var(--surface-muted); padding: 4px; border-radius: var(--radius-sm); border: 1px solid var(--border-subtle); gap: 4px; margin-top: 1rem; margin-bottom: 1rem;">
<div
role="tablist"
style="display: flex; background: var(--surface-muted); padding: 4px; border-radius: var(--radius-sm); border: 1px solid var(--border-subtle); gap: 4px; margin-top: 1rem; margin-bottom: 1rem;"
>
<button
type="button"
role="tab"
aria-selected="true"
id="tabBtnDirectPass"
class="delegate-tab-btn active"
onclick="switchDelegateTab('tabDirectPass')"
style="display: flex; flex-direction: column; align-items: center; gap: 2px;"
>
🔑 1:1 Direct Pass
<span style="font-size: 0.95rem; font-weight: 700;">
Single Session
</span>
<span style="font-size: 0.75rem; font-weight: 400; opacity: 0.85;">
For agents, CI/CD, or 1:1 delegation
</span>
</button>
<button
type="button"
role="tab"
aria-selected="false"
id="tabBtnWorkshopPass"
class="delegate-tab-btn"
onclick="switchDelegateTab('tabWorkshopPass')"
style="display: flex; flex-direction: column; align-items: center; gap: 2px;"
>
🎟 Multi-Claim Workshop Pass
<span style="font-size: 0.95rem; font-weight: 700;">
Multi-Claim Event
</span>
<span style="font-size: 0.75rem; font-weight: 400; opacity: 0.85;">
For workshops, teams & guest pools
</span>
</button>
</div>
</div>
@ -175,11 +194,13 @@ export const SessionsPage = ({
transition: all 0.15s;
background: transparent;
color: var(--text-secondary);
opacity: 0.75;
}
.delegate-tab-btn.active {
background: var(--surface-card);
color: var(--primary);
background: var(--primary);
color: #ffffff;
box-shadow: var(--shadow-xs);
opacity: 1;
}
`}
</style>

View File

@ -14,6 +14,7 @@ export const EventCockpitDeck = ({ eventPasses }: { eventPasses: any[] }) => {
class="view-mode-btn active"
onclick="setEventViewMode('grid')"
aria-label="Grid View"
aria-pressed="true"
>
🗂 Grid
</button>
@ -23,6 +24,7 @@ export const EventCockpitDeck = ({ eventPasses }: { eventPasses: any[] }) => {
class="view-mode-btn"
onclick="setEventViewMode('compact')"
aria-label="Compact View"
aria-pressed="false"
>
📋 Compact
</button>
@ -125,29 +127,31 @@ export const EventCockpitDeck = ({ eventPasses }: { eventPasses: any[] }) => {
</button>
</div>
<div style="display: flex; align-items: center; gap: 0.5rem;">
<span style="font-size: 0.8rem; font-weight: 600; width: 45px; color: var(--text-muted);">
CLI:
</span>
<code style="flex: 1; padding: 0.35rem 0.5rem; background: var(--surface-muted); border-radius: var(--radius-sm); font-family: monospace; font-size: 0.75rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
curl -sSL {Deno.env.get("RP_ID")
? `https://${Deno.env.get("RP_ID")}`
: ""}/join/{event.slug}?format=env | source /dev/stdin
</code>
<button
type="button"
class="btn-outline"
aria-label={`Copy CLI command for ${event.name}`}
style="padding: 0.25rem 0.5rem; font-size: 0.75rem; min-height: 28px;"
onclick={`copyText("curl -sSL " + window.location.origin + "/join/${event.slug}?format=env | source /dev/stdin")`}
>
Copy
</button>
</div>
<details style="font-size: 0.75rem; margin-top: 0.1rem;">
<summary style="cursor: pointer; color: var(--primary); font-weight: 600; user-select: none;">
Expand full CLI command
<details
class="cli-details"
style="margin-top: 0.1rem; width: 100%;"
>
<summary style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer; user-select: none;">
<span style="font-size: 0.8rem; font-weight: 600; width: 45px; color: var(--text-muted);">
CLI:
</span>
<code style="flex: 1; padding: 0.35rem 0.5rem; background: var(--surface-muted); border-radius: var(--radius-sm); font-family: monospace; font-size: 0.75rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; cursor: pointer;">
curl -sSL {Deno.env.get("RP_ID")
? `https://${Deno.env.get("RP_ID")}`
: ""}/join/{event.slug}?format=env | source /dev/stdin
</code>
<button
type="button"
class="btn-outline"
aria-label={`Copy CLI command for ${event.name}`}
style="padding: 0.25rem 0.5rem; font-size: 0.75rem; min-height: 28px;"
onclick={`event.stopPropagation(); copyText("curl -sSL " + window.location.origin + "/join/${event.slug}?format=env | source /dev/stdin")`}
>
Copy
</button>
<span style="font-size: 0.75rem; padding-right: 0.25rem;">
[ ]
</span>
</summary>
<textarea
readonly
@ -192,11 +196,11 @@ export const EventCockpitDeck = ({ eventPasses }: { eventPasses: any[] }) => {
<button
type="button"
class="btn-outline"
aria-label={`Rotate PIN code for ${event.name}`}
aria-label={`Rotate Credentials for ${event.name}`}
style="justify-content: center; min-height: 38px; font-size: 0.82rem; padding: 0 0.25rem;"
onclick={`rotatePin('${event.id}')`}
>
🔄 Rotate PIN
🔄 Rotate Credentials
</button>
<button
type="button"
@ -220,25 +224,50 @@ export const EventCockpitDeck = ({ eventPasses }: { eventPasses: any[] }) => {
</div>
{/* Compact Actions (only visible in compact mode) */}
<div class="event-card-compact-actions">
<button
type="button"
class="btn-outline"
style="padding: 0.25rem 0.5rem; font-size: 0.8rem; min-height: 32px;"
onclick={`openAttendeesDrawer('${event.id}', '${
event.name.replace(/'/g, "\\'")
}')`}
>
👥 Guests ({event.seats_claimed})
</button>
<button
type="button"
class="btn-outline"
style="padding: 0.25rem 0.5rem; font-size: 0.8rem; min-height: 32px;"
onclick="alert('Expand functionality for compact view to be implemented if needed')"
>
Details
</button>
<div class="event-card-compact-row-2">
<div class="compact-pills">
<span
class="compact-pill"
onclick={`copyText(document.getElementById('pin-${event.id}').textContent.trim())`}
>
PIN:{" "}
<span id={`compact-pin-${event.id}`}>{event.pin_code}</span>
{" "}
(Copy)
</span>
<span
class="compact-pill"
onclick={`copyText(window.location.origin + '/e/${event.slug}')`}
>
Link (Copy)
</span>
<span
class="compact-pill"
onclick={`copyText("curl -sSL " + window.location.origin + "/join/${event.slug}?format=env | source /dev/stdin")`}
>
CLI (Copy)
</span>
</div>
<div class="compact-actions-right">
<button
type="button"
class="btn-outline"
style="padding: 0.25rem 0.5rem; font-size: 0.8rem; min-height: 28px;"
onclick={`openAttendeesDrawer('${event.id}', '${
event.name.replace(/'/g, "\\'")
}')`}
>
👥 Guests ({event.seats_claimed})
</button>
<button
type="button"
class="btn-outline extend-event-btn"
data-event-id={event.id}
style="padding: 0.25rem 0.5rem; font-size: 0.8rem; min-height: 28px;"
>
🔄 +1h
</button>
</div>
</div>
</div>
);
@ -246,6 +275,13 @@ export const EventCockpitDeck = ({ eventPasses }: { eventPasses: any[] }) => {
</div>
<style>
{`
.cli-details summary {
list-style: none;
}
.cli-details summary::-webkit-details-marker {
display: none;
}
.view-mode-btn {
padding: 0.25rem 0.75rem;
border-radius: var(--radius-sm);
@ -256,11 +292,15 @@ export const EventCockpitDeck = ({ eventPasses }: { eventPasses: any[] }) => {
transition: all 0.15s;
background: transparent;
color: var(--text-secondary);
opacity: 0.75;
}
.view-mode-btn.active {
background: var(--surface-card);
color: var(--primary);
box-shadow: var(--shadow-xs);
background: var(--primary);
color: #ffffff;
font-weight: 700;
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
border-radius: var(--radius-sm);
opacity: 1;
}
/* Grid View Layout */
@ -275,7 +315,7 @@ export const EventCockpitDeck = ({ eventPasses }: { eventPasses: any[] }) => {
align-items: flex-start;
margin-bottom: 0.75rem;
}
.grid-view .event-card-compact-actions {
.grid-view .event-card-compact-row-2 {
display: none;
}
@ -287,10 +327,14 @@ export const EventCockpitDeck = ({ eventPasses }: { eventPasses: any[] }) => {
}
.compact-view .event-card {
display: flex;
align-items: center;
justify-content: space-between;
flex-direction: column;
justify-content: center;
padding: 0.75rem 1rem;
gap: 1rem;
box-sizing: border-box;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
min-height: 70px;
}
.compact-view .event-card > div {
margin-bottom: 0 !important; /* Reset component margins */
@ -298,34 +342,68 @@ export const EventCockpitDeck = ({ eventPasses }: { eventPasses: any[] }) => {
.compact-view .event-card-header {
display: flex;
align-items: center;
gap: 1rem;
gap: 0.5rem;
width: 100%;
margin-bottom: 0.5rem !important;
}
.compact-view .event-card-header > div {
display: flex;
align-items: center;
gap: 0.5rem;
flex: 1;
min-width: 0;
}
.compact-view .event-card-header h3 {
margin: 0 !important;
font-size: 1rem !important;
font-weight: 700;
color: var(--text-primary);
max-width: 280px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: inline-block;
}
.compact-view .event-card-header > div {
.compact-view .event-card-header > .status-badge {
margin-left: auto;
}
.compact-view .event-card-compact-row-2 {
display: flex;
align-items: center;
gap: 1rem;
justify-content: space-between;
width: 100%;
}
.compact-view .event-card-snippets,
.compact-view .event-card-actions,
.compact-view .status-badge {
display: none; /* Hide heavy elements in compact */
}
.compact-view .event-card-compact-actions {
.compact-view .compact-pills {
display: flex;
gap: 0.5rem;
align-items: center;
flex-shrink: 0;
}
.compact-view .countdown-pill {
font-size: 0.75rem;
.compact-view .compact-pill {
padding: 2px 6px;
font-size: 0.75rem;
background: var(--surface-muted);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
cursor: pointer;
color: var(--text-secondary);
font-family: monospace;
}
.compact-view .compact-pill:hover {
border-color: var(--primary);
color: var(--primary);
}
.compact-view .compact-actions-right {
display: flex;
gap: 0.5rem;
align-items: center;
}
.compact-view .event-card-snippets,
.compact-view .event-card-actions {
display: none !important; /* Hide heavy elements in compact */
}
/* Dynamic Countdown Colors */

View File

@ -32,8 +32,7 @@ export const EventGuestsDrawer = () => {
>
Event Pass · <span id="guestDrawerClaimed">0</span> /{" "}
<span id="guestDrawerMax">0</span> Claimed Seats ·{" "}
<span id="guestDrawerCountdown"> 0h 0m left</span> · (Expires{" "}
<span id="guestDrawerExpiresAt">Time</span>)
<span id="guestDrawerCountdown"> 0h 0m left</span>
</div>
</div>

View File

@ -16,34 +16,47 @@ export const SessionsScript = () => {
}
function openDelegateDrawer() {
document.getElementById('delegateDrawer').style.display = 'block';
document.getElementById('delegateDrawer').scrollIntoView({ behavior: 'smooth' });
}
const drawer = document.getElementById('delegateDrawer');
drawer.style.display = 'block';
drawer.scrollIntoView({ behavior: 'smooth' });
function closeDelegateDrawer() {
document.getElementById('delegateDrawer').style.display = 'none';
// Reset WorkshopDrawer 2-State Machine
// Reset WorkshopDrawer 2-State Machine on open
const eventCreateState = document.getElementById('eventCreateState');
if (eventCreateState) eventCreateState.style.display = 'block';
const eventHandoffState = document.getElementById('eventHandoffState');
if (eventHandoffState) eventHandoffState.style.display = 'none';
const eventForm = document.getElementById('eventForm');
if (eventForm) eventForm.reset();
const drawerTitle = document.getElementById('delegateDrawerTitle');
if (drawerTitle) drawerTitle.textContent = 'Create New Pass / Session';
// Default back to Single Session Tab
switchDelegateTab('tabDirectPass');
}
function closeDelegateDrawer() {
document.getElementById('delegateDrawer').style.display = 'none';
}
function switchDelegateTab(tabId) {
document.getElementById('tabDirectPass').style.display = 'none';
document.getElementById('tabWorkshopPass').style.display = 'none';
document.getElementById('tabBtnDirectPass').classList.remove('active');
document.getElementById('tabBtnWorkshopPass').classList.remove('active');
const btnDirect = document.getElementById('tabBtnDirectPass');
const btnWorkshop = document.getElementById('tabBtnWorkshopPass');
btnDirect.classList.remove('active');
btnDirect.setAttribute('aria-selected', 'false');
btnWorkshop.classList.remove('active');
btnWorkshop.setAttribute('aria-selected', 'false');
document.getElementById(tabId).style.display = 'block';
if (tabId === 'tabDirectPass') {
document.getElementById('tabBtnDirectPass').classList.add('active');
btnDirect.classList.add('active');
btnDirect.setAttribute('aria-selected', 'true');
} else {
document.getElementById('tabBtnWorkshopPass').classList.add('active');
btnWorkshop.classList.add('active');
btnWorkshop.setAttribute('aria-selected', 'true');
}
}
@ -166,7 +179,6 @@ export const SessionsScript = () => {
document.getElementById('eventCreateState').style.display = 'none';
document.getElementById('eventHandoffState').style.display = 'block';
document.getElementById('delegateDrawerTitle').textContent = 'Event Pass Active';
showNotice('Workshop pass created: ' + ev.name, false);
} else {
@ -388,12 +400,18 @@ export const SessionsScript = () => {
const usernameParts = att.username.split('_');
const seatNumber = usernameParts.length > 2 ? usernameParts[usernameParts.length - 1] : '?';
// Compute relative join time (approximate based on created_at)
const createdDate = new Date(att.created_at);
const now = new Date();
const diffMs = now - createdDate;
const diffMins = Math.floor(diffMs / 60000);
const joinText = diffMins < 1 ? 'Joined just now' : 'Joined ' + diffMins + 'm ago';
const joinText = formatNaturalJoinTime(att.created_at, !isPaused);
const lastAction = att.last_activity_action || 'ForwardAuth Ingress';
let timeAgo = 'just now';
if (att.last_activity_at) {
const lastActDate = new Date(att.last_activity_at);
const now = new Date();
const diffMinsAct = Math.floor((now - lastActDate) / 60000);
timeAgo = diffMinsAct < 1 ? 'just now' : diffMinsAct + 'm ago';
}
const clientIcon = lastAction.includes('CLI') ? '📟 CLI' : '💻 Web';
html += \`
<div class="card" style="padding: 0.75rem; margin: 0; display: flex; justify-content: space-between; align-items: center; border-left: 3px solid \${isPaused ? 'var(--warning)' : 'var(--success)'}; opacity: \${isPaused ? '0.7' : '1'};">
@ -405,7 +423,10 @@ export const SessionsScript = () => {
</div>
</div>
<div style="font-size: 0.75rem; color: var(--text-secondary);" title="\${att.username}">
\${joinText} · \${att.username}
\${joinText}
</div>
<div style="font-size: 0.75rem; color: var(--text-secondary); margin-top: 0.15rem;">
Last Action: \${lastAction} &middot; \${timeAgo} &middot; \${clientIcon}
</div>
</div>
<div style="display: flex; gap: 0.5rem;">
@ -584,49 +605,117 @@ export const SessionsScript = () => {
}
}
// Standardized formatters
function formatNaturalExpiry(expiresAt) {
const expDate = new Date(expiresAt);
const now = new Date();
const diffMs = expDate.getTime() - now.getTime();
const timeStr = expDate.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
const fullISO = expDate.toISOString();
let dateStr = '';
const isToday = expDate.getDate() === now.getDate() && expDate.getMonth() === now.getMonth() && expDate.getFullYear() === now.getFullYear();
const tomorrow = new Date(now);
tomorrow.setDate(tomorrow.getDate() + 1);
const isTomorrow = expDate.getDate() === tomorrow.getDate() && expDate.getMonth() === tomorrow.getMonth() && expDate.getFullYear() === tomorrow.getFullYear();
if (isToday) {
dateStr = 'Today';
} else if (isTomorrow) {
dateStr = 'Tomorrow';
} else if (expDate.getFullYear() === now.getFullYear()) {
dateStr = expDate.toLocaleDateString([], { month: 'short', day: 'numeric' });
} else {
dateStr = expDate.toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' });
}
let badge = '';
if (diffMs <= 0) {
badge = '<span class="status-badge-red" title="' + fullISO + '" style="background:rgba(239,68,68,0.15);color:#dc2626;padding:2px 6px;border-radius:4px;font-size:0.75rem;font-weight:600;">Expired</span>';
} else {
const totalMins = Math.floor(diffMs / 60000);
const hours = totalMins / 60;
const days = hours / 24;
const months = days / 30;
const years = days / 365;
let timeRemainingStr = '';
let badgeStyle = 'background:rgba(34,197,94,0.15);color:#16a34a;padding:2px 6px;border-radius:4px;font-size:0.75rem;font-weight:600;'; // green
if (hours < 1) {
timeRemainingStr = totalMins + 'm left';
badgeStyle = 'background:rgba(245,158,11,0.15);color:#d97706;padding:2px 6px;border-radius:4px;font-size:0.75rem;font-weight:600;'; // amber
} else if (hours < 24) {
const h = Math.floor(hours);
const m = totalMins % 60;
timeRemainingStr = h + 'h ' + m + 'm left';
badgeStyle = 'background:rgba(245,158,11,0.15);color:#d97706;padding:2px 6px;border-radius:4px;font-size:0.75rem;font-weight:600;'; // amber
} else if (days <= 60) {
timeRemainingStr = Math.floor(days) + 'd left';
} else if (months <= 12) {
timeRemainingStr = months.toFixed(1) + ' mos left';
} else {
timeRemainingStr = years.toFixed(1) + ' yrs left';
}
badge = '<span style="' + badgeStyle + '" title="' + fullISO + '">' + timeRemainingStr + '</span>';
}
return dateStr + ' &middot; ' + timeStr + ' &middot; ' + badge;
}
function formatNaturalJoinTime(createdAt, isActive) {
const createdDate = new Date(createdAt);
const now = new Date();
const diffMs = now.getTime() - createdDate.getTime();
const diffMins = Math.floor(diffMs / 60000);
const timeStr = createdDate.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
const isToday = createdDate.getDate() === now.getDate() && createdDate.getMonth() === now.getMonth() && createdDate.getFullYear() === now.getFullYear();
const dateStr = isToday ? 'Today' : createdDate.toLocaleDateString([], { month: 'short', day: 'numeric' });
let durationStr = '';
if (diffMins < 60) {
durationStr = diffMins + 'm';
} else {
const h = Math.floor(diffMins / 60);
const m = diffMins % 60;
durationStr = h + 'h ' + m + 'm';
}
const badgeStyle = isActive ? 'background:rgba(34,197,94,0.15);color:#16a34a;padding:2px 6px;border-radius:4px;font-size:0.75rem;font-weight:600;' : 'background:rgba(234,179,8,0.15);color:#ca8a04;padding:2px 6px;border-radius:4px;font-size:0.75rem;font-weight:600;';
const badgeText = isActive ? 'Active ' + durationStr : 'Paused';
const activeBadge = '<span style="' + badgeStyle + '">' + badgeText + '</span>';
return 'Joined ' + dateStr + ' &middot; ' + timeStr + ' &middot; ' + activeBadge;
}
// Dynamic Countdown Updater
function updateAllCountdowns() {
const pills = document.querySelectorAll('.countdown-pill, #guestDrawerCountdown');
const now = new Date();
pills.forEach(pill => {
const expiresAtStr = pill.getAttribute('data-expires-at');
if (!expiresAtStr) return;
const expDate = new Date(expiresAtStr);
const diffMs = expDate - now;
pill.classList.remove('status-green', 'status-amber', 'status-red');
if (diffMs <= 0) {
pill.textContent = '⏳ Expired';
pill.classList.add('status-red');
if (pill.id === 'guestDrawerCountdown') {
const now = new Date();
const expDate = new Date(expiresAtStr);
const diffMs = expDate - now;
if (diffMs <= 0) {
pill.textContent = '⏳ Expired';
} else {
const totalMins = Math.floor(diffMs / 60000);
const hours = Math.floor(totalMins / 60);
const mins = totalMins % 60;
pill.textContent = \`\${hours}h \${mins}m left\`;
}
} else {
const totalMins = Math.floor(diffMs / 60000);
const hours = Math.floor(totalMins / 60);
const mins = totalMins % 60;
let text = '';
if (hours > 72) { // more than 3 days
const days = Math.floor(hours / 24);
text = \`\${days}d left\`;
} else {
text = \`\${hours}h \${mins}m left\`;
}
// Add absolute time suffix if it's a standard pill (not the drawer context header which has it separate)
if (pill.id !== 'guestDrawerCountdown') {
const timeString = expDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
text += \` · (Expires \${timeString})\`;
}
pill.textContent = text;
if (hours < 1) {
pill.classList.add('status-amber');
} else {
pill.classList.add('status-green');
}
pill.innerHTML = formatNaturalExpiry(expiresAtStr);
}
});
}

View File

@ -241,7 +241,7 @@ export const WorkshopDrawer = ({ apps = [] }: { apps?: any[] }) => {
</code>
<button
type="button"
class="btn-primary"
class="btn-outline"
aria-label="Copy Universal PIN"
style="min-height: 36px; padding: 0 0.85rem; font-size: 0.8rem;"
onclick="copyEventHandoff('pin')"
@ -304,7 +304,7 @@ export const WorkshopDrawer = ({ apps = [] }: { apps?: any[] }) => {
style="width: 100%; min-height: 38px; justify-content: center; font-size: 0.85rem;"
onclick="closeEventHandoffModal()"
>
Dismiss
OK
</button>
</div>
</div>

View File

@ -0,0 +1,118 @@
export function formatNaturalExpiry(expiresAt: string | Date): string {
const expDate = new Date(expiresAt);
const now = new Date();
const diffMs = expDate.getTime() - now.getTime();
const timeStr = expDate.toLocaleTimeString([], {
hour: "numeric",
minute: "2-digit",
});
const fullISO = expDate.toISOString();
let dateStr = "";
const isToday = expDate.getDate() === now.getDate() &&
expDate.getMonth() === now.getMonth() &&
expDate.getFullYear() === now.getFullYear();
const tomorrow = new Date(now);
tomorrow.setDate(tomorrow.getDate() + 1);
const isTomorrow = expDate.getDate() === tomorrow.getDate() &&
expDate.getMonth() === tomorrow.getMonth() &&
expDate.getFullYear() === tomorrow.getFullYear();
if (isToday) {
dateStr = "Today";
} else if (isTomorrow) {
dateStr = "Tomorrow";
} else if (expDate.getFullYear() === now.getFullYear()) {
dateStr = expDate.toLocaleDateString([], {
month: "short",
day: "numeric",
});
} else {
dateStr = expDate.toLocaleDateString([], {
month: "short",
day: "numeric",
year: "numeric",
});
}
let badge = "";
if (diffMs <= 0) {
badge = '<span class="status-badge-red" title="' + fullISO +
'">Expired</span>';
} else {
const totalMins = Math.floor(diffMs / 60000);
const hours = totalMins / 60;
const days = hours / 24;
const months = days / 30;
const years = days / 365;
let timeRemainingStr = "";
let badgeClass = "status-badge-green";
if (hours < 1) {
timeRemainingStr = totalMins + "m left";
badgeClass = "status-badge-amber";
} else if (hours < 24) {
const h = Math.floor(hours);
const m = totalMins % 60;
timeRemainingStr = h + "h " + m + "m left";
badgeClass = "status-badge-amber";
} else if (days <= 60) {
timeRemainingStr = Math.floor(days) + "d left";
} else if (months <= 12) {
timeRemainingStr = months.toFixed(1) + " mos left";
} else {
timeRemainingStr = years.toFixed(1) + " yrs left";
}
badge = '<span class="' + badgeClass + '" title="' + fullISO + '">' +
timeRemainingStr + "</span>";
}
return dateStr + " &middot; " + timeStr + " &middot; " + badge;
}
export function formatNaturalJoinTime(
createdAt: string | Date,
isActive: boolean = true,
): string {
const createdDate = new Date(createdAt);
const now = new Date();
const diffMs = now.getTime() - createdDate.getTime();
const diffMins = Math.floor(diffMs / 60000);
const timeStr = createdDate.toLocaleTimeString([], {
hour: "numeric",
minute: "2-digit",
});
const isToday = createdDate.getDate() === now.getDate() &&
createdDate.getMonth() === now.getMonth() &&
createdDate.getFullYear() === now.getFullYear();
const dateStr = isToday
? "Today"
: createdDate.toLocaleDateString([], { month: "short", day: "numeric" });
let durationStr = "";
if (diffMins < 60) {
durationStr = diffMins + "m";
} else {
const h = Math.floor(diffMins / 60);
const m = diffMins % 60;
durationStr = h + "h " + m + "m";
}
const badgeStyle = isActive
? "background:rgba(34,197,94,0.15);color:#16a34a;padding:2px 6px;border-radius:4px;font-size:0.75rem;font-weight:600;"
: "background:rgba(234,179,8,0.15);color:#ca8a04;padding:2px 6px;border-radius:4px;font-size:0.75rem;font-weight:600;";
const badgeText = isActive ? "Active " + durationStr : "Paused";
const activeBadge = '<span style="' + badgeStyle + '">' + badgeText +
"</span>";
return "Joined " + dateStr + " &middot; " + timeStr + " &middot; " +
activeBadge;
}

View File

@ -1,4 +1,77 @@
import { assertEquals } from "jsr:@std/assert@1";
import {
formatNaturalExpiry,
formatNaturalJoinTime,
} from "./components/sessions/formatters.ts";
Deno.test("formatNaturalExpiry - < 1h", () => {
const now = new Date();
const expiry = new Date(now.getTime() + 45 * 60000);
const result = formatNaturalExpiry(expiry);
assertEquals(
result.includes("45m left") || result.includes("44m left"),
true,
);
assertEquals(result.includes("status-badge-amber"), true);
});
Deno.test("formatNaturalExpiry - < 24h", () => {
const now = new Date();
const expiry = new Date(now.getTime() + 165 * 60000); // 2h 45m
const result = formatNaturalExpiry(expiry);
assertEquals(
result.includes("2h 45m left") || result.includes("2h 44m left"),
true,
);
assertEquals(result.includes("status-badge-amber"), true);
});
Deno.test("formatNaturalExpiry - 1-60d", () => {
const now = new Date();
const expiry = new Date(now.getTime() + 28 * 24 * 60 * 60000);
const result = formatNaturalExpiry(expiry);
assertEquals(
result.includes("28d left") || result.includes("27d left"),
true,
);
assertEquals(result.includes("status-badge-green"), true);
});
Deno.test("formatNaturalExpiry - 2-12mos", () => {
const now = new Date();
const expiry = new Date(now.getTime() + 142 * 24 * 60 * 60000);
const result = formatNaturalExpiry(expiry);
assertEquals(result.includes("4.7 mos left"), true);
assertEquals(result.includes("status-badge-green"), true);
});
Deno.test("formatNaturalExpiry - > 1yr", () => {
const now = new Date();
const expiry = new Date(now.getTime() + 420 * 24 * 60 * 60000);
const result = formatNaturalExpiry(expiry);
assertEquals(
result.includes("1.2 yrs left") || result.includes("1.1 yrs left"),
true,
);
assertEquals(result.includes("status-badge-green"), true);
});
Deno.test("formatNaturalExpiry - expired", () => {
const now = new Date();
const expiry = new Date(now.getTime() - 45 * 60000);
const result = formatNaturalExpiry(expiry);
assertEquals(result.includes("Expired"), true);
assertEquals(result.includes("status-badge-red"), true);
});
Deno.test("formatNaturalJoinTime", () => {
const now = new Date();
const joinTime = new Date(now.getTime() - 474 * 60000); // 7h 54m ago
const result = formatNaturalJoinTime(joinTime);
assertEquals(result.includes("7h 54m"), true);
assertEquals(result.includes("Active"), true);
});
import { SessionsPage } from "./components/SessionsPage.tsx";
import { LoginPage } from "./components/LoginPage.tsx";
import { RegisterPage } from "./components/RegisterPage.tsx";