Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72966bf9ae |
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 panelTokens from "../../../custom_components/nestquest/www/nestquest-panel-tokens.css?inline";
|
||||||
|
|
||||||
import { registerCustomCard, type CardConfig, type HassLike } from "../types";
|
import { registerCustomCard, type CardConfig, type HassLike } from "../types";
|
||||||
@@ -17,6 +17,14 @@ type ChildPlate = {
|
|||||||
due: number;
|
due: number;
|
||||||
pct: number;
|
pct: number;
|
||||||
returns: string | null;
|
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 = {
|
type DockWeather = {
|
||||||
@@ -476,6 +484,62 @@ const boardStyles = css`
|
|||||||
color: var(--nq-p-ink-secondary);
|
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 {
|
.spacer {
|
||||||
flex: 1 1 0;
|
flex: 1 1 0;
|
||||||
}
|
}
|
||||||
@@ -569,6 +633,58 @@ const ICON_TENT = html`<svg
|
|||||||
<path d="M9 21l3-8 3 8"></path>
|
<path d="M9 21l3-8 3 8"></path>
|
||||||
</svg>`;
|
</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
|
const ICON_CLOUD_SUN = html`<svg
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
@@ -628,6 +744,7 @@ export class NestQuestPartyBoardCard extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
|
const notice = this._boardNotice();
|
||||||
return html`
|
return html`
|
||||||
<div class="board">
|
<div class="board">
|
||||||
<div class="frame frame-outer"></div>
|
<div class="frame frame-outer"></div>
|
||||||
@@ -644,10 +761,14 @@ export class NestQuestPartyBoardCard extends LitElement {
|
|||||||
<span class="rule"></span>
|
<span class="rule"></span>
|
||||||
</div>
|
</div>
|
||||||
<p class="kicker">The Party · ${formatDay(this._now, this._timeZone())}</p>
|
<p class="kicker">The Party · ${formatDay(this._now, this._timeZone())}</p>
|
||||||
<div class="plates">
|
${notice
|
||||||
${this._plates().map((plate) => this._renderPlate(plate))}
|
? this._renderNotice(notice)
|
||||||
</div>
|
: html`
|
||||||
<p class="hint">Tap your crest to open your Quest Log</p>
|
<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>
|
<div class="spacer"></div>
|
||||||
${this._renderDock()}
|
${this._renderDock()}
|
||||||
</div>
|
</div>
|
||||||
@@ -687,6 +808,7 @@ export class NestQuestPartyBoardCard extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private _plate(slug: string): ChildPlate {
|
private _plate(slug: string): ChildPlate {
|
||||||
|
const unresolved = this._dueSensorResolution(slug);
|
||||||
const dueSensor = this._state(`sensor.nestquest_${slug}_quests_due_today`);
|
const dueSensor = this._state(`sensor.nestquest_${slug}_quests_due_today`);
|
||||||
const doneSensor = this._state(
|
const doneSensor = this._state(
|
||||||
`sensor.nestquest_${slug}_quests_completed_today`
|
`sensor.nestquest_${slug}_quests_completed_today`
|
||||||
@@ -744,15 +866,75 @@ export class NestQuestPartyBoardCard extends LitElement {
|
|||||||
slug,
|
slug,
|
||||||
name,
|
name,
|
||||||
initial: (name.charAt(0) || "?").toUpperCase(),
|
initial: (name.charAt(0) || "?").toUpperCase(),
|
||||||
present,
|
present: unresolved === null ? present : false,
|
||||||
completed,
|
completed,
|
||||||
due,
|
due,
|
||||||
pct,
|
pct,
|
||||||
returns,
|
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) {
|
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) {
|
if (plate.present) {
|
||||||
return html`
|
return html`
|
||||||
<button
|
<button
|
||||||
|
|||||||
+341
-25
@@ -36,6 +36,23 @@ type OtherChild = {
|
|||||||
returnsWeekday: string | null;
|
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>();
|
const FORMATTER_CACHE = new Map<string, Intl.DateTimeFormat>();
|
||||||
|
|
||||||
function zonedFormatter(
|
function zonedFormatter(
|
||||||
@@ -299,6 +316,10 @@ const logStyles = css`
|
|||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.crest.away .crest-face {
|
||||||
|
background: var(--nq-p-crest-away);
|
||||||
|
}
|
||||||
|
|
||||||
.titles {
|
.titles {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -646,6 +667,81 @@ const logStyles = css`
|
|||||||
color: var(--nq-p-ink-secondary);
|
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 {
|
.scrim {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
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>
|
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"></path>
|
||||||
</svg>`;
|
</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[] = [
|
const WINDOW_DEFS: WindowDef[] = [
|
||||||
{ key: "morning", name: "Morning", range: "Until 11:59 AM", icon: ICON_SUNRISE },
|
{ 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 },
|
{ key: "afternoon", name: "Afternoon", range: "12:00–5:00 PM", icon: ICON_SUN },
|
||||||
@@ -935,19 +1102,162 @@ export class NestQuestQuestLogCard extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
|
const view = this._logView();
|
||||||
return html`
|
return html`
|
||||||
<div class="board">
|
<div class="board">
|
||||||
<div class="frame frame-outer"></div>
|
<div class="frame frame-outer"></div>
|
||||||
<div class="frame frame-inner"></div>
|
<div class="frame frame-inner"></div>
|
||||||
${this._renderHeader()}
|
${this._renderMain(view)}
|
||||||
<div class="columns">
|
|
||||||
${WINDOW_DEFS.map((windowDef) => this._renderColumn(windowDef))}
|
|
||||||
</div>
|
|
||||||
${this._renderConfirm()}
|
${this._renderConfirm()}
|
||||||
</div>
|
</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 {
|
private _timeZone(): string | undefined {
|
||||||
return hassTimeZone(this.hass);
|
return hassTimeZone(this.hass);
|
||||||
}
|
}
|
||||||
@@ -1110,18 +1420,20 @@ export class NestQuestQuestLogCard extends LitElement {
|
|||||||
return result.sort((a, b) => a.name.localeCompare(b.name));
|
return result.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
}
|
}
|
||||||
|
|
||||||
private _renderHeader() {
|
private _renderHeader(view: LogView) {
|
||||||
const slug = this._childSlug();
|
const slug = this._childSlug();
|
||||||
const name = this._childName(slug);
|
const away = view.kind === "away";
|
||||||
const displayName = name ?? (slug ? titleCaseSlug(slug) : null);
|
const name = away
|
||||||
|
? view.name
|
||||||
|
: (this._childName(slug) ?? (slug ? titleCaseSlug(slug) : null));
|
||||||
const { due, completed } = this._childCounts(slug);
|
const { due, completed } = this._childCounts(slug);
|
||||||
const remaining = this._remaining();
|
const remaining = this._remaining();
|
||||||
const party = this._partyCounts();
|
const party = this._partyCounts();
|
||||||
const title = displayName ? `${displayName}'s Quest Log` : "Quest Log";
|
const title = name ? `${name}'s Quest Log` : "Quest Log";
|
||||||
const initial = (displayName?.charAt(0) || "?").toUpperCase();
|
const initial = (name?.charAt(0) || "?").toUpperCase();
|
||||||
return html`
|
return html`
|
||||||
<header class="header">
|
<header class="header">
|
||||||
<span class="crest" aria-hidden="true">
|
<span class="crest${away ? " away" : nothing}" aria-hidden="true">
|
||||||
<span class="crest-face">
|
<span class="crest-face">
|
||||||
<span class="initial">${initial}</span>
|
<span class="initial">${initial}</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -1129,23 +1441,27 @@ export class NestQuestQuestLogCard extends LitElement {
|
|||||||
<div class="titles">
|
<div class="titles">
|
||||||
<h1 class="title">${title}</h1>
|
<h1 class="title">${title}</h1>
|
||||||
<p class="sub">
|
<p class="sub">
|
||||||
${formatDay(this._now, this._timeZone())} · ${completed} of ${due}
|
${formatDay(this._now, this._timeZone())} ·
|
||||||
claimed
|
${away ? "On travels" : `${completed} of ${due} claimed`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="remaining">
|
${away
|
||||||
<span class="d20" aria-hidden="true">
|
? nothing
|
||||||
<span class="d20-numeral">${remaining}</span>
|
: html`
|
||||||
</span>
|
<div class="remaining">
|
||||||
<div class="remaining-text">
|
<span class="d20" aria-hidden="true">
|
||||||
<span class="remaining-count">
|
<span class="d20-numeral">${remaining}</span>
|
||||||
${remaining} quest${remaining === 1 ? "" : "s"} left
|
</span>
|
||||||
</span>
|
<div class="remaining-text">
|
||||||
<span class="party-line">
|
<span class="remaining-count">
|
||||||
Party progress · ${party.completed} of ${party.due} today
|
${remaining} quest${remaining === 1 ? "" : "s"} left
|
||||||
</span>
|
</span>
|
||||||
</div>
|
<span class="party-line">
|
||||||
</div>
|
Party progress · ${party.completed} of ${party.due} today
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`}
|
||||||
</header>
|
</header>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user