Compare commits

...
Author SHA1 Message Date
overseer 72966bf9ae Define and implement the panel's empty and error states
Close the DESIGN-HANDOFF open question "empty and error states are
unspecified" with a written addendum (design/PANEL-EMPTY-STATES.md)
and its implementation in both panel cards:

- quest log resolves one state in priority order: no adventurer in
  the path, integration not set up, records unreachable
  (unavailable/unknown), away via direct navigation, all-clear
  zero-quest day, all-done fallback before the 1c takeover, then the
  normal three-window columns
- party board renders a notice instead of an empty grid when no
  children are configured or every child's sensors are unresolvable,
  and muted "Unknown" plates for partially unresolvable children
- a shared notice panel (panel tokens, role=status) replaces every
  state that would otherwise render a blank parchment; frames,
  kicker and dock always stay
2026-09-20 14:01:07 -04:00
4 changed files with 1768 additions and 773 deletions
File diff suppressed because it is too large Load Diff
+104
View File
@@ -0,0 +1,104 @@
# Panel empty & error states — design addendum
Closes the gap DESIGN-HANDOFF.md lists under "Open questions": *"Empty and error
states are unspecified by request (child with no quests, everyone away, integration
unconfigured, DB unreachable). Panel must not render a blank parchment — decide
before shipping the card."*
All decisions below reuse PANEL-SPEC.md's approved aesthetic — parchment ground,
double frame, dock, panel tokens, no text below 22px, no alpha-muted ink — and add
one shared component per card: a **notice panel** (radius 20px, `--nq-p-card-panel`,
2px `--nq-p-panel-border`, `--nq-p-panel-shadow`, centred icon + headline + body).
The frame, wordmark row, kicker and dock always render; only the content area
changes. **No state renders a blank parchment.**
## 1. What the data can say
The cards read `sensor.nestquest_<child>_quests_due_today` (plus siblings). One of
four things comes back, and the card must tell them apart:
| Backend condition | What the card sees | Card's interpretation |
|---|---|---|
| Resolving (DB fine) | numeric state + attributes (`child_name`, `present`, `instances`) | normal, all states below |
| Integration not configured | entity does not exist at all | **not set up** |
| Backend unreachable (DB locked, refresh failed) | state `unavailable` | **records unreachable** |
| Child deactivated / absent from snapshot | state `unknown` | **records unreachable** (same panel voice: the records do not answer) |
An away child (presence `present: false`, from the due-sensor attributes with the
presence binary sensor as fallback) is *not* an error: it is a first-class panel
state ("On travels"). Away children get no instances (materialiser skips them), so
away is detected from the presence flag, never from a zero quest count.
## 2. Quest log — state priority
`nestquest-quest-log-card` resolves exactly one state, in this order:
1. **No adventurer in the path** (direct navigation to a URL with no child slug —
misconfigured `board_path`/`quest_log_path`) → notice *"No adventurer chosen"*,
body *"Open The Party and tap your crest to open your quest log."*
2. **Not set up** (entity missing) → notice *"NestQuest is not set up yet"*, body
*"A parent needs to finish setting up NestQuest."*
3. **Records unreachable** (`unavailable`/`unknown`) → notice *"The records cannot
be reached"*, body *"The party's records are quiet right now. NestQuest will
return shortly."*
4. **Away** (present flag false) → away notice: dashed muted panel (the away-plate
gradient), tent icon, *"<Name> is on travels"*, *"The quest log unlocks when
they return."* and, when `next_present` is known, *"Returns <weekday>, <Mon D>"*.
Direct navigation is the one path that can reach this state (the board's away
plate is not tappable); the log answers it honestly instead of showing an empty
column set. Header renders with the crest in away colours and the sub line
`… · On travels`; the remaining-count block is hidden (it is meaningless while
away).
5. **No quests today** (present, due = 0) → all-clear notice, sun icon, *"No quests
today"*, body *"Nothing was posted for today, <Name>. Enjoy the day's rest!"* —
never three bare empty columns. The header and remaining block stay (0 quests
left, party progress), so the log still reads like the log.
6. **All quests done** (due > 0, remaining = 0) → the fallback celebration that
shows until the 1c quest-complete screen takes over: centred wax-seal mark (the
`--nq-p-seal` tokens at 78px, check glyph, −6°) with headline *"Quest complete"*
— the same phrase screen 1c uses — and body *"Every quest is claimed, <Name>.
The seal is set for today. Returning to The Party shortly."* The idle return
(§3.5) is what actually navigates back; 1c remains the designed takeover.
7. **Normal** → the three-window column layout of PANEL-SPEC §3.1 unchanged. A
window with no quests still renders its head alone, exactly as the spec says.
Notices sit in the columns' content area, vertically centred. Error notices (states
1–3) replace the header entirely — there is no honest name to headline when the
records do not answer — while states 4–6 keep the full header.
## 3. Party board — state priority
`nestquest-party-board-card` resolves per configured child, then at board level:
1. **No children configured** (`child_order` empty) → board notice *"NestQuest is
not set up yet"*, body *"A parent needs to finish setting up NestQuest before
the party can gather."*
2. **Every configured child unresolvable** → one board notice instead of a grid of
broken plates: *"NestQuest is not set up yet"* when the entities are missing,
*"The records cannot be reached"* when they answer `unavailable`/`unknown`.
3. **Partial** (some children resolve, some do not) → the board still renders the
resolving plates; an unresolvable child renders a muted, dashed plate in the
away geometry with a compass icon and the pill label **"Unknown"**, progress
line `—`, empty bar, **not tappable**. It must not claim "Home today" (0 of 0)
and must not lie "On travels": the records simply do not say.
4. **Normal** → the plates grid of PANEL-SPEC §2 unchanged (present + away plates).
The hint line ("Tap your crest…") is hidden in board-level notice states — there
is nothing to tap.
## 4. Shared rules
- **Never a blank parchment.** Every render path emits the frames, wordmark,
kicker, dock, and either content or a notice panel. The dock already degrades to
date + time when weather is unset (PANEL-SPEC §2.3); that behaviour is unchanged.
- **Notice panel type scale:** icon 46px, headline 40px/700 Cinzel
(`--nq-p-font-heading`, `--nq-p-ink`), body 24px/600 Nunito
(`--nq-p-ink-secondary`), `role="status"` so assistive tech announces it.
- **Tone.** The panel speaks tabletop (PANEL-SPEC vocabulary) but never lies: an
error says the records cannot be reached, not that everything is fine. Error
copy names the actor who can fix it (a parent) or promises the return of the
data — no stack traces, no entity IDs.
- **No interaction in error/away states.** No confirm dialog, no idle return
changes, no navigation attempts. Notices are static.
- Both cards implement the same resolution rules, so the board and the log never
disagree about whether the integration is up.
+188 -6
View File
@@ -1,4 +1,4 @@
import { LitElement, css, html, nothing, unsafeCSS } from "lit";
import { LitElement, css, html, nothing, unsafeCSS, type TemplateResult } from "lit";
import panelTokens from "../../../custom_components/nestquest/www/nestquest-panel-tokens.css?inline";
import { registerCustomCard, type CardConfig, type HassLike } from "../types";
@@ -17,6 +17,14 @@ type ChildPlate = {
due: number;
pct: number;
returns: string | null;
/** Set when the child's sensors cannot resolve; the plate shows "Unknown". */
unresolved: "missing" | "stale" | null;
};
type BoardNotice = {
icon: TemplateResult;
headline: string;
body: string;
};
type DockWeather = {
@@ -476,6 +484,62 @@ const boardStyles = css`
color: var(--nq-p-ink-secondary);
}
.plate.unknown {
border: 2px dashed var(--nq-p-panel-border);
background: linear-gradient(#f2ecdd, #e6dcc6);
box-shadow: none;
}
.notice-wrap {
position: absolute;
top: 262px;
left: 110px;
right: 110px;
bottom: 190px;
display: flex;
align-items: center;
justify-content: center;
}
.notice {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 20px;
width: 100%;
max-width: 980px;
padding: 56px 64px;
border: 2px solid var(--nq-p-panel-border);
border-radius: var(--nq-p-radius-panel);
background: var(--nq-p-card-panel);
box-shadow: var(--nq-p-panel-shadow);
text-align: center;
}
.notice svg {
width: 46px;
height: 46px;
color: var(--nq-p-ink-secondary);
}
.notice-headline {
font-family: var(--nq-p-font-heading);
font-size: 40px;
font-weight: 700;
line-height: 1.15;
color: var(--nq-p-ink);
}
.notice-body {
margin: 0;
font-family: var(--nq-p-font-body);
font-size: 24px;
font-weight: 600;
line-height: 1.4;
color: var(--nq-p-ink-secondary);
}
.spacer {
flex: 1 1 0;
}
@@ -569,6 +633,58 @@ const ICON_TENT = html`<svg
<path d="M9 21l3-8 3 8"></path>
</svg>`;
const ICON_COMPASS = html`<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<circle cx="12" cy="12" r="10"></circle>
<polygon points="16.24 7.76 14.12 14.12 7.76 16.24 9.88 9.88 16.24 7.76"></polygon>
</svg>`;
const ICON_ALERT = html`<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"></path>
<path d="M12 9v4"></path>
<path d="M12 17h.01"></path>
</svg>`;
const ICON_CLOUD_OFF = html`<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z"></path>
<path d="m2 2 20 20"></path>
</svg>`;
const BOARD_NOTICE_NOT_SET_UP: BoardNotice = {
icon: ICON_ALERT,
headline: "NestQuest is not set up yet",
body: "A parent needs to finish setting up NestQuest before the party can gather.",
};
const BOARD_NOTICE_UNREACHABLE: BoardNotice = {
icon: ICON_CLOUD_OFF,
headline: "The records cannot be reached",
body: "The party's records are quiet right now. NestQuest will return shortly.",
};
const ICON_CLOUD_SUN = html`<svg
viewBox="0 0 24 24"
fill="none"
@@ -628,6 +744,7 @@ export class NestQuestPartyBoardCard extends LitElement {
}
render() {
const notice = this._boardNotice();
return html`
<div class="board">
<div class="frame frame-outer"></div>
@@ -644,10 +761,14 @@ export class NestQuestPartyBoardCard extends LitElement {
<span class="rule"></span>
</div>
<p class="kicker">The Party · ${formatDay(this._now, this._timeZone())}</p>
<div class="plates">
${this._plates().map((plate) => this._renderPlate(plate))}
</div>
<p class="hint">Tap your crest to open your Quest Log</p>
${notice
? this._renderNotice(notice)
: html`
<div class="plates">
${this._plates().map((plate) => this._renderPlate(plate))}
</div>
<p class="hint">Tap your crest to open your Quest Log</p>
`}
<div class="spacer"></div>
${this._renderDock()}
</div>
@@ -687,6 +808,7 @@ export class NestQuestPartyBoardCard extends LitElement {
}
private _plate(slug: string): ChildPlate {
const unresolved = this._dueSensorResolution(slug);
const dueSensor = this._state(`sensor.nestquest_${slug}_quests_due_today`);
const doneSensor = this._state(
`sensor.nestquest_${slug}_quests_completed_today`
@@ -744,15 +866,75 @@ export class NestQuestPartyBoardCard extends LitElement {
slug,
name,
initial: (name.charAt(0) || "?").toUpperCase(),
present,
present: unresolved === null ? present : false,
completed,
due,
pct,
returns,
unresolved,
};
}
/** "missing" when the due sensor does not exist (integration not
* configured), "stale" when it answers unavailable/unknown (backend
* unreachable or the child is absent from the snapshot), null when
* it resolves. */
private _dueSensorResolution(slug: string): "missing" | "stale" | null {
const sensor = this._state(`sensor.nestquest_${slug}_quests_due_today`);
if (!sensor) {
return "missing";
}
const state = String(sensor.state ?? "").trim().toLowerCase();
if (!state || state === "unavailable" || state === "unknown") {
return "stale";
}
return null;
}
private _boardNotice(): BoardNotice | null {
const plates = this._plates();
if (plates.length === 0) {
return BOARD_NOTICE_NOT_SET_UP;
}
if (plates.every((plate) => plate.unresolved !== null)) {
return plates.some((plate) => plate.unresolved === "stale")
? BOARD_NOTICE_UNREACHABLE
: BOARD_NOTICE_NOT_SET_UP;
}
return null;
}
private _renderNotice(notice: BoardNotice) {
return html`
<div class="notice-wrap">
<div class="notice" role="status">
${notice.icon}
<span class="notice-headline">${notice.headline}</span>
<p class="notice-body">${notice.body}</p>
</div>
</div>
`;
}
private _renderPlate(plate: ChildPlate) {
if (plate.unresolved) {
return html`
<div class="plate unknown">
<span class="crest away">
<span class="crest-face">
<span class="initial">${plate.initial}</span>
</span>
</span>
<span class="name">${plate.name}</span>
<span class="pill away">
${ICON_COMPASS}
<span>Unknown</span>
</span>
<span class="progress-line">&mdash;</span>
<span class="bar"></span>
</div>
`;
}
if (plate.present) {
return html`
<button
+341 -25
View File
@@ -36,6 +36,23 @@ type OtherChild = {
returnsWeekday: string | null;
};
/** The one state the log resolves before rendering anything; see
* design/PANEL-EMPTY-STATES.md for the decisions behind each kind. */
type LogView =
| { kind: "no-adventurer" }
| { kind: "not-set-up" }
| { kind: "unreachable" }
| { kind: "away"; name: string; returns: string | null }
| { kind: "empty-day"; name: string }
| { kind: "complete-day"; name: string }
| { kind: "normal" };
type LogNotice = {
icon: TemplateResult;
headline: string;
body: string;
};
const FORMATTER_CACHE = new Map<string, Intl.DateTimeFormat>();
function zonedFormatter(
@@ -299,6 +316,10 @@ const logStyles = css`
color: #ffffff;
}
.crest.away .crest-face {
background: var(--nq-p-crest-away);
}
.titles {
display: flex;
flex-direction: column;
@@ -646,6 +667,81 @@ const logStyles = css`
color: var(--nq-p-ink-secondary);
}
.notice-wrap {
position: absolute;
top: 236px;
left: var(--nq-p-page-inset);
right: var(--nq-p-page-inset);
bottom: 130px;
display: flex;
align-items: center;
justify-content: center;
}
.notice {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 20px;
width: 100%;
max-width: 980px;
padding: 56px 64px;
border: 2px solid var(--nq-p-panel-border);
border-radius: 20px;
background: var(--nq-p-card-panel);
box-shadow: var(--nq-p-panel-shadow);
text-align: center;
}
.notice svg {
width: 46px;
height: 46px;
color: var(--nq-p-ink-secondary);
}
.notice.away {
background: linear-gradient(#f2ecdd, #e6dcc6);
border-style: dashed;
box-shadow: none;
}
.notice-headline {
font-family: var(--nq-p-font-heading);
font-size: 40px;
font-weight: 700;
line-height: 1.15;
color: var(--nq-p-ink);
}
.notice-body {
margin: 0;
font-family: var(--nq-p-font-body);
font-size: 24px;
font-weight: 600;
line-height: 1.4;
color: var(--nq-p-ink-secondary);
}
.notice-seal {
display: inline-flex;
align-items: center;
justify-content: center;
width: 78px;
height: 78px;
border-radius: var(--nq-p-radius-pill);
background: var(--nq-p-seal);
box-shadow: var(--nq-p-seal-shadow);
color: rgba(255, 235, 235, 0.95);
transform: rotate(-6deg);
}
.notice-seal svg {
width: 38px;
height: 38px;
color: rgba(255, 235, 235, 0.95);
}
.scrim {
position: absolute;
inset: 0;
@@ -877,6 +973,77 @@ const ICON_MOON = html`<svg
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"></path>
</svg>`;
const ICON_TENT = html`<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M3.5 21 12 3.5 20.5 21"></path>
<path d="M9 21l3-8 3 8"></path>
</svg>`;
const ICON_COMPASS = html`<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<circle cx="12" cy="12" r="10"></circle>
<polygon points="16.24 7.76 14.12 14.12 7.76 16.24 9.88 9.88 16.24 7.76"></polygon>
</svg>`;
const ICON_ALERT = html`<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"></path>
<path d="M12 9v4"></path>
<path d="M12 17h.01"></path>
</svg>`;
const ICON_CLOUD_OFF = html`<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z"></path>
<path d="m2 2 20 20"></path>
</svg>`;
const LOG_NOTICE_NO_ADVENTURER: LogNotice = {
icon: ICON_COMPASS,
headline: "No adventurer chosen",
body: "Open The Party and tap your crest to open your quest log.",
};
const LOG_NOTICE_NOT_SET_UP: LogNotice = {
icon: ICON_ALERT,
headline: "NestQuest is not set up yet",
body: "A parent needs to finish setting up NestQuest.",
};
const LOG_NOTICE_UNREACHABLE: LogNotice = {
icon: ICON_CLOUD_OFF,
headline: "The records cannot be reached",
body: "The party's records are quiet right now. NestQuest will return shortly.",
};
const WINDOW_DEFS: WindowDef[] = [
{ key: "morning", name: "Morning", range: "Until 11:59 AM", icon: ICON_SUNRISE },
{ key: "afternoon", name: "Afternoon", range: "12:00–5:00 PM", icon: ICON_SUN },
@@ -935,19 +1102,162 @@ export class NestQuestQuestLogCard extends LitElement {
}
render() {
const view = this._logView();
return html`
<div class="board">
<div class="frame frame-outer"></div>
<div class="frame frame-inner"></div>
${this._renderHeader()}
<div class="columns">
${WINDOW_DEFS.map((windowDef) => this._renderColumn(windowDef))}
</div>
${this._renderMain(view)}
${this._renderConfirm()}
</div>
`;
}
private _renderMain(view: LogView): TemplateResult {
switch (view.kind) {
case "no-adventurer":
return this._renderNotice(LOG_NOTICE_NO_ADVENTURER);
case "not-set-up":
return this._renderNotice(LOG_NOTICE_NOT_SET_UP);
case "unreachable":
return this._renderNotice(LOG_NOTICE_UNREACHABLE);
case "away": {
const body = ["The quest log unlocks when they return."];
if (view.returns) {
body.push(`Returns ${view.returns}`);
}
return html`
${this._renderHeader(view)}
<div class="notice-wrap">
<div class="notice away" role="status">
${ICON_TENT}
<span class="notice-headline">${view.name} is on travels</span>
${body.map((line) => html`<p class="notice-body">${line}</p>`)}
</div>
</div>
`;
}
case "empty-day":
return html`
${this._renderHeader(view)}
<div class="notice-wrap">
<div class="notice" role="status">
${ICON_SUN}
<span class="notice-headline">No quests today</span>
<p class="notice-body">
${`Nothing was posted for today, ${view.name}. Enjoy the day's rest!`}
</p>
</div>
</div>
`;
case "complete-day":
return html`
${this._renderHeader(view)}
<div class="notice-wrap">
<div class="notice" role="status">
<span class="notice-seal">${ICON_CHECK}</span>
<span class="notice-headline">Quest complete</span>
<p class="notice-body">
${`Every quest is claimed, ${view.name}. The seal is set for today.`}
</p>
<p class="notice-body">Returning to The Party shortly.</p>
</div>
</div>
`;
default:
return html`
${this._renderHeader(view)}
<div class="columns">
${WINDOW_DEFS.map((windowDef) => this._renderColumn(windowDef))}
</div>
`;
}
}
/** Resolve the log's single render state; the priority order is
* design/PANEL-EMPTY-STATES.md §2. */
private _logView(): LogView {
const slug = this._childSlug();
if (!slug) {
return { kind: "no-adventurer" };
}
const resolution = this._dueSensorResolution(slug);
if (resolution === "missing") {
return { kind: "not-set-up" };
}
if (resolution === "stale") {
return { kind: "unreachable" };
}
const name = this._childName(slug) ?? titleCaseSlug(slug);
if (!this._childPresent(slug)) {
return { kind: "away", name, returns: this._awayReturns(slug) };
}
const dueSensor = this._state(`sensor.nestquest_${slug}_quests_due_today`);
const due = clampCount(asNumber(dueSensor?.state, 0));
if (due === 0) {
return { kind: "empty-day", name };
}
if (this._remaining() === 0) {
return { kind: "complete-day", name };
}
return { kind: "normal" };
}
/** "missing" when the due sensor does not exist (integration not
* configured), "stale" when it answers unavailable/unknown (backend
* unreachable or the child is absent from the snapshot), null when
* it resolves. */
private _dueSensorResolution(slug: string): "missing" | "stale" | null {
const sensor = this._state(`sensor.nestquest_${slug}_quests_due_today`);
if (!sensor) {
return "missing";
}
const state = String(sensor.state ?? "").trim().toLowerCase();
if (!state || state === "unavailable" || state === "unknown") {
return "stale";
}
return null;
}
private _childPresent(slug: string): boolean {
const dueSensor = this._state(`sensor.nestquest_${slug}_quests_due_today`);
const presentAttr = dueSensor?.attributes?.present;
if (typeof presentAttr === "boolean") {
return presentAttr;
}
const presence = this._state(
`binary_sensor.nestquest_${slug}_present_today`
);
if (presence) {
return String(presence.state ?? "").trim().toLowerCase() === "on";
}
return true;
}
private _awayReturns(slug: string): string | null {
const timeZone = this._timeZone();
const presence = this._state(
`binary_sensor.nestquest_${slug}_present_today`
);
const nextPresent = parseIsoDate(
presence?.attributes?.next_present,
timeZone
);
return nextPresent ? formatDay(nextPresent, timeZone) : null;
}
private _renderNotice(notice: LogNotice): TemplateResult {
return html`
<div class="notice-wrap">
<div class="notice" role="status">
${notice.icon}
<span class="notice-headline">${notice.headline}</span>
<p class="notice-body">${notice.body}</p>
</div>
</div>
`;
}
private _timeZone(): string | undefined {
return hassTimeZone(this.hass);
}
@@ -1110,18 +1420,20 @@ export class NestQuestQuestLogCard extends LitElement {
return result.sort((a, b) => a.name.localeCompare(b.name));
}
private _renderHeader() {
private _renderHeader(view: LogView) {
const slug = this._childSlug();
const name = this._childName(slug);
const displayName = name ?? (slug ? titleCaseSlug(slug) : null);
const away = view.kind === "away";
const name = away
? view.name
: (this._childName(slug) ?? (slug ? titleCaseSlug(slug) : null));
const { due, completed } = this._childCounts(slug);
const remaining = this._remaining();
const party = this._partyCounts();
const title = displayName ? `${displayName}'s Quest Log` : "Quest Log";
const initial = (displayName?.charAt(0) || "?").toUpperCase();
const title = name ? `${name}'s Quest Log` : "Quest Log";
const initial = (name?.charAt(0) || "?").toUpperCase();
return html`
<header class="header">
<span class="crest" aria-hidden="true">
<span class="crest${away ? " away" : nothing}" aria-hidden="true">
<span class="crest-face">
<span class="initial">${initial}</span>
</span>
@@ -1129,23 +1441,27 @@ export class NestQuestQuestLogCard extends LitElement {
<div class="titles">
<h1 class="title">${title}</h1>
<p class="sub">
${formatDay(this._now, this._timeZone())} · ${completed} of ${due}
claimed
${formatDay(this._now, this._timeZone())} ·
${away ? "On travels" : `${completed} of ${due} claimed`}
</p>
</div>
<div class="remaining">
<span class="d20" aria-hidden="true">
<span class="d20-numeral">${remaining}</span>
</span>
<div class="remaining-text">
<span class="remaining-count">
${remaining} quest${remaining === 1 ? "" : "s"} left
</span>
<span class="party-line">
Party progress · ${party.completed} of ${party.due} today
</span>
</div>
</div>
${away
? nothing
: html`
<div class="remaining">
<span class="d20" aria-hidden="true">
<span class="d20-numeral">${remaining}</span>
</span>
<div class="remaining-text">
<span class="remaining-count">
${remaining} quest${remaining === 1 ? "" : "s"} left
</span>
<span class="party-line">
Party progress · ${party.completed} of ${party.due} today
</span>
</div>
</div>
`}
</header>
`;
}