Define and implement the panel's empty and error states
Define and implement the panel's empty and error states
This commit was merged in pull request #127.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
@@ -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">—</span>
|
||||
<span class="bar"></span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
if (plate.present) {
|
||||
return html`
|
||||
<button
|
||||
|
||||
+341
-25
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user