Skip to content

Commit 1b3e1ab

Browse files
committed
Add handling for limitation, deferred, and follow-on items
1 parent 75def1b commit 1b3e1ab

28 files changed

Lines changed: 3116 additions & 1847 deletions

src/StoreProvider.tsx

Lines changed: 9 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,8 @@
11
import { type Dispatch, type ReactNode, useCallback, useEffect, useReducer, useRef, useState } from "react";
22

3+
import { migrateAppState } from "./migrations";
34
import { loadPersisted, savePersisted } from "./persistence";
4-
import {
5-
type Action,
6-
type AppState,
7-
DispatchContext,
8-
init,
9-
reducer,
10-
type SaveStatus,
11-
StateContext,
12-
StatusContext,
13-
} from "./state";
5+
import { type Action, DispatchContext, init, reducer, type SaveStatus, StateContext, StatusContext } from "./state";
146

157
export function StoreProvider({ children }: { children: ReactNode }) {
168
const [state, dispatch] = useReducer(reducer, undefined, init);
@@ -24,13 +16,16 @@ export function StoreProvider({ children }: { children: ReactNode }) {
2416
dispatch(action);
2517
}, []);
2618

27-
// Hydrate from IndexedDB once on mount.
19+
// Hydrate from IndexedDB once on mount. The persisted value isn't schema-
20+
// validated, so it's run through migrateAppState to normalize older shapes
21+
// (e.g. pre-sections data) instead of discarding them.
2822
useEffect(() => {
2923
let cancelled = false;
30-
loadPersisted<AppState>().then((saved) => {
24+
loadPersisted<unknown>().then((saved) => {
3125
if (cancelled) return;
32-
if (saved && Array.isArray(saved.databases)) {
33-
dispatch({ type: "hydrate", state: saved });
26+
const migrated = saved ? migrateAppState(saved) : null;
27+
if (migrated) {
28+
dispatch({ type: "hydrate", state: migrated });
3429
}
3530
loaded.current = true;
3631
});

src/checklist.ts

Lines changed: 32 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,21 @@ export interface ActionItem {
66
challenge: string;
77
response?: string;
88
extension?: string;
9+
/** Flags this action as a limitation. */
10+
limitation?: boolean;
11+
/** Id of another checklist this item defers to. */
12+
defer?: string;
13+
/** Id of another checklist to follow on to after this item. */
14+
followOn?: string;
915
}
1016

1117
export interface SensedItem {
1218
type: "sensed";
1319
id: string;
1420
challenge: string;
1521
response?: string;
16-
sensed: string;
22+
/** Numeric ECL_VARIABLE index (see lib/vars.ts), not the variable name. */
23+
sensed?: number;
1724
inverted?: boolean;
1825
latchable?: boolean;
1926
}
@@ -50,37 +57,47 @@ export type ChecklistItem = ActionItem | SensedItem | ConditionalItem | MultiSel
5057

5158
export type ItemType = ChecklistItem["type"];
5259

53-
export type Category = "normal" | "non-normal" | "procedure";
60+
export type Category = "normal" | "non_normal" | "procedure";
5461

55-
export interface SectionHeader {
56-
kind: "header";
57-
id: string;
58-
name: string;
59-
}
62+
export type Phase = "pre-flight" | "in-flight" | "post-flight";
6063

6164
export interface Checklist {
6265
kind?: "checklist";
6366
id: string;
6467
name: string;
65-
category: Category;
6668
items: ChecklistItem[];
69+
/** Non-normal only. */
6770
cas?: CasMessage;
71+
/** Normal only. */
72+
phase?: Phase;
6873
}
6974

70-
export type CategoryEntry = Checklist | SectionHeader;
71-
72-
export interface ChecklistDatabase {
75+
/** A named group of checklists shown together in the sidebar (non_normal, by system; procedure, by phase/task). */
76+
export interface Section {
77+
kind: "section";
7378
id: string;
7479
name: string;
75-
categories: Record<Category, CategoryEntry[]>;
80+
checklists: Checklist[];
7681
}
7782

78-
export function isHeader(entry: CategoryEntry): entry is SectionHeader {
79-
return (entry as SectionHeader).kind === "header";
83+
export interface ChecklistDatabase {
84+
id: string;
85+
name: string;
86+
categories: {
87+
normal: Checklist[];
88+
non_normal: Section[];
89+
procedure: Section[];
90+
};
8091
}
8192

8293
export const CATEGORY_LABELS: Record<Category, string> = {
8394
normal: "Normal",
84-
"non-normal": "Non-Normal",
95+
non_normal: "Non-Normal",
8596
procedure: "Procedure",
8697
};
98+
99+
export const PHASE_LABELS: Record<Phase, string> = {
100+
"pre-flight": "Pre-Flight",
101+
"in-flight": "In-Flight",
102+
"post-flight": "Post-Flight",
103+
};

src/components/TopBar.css

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@
3333
}
3434
.topbar .file {
3535
color: var(--fg-2);
36+
min-width: 0;
37+
overflow: hidden;
38+
white-space: nowrap;
39+
text-overflow: ellipsis;
3640
}
3741
.topbar .file .slash {
3842
color: var(--fg-4);
@@ -63,6 +67,41 @@
6367
opacity: 0.4;
6468
pointer-events: none;
6569
}
70+
.export-ctl {
71+
position: relative;
72+
}
73+
.export-menu {
74+
position: absolute;
75+
top: calc(100% + 4px);
76+
right: 0;
77+
min-width: 160px;
78+
display: flex;
79+
flex-direction: column;
80+
gap: 2px;
81+
padding: 4px;
82+
border: 1px solid var(--bd-2);
83+
border-radius: 6px;
84+
background: var(--bg-2);
85+
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
86+
z-index: 10;
87+
}
88+
.export-menu button {
89+
display: flex;
90+
justify-content: flex-start;
91+
white-space: nowrap;
92+
padding: 5px 8px;
93+
border-radius: 4px;
94+
font-size: 12.5px;
95+
color: var(--fg-2);
96+
}
97+
.export-menu button:hover {
98+
background: var(--bg-3);
99+
color: var(--fg-1);
100+
}
101+
.export-menu button:disabled {
102+
opacity: 0.4;
103+
pointer-events: none;
104+
}
66105
.topbar .pill {
67106
display: inline-flex;
68107
align-items: center;

src/components/TopBar.tsx

Lines changed: 70 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,54 @@
1+
import { useEffect, useRef, useState } from "react";
12
import "./TopBar.css";
23

3-
import { CATEGORY_LABELS, isHeader } from "../checklist";
4+
import { CATEGORY_LABELS } from "../checklist";
45
import { Icon } from "../icons";
5-
import { serializeChecklist } from "../schemas";
6+
import { nameResolverFor, serializeChecklist, serializePackage } from "../schemas";
67
import { useSaveStatus, useSelectedChecklist } from "../state";
78

8-
function sectionFor(db: ReturnType<typeof useSelectedChecklist>["db"], checklistId: string): string | null {
9-
if (!db) return null;
10-
for (const cat of Object.values(db.categories)) {
11-
let section: string | null = null;
12-
for (const entry of cat) {
13-
if (isHeader(entry)) {
14-
section = entry.name;
15-
} else if (entry.id === checklistId) {
16-
return section;
17-
}
18-
}
19-
}
20-
return null;
9+
function downloadBlob(text: string, filename: string) {
10+
const blob = new Blob([text], { type: "application/json" });
11+
const url = URL.createObjectURL(blob);
12+
const a = document.createElement("a");
13+
a.href = url;
14+
a.download = filename;
15+
a.click();
16+
URL.revokeObjectURL(url);
2117
}
2218

2319
export function TopBar() {
24-
const { checklist, db } = useSelectedChecklist();
20+
const { checklist, db, category, section } = useSelectedChecklist();
2521
const status = useSaveStatus();
22+
const [exportOpen, setExportOpen] = useState(false);
23+
const exportMenuRef = useRef<HTMLDivElement>(null);
24+
25+
useEffect(() => {
26+
if (!exportOpen) return;
27+
function onDown(e: PointerEvent) {
28+
if (exportMenuRef.current && !exportMenuRef.current.contains(e.target as Node)) setExportOpen(false);
29+
}
30+
function onKey(e: KeyboardEvent) {
31+
if (e.key === "Escape") setExportOpen(false);
32+
}
33+
window.addEventListener("pointerdown", onDown);
34+
window.addEventListener("keydown", onKey);
35+
return () => {
36+
window.removeEventListener("pointerdown", onDown);
37+
window.removeEventListener("keydown", onKey);
38+
};
39+
}, [exportOpen]);
2640

27-
const section = checklist ? sectionFor(db, checklist.id) : null;
41+
function handleExportPackage() {
42+
if (!db) return;
43+
downloadBlob(serializePackage(db), `${db.name.replace(/\s+/g, "_").toLowerCase()}.json`);
44+
}
2845

29-
function handleExport() {
30-
if (!checklist) return;
31-
const blob = new Blob([serializeChecklist(checklist)], { type: "application/json" });
32-
const url = URL.createObjectURL(blob);
33-
const a = document.createElement("a");
34-
a.href = url;
35-
a.download = `${checklist.name.replace(/\s+/g, "_").toLowerCase()}.json`;
36-
a.click();
37-
URL.revokeObjectURL(url);
46+
function handleExportChecklist() {
47+
if (!checklist || !db) return;
48+
downloadBlob(
49+
serializeChecklist(checklist, nameResolverFor(db)),
50+
`${checklist.name.replace(/\s+/g, "_").toLowerCase()}.json`,
51+
);
3852
}
3953

4054
return (
@@ -47,14 +61,14 @@ export function TopBar() {
4761
<span className="sep" />
4862
<span className="file mono">
4963
<span>{db ? db.name : "—"}</span>
50-
{checklist && (
64+
{checklist && category && (
5165
<>
5266
<span className="slash">/</span>
53-
<span>{CATEGORY_LABELS[checklist.category]}</span>
67+
<span>{CATEGORY_LABELS[category]}</span>
5468
{section && (
5569
<>
5670
<span className="slash">/</span>
57-
<span>{section}</span>
71+
<span>{section.name}</span>
5872
</>
5973
)}
6074
<span className="slash">/</span>
@@ -67,10 +81,33 @@ export function TopBar() {
6781
{status === "saving" ? "saving changes" : "changes automatically saved"}
6882
</span>
6983
<div className="spacer" />
70-
<button className="btn-export" onClick={handleExport} disabled={!checklist}>
71-
<Icon name="download" size={11} />
72-
Export
73-
</button>
84+
<div className={`export-ctl${exportOpen ? " open" : ""}`} ref={exportMenuRef}>
85+
<button className="btn-export" onClick={() => setExportOpen((o) => !o)} disabled={!db}>
86+
<Icon name="download" size={11} />
87+
Export
88+
</button>
89+
{exportOpen && (
90+
<div className="export-menu mono">
91+
<button
92+
onClick={() => {
93+
setExportOpen(false);
94+
handleExportPackage();
95+
}}
96+
>
97+
Export package…
98+
</button>
99+
<button
100+
disabled={!checklist}
101+
onClick={() => {
102+
setExportOpen(false);
103+
handleExportChecklist();
104+
}}
105+
>
106+
Export checklist…
107+
</button>
108+
</div>
109+
)}
110+
</div>
74111
</header>
75112
);
76113
}

src/components/common/CasCombobox.tsx

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { useEffect, useLayoutEffect, useRef, useState } from "react";
22
import { createPortal } from "react-dom";
33

4-
import { CAS_MESSAGE_OPTIONS, casMessageLabel, MESSAGE_LEVEL_COLOR } from "../../lib/cas";
5-
import type { CasMessage } from "../../lib/cas";
64
import { Icon } from "../../icons";
5+
import type { CasMessage } from "../../lib/cas";
6+
import { CAS_MESSAGE_OPTIONS, casMessageLabel, MESSAGE_LEVEL_COLOR } from "../../lib/cas";
77
import "./Combobox.css";
88

99
interface Props {
@@ -27,9 +27,7 @@ export function CasCombobox({ value, onChange, placeholder = "select CAS message
2727
const listRef = useRef<HTMLUListElement>(null);
2828

2929
const q = query.trim().toLowerCase();
30-
const filtered = q
31-
? CAS_MESSAGE_OPTIONS.filter((o) => o.label.toLowerCase().includes(q))
32-
: CAS_MESSAGE_OPTIONS;
30+
const filtered = q ? CAS_MESSAGE_OPTIONS.filter((o) => o.label.toLowerCase().includes(q)) : CAS_MESSAGE_OPTIONS;
3331

3432
function place() {
3533
const el = wrapRef.current;
@@ -96,7 +94,10 @@ export function CasCombobox({ value, onChange, placeholder = "select CAS message
9694
}
9795

9896
const currentLabel = value !== undefined ? casMessageLabel(value) : "";
99-
const currentColor = value !== undefined ? MESSAGE_LEVEL_COLOR[CAS_MESSAGE_OPTIONS.find((o) => o.message === value)!.level] : undefined;
97+
const currentColor =
98+
value !== undefined
99+
? MESSAGE_LEVEL_COLOR[CAS_MESSAGE_OPTIONS.find((o) => o.message === value)!.level]
100+
: undefined;
100101

101102
return (
102103
<div className="cbx" ref={wrapRef}>

src/components/common/EditableText.css

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,3 +45,40 @@
4545
flex: 1;
4646
width: 100%;
4747
}
48+
49+
/* width-constrained field with explicit-only line breaks (Shift+Enter) — each
50+
line stays put and is clipped rather than wrapping to fill the width */
51+
.edit.pre {
52+
display: block;
53+
width: 100%;
54+
white-space: pre;
55+
overflow: hidden;
56+
resize: none;
57+
line-height: 1.5;
58+
}
59+
60+
/* clamped read-only preview (multiline fields with clampLines) — shows up to
61+
N explicit lines, each individually ellipsized if it overflows the width;
62+
swaps to the real textarea when clicked/focused */
63+
.edit-clamp {
64+
border: 1px solid transparent;
65+
border-radius: 3px;
66+
padding: 1px 4px;
67+
margin: -1px -4px;
68+
font: inherit;
69+
line-height: 1.5;
70+
color: inherit;
71+
cursor: text;
72+
}
73+
.edit-clamp:hover {
74+
background: rgba(255, 255, 255, 0.03);
75+
box-shadow: 0 0 0 1px var(--bd-2);
76+
}
77+
.edit-clamp-line {
78+
overflow: hidden;
79+
white-space: nowrap;
80+
text-overflow: ellipsis;
81+
}
82+
.edit-clamp .edit-ph {
83+
color: var(--fg-4);
84+
}

0 commit comments

Comments
 (0)