Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,9 @@ Netlify reads the `netlify.toml` **inside that base dir**, so the sites stay iso
sprint/PI filters; timezone-aware times) and **Sprints & PIs → PI Roadmap**. **USWDS palette, light-only**
(no dark theme). **No generator/Action** — data is hand-entered JSON bundled at BUILD via
`import.meta.glob` (no runtime fetch). Source of truth = compact **`data/meetings.json` + `data/pis.json`**
arrays; adds/edits (meetings **and** PIs) stage in an in-app **Changes cart** → **one prefilled new-file
PR** creating `dse-hub/data/changes/<ts>.json` (array of `ChangeDoc`), which the loaders **merge** over the
canonical arrays at load (upsert by id, newest `ts` wins) — token-free, conflict-free (unique filenames),
arrays; adds/edits/**deletes** (meetings **and** PIs) stage in an in-app **Changes cart** → **one prefilled
new-file PR** creating `dse-hub/data/changes/<ts>.json` (array of `ChangeDoc`, `op: upsert|delete`), which the
loaders **merge** over the canonical arrays at load (upsert/delete by id, newest `ts` wins) — token-free, conflict-free (unique filenames),
same idea as leave-dashboard overrides. `dse-hub/scripts/compact.mjs` +
`.github/workflows/dse-hub-compact.yml` fold change files back into the canonical arrays on merge.
**Recurrence** (`src/meetings/recurrence.ts`): weekly/monthly/sprint/sprint-week/tbd → real dates against
Expand Down
18 changes: 14 additions & 4 deletions docs/DSE_HUB.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,16 @@ in-app **prefilled PRs** (no backend, no token, no runtime fetch).
## Data model (GitHub-only, merged at BUILD)
- Source of truth = compact **`data/meetings.json`** and **`data/pis.json`** (single JSON arrays, empty
optional fields omitted). Bundled at build via `import.meta.glob` — no runtime fetch, no CDN staleness.
- Every add/edit (meeting or PI) is a **`ChangeDoc`** `{ kind:"meeting"|"pi", op:"upsert", ts, data, label }`
staged in the in-app **Changes cart** (`src/ChangesContext.tsx`, `localStorage dse-hub:changes`).
- Every add/edit/**delete** (meeting or PI) is a **`ChangeDoc`** `{ kind:"meeting"|"pi", op:"upsert"|"delete", ts, data, label }`
staged in the in-app **Changes cart** (`src/ChangesContext.tsx`, `localStorage dse-hub:changes`). A
**delete** keeps the full record in `data` so loaders/compact can read its `id`; the "🗑 Delete meeting"
button (shared `MeetingDetail`) stages one after a confirm.
- **Submit PR** builds ONE file `data/changes/<ts>-<n>.json` (array of ChangeDocs) and opens the prefilled
`github.com/NASA-IMPACT/veda-github-actions/new/main?filename=&value=` page — the same token-free flow as
leave-dashboard's overrides. The user just clicks *commit* on GitHub.
- Loaders (`src/meetings/data.ts`, `src/pi/data.ts`) import the canonical arrays + glob `data/changes/*.json`
(`src/changesData.ts`) and **merge**: upsert by `data.id`, newest `ts` wins. So a merged PR shows up on the
next Netlify build.
(`src/changesData.ts`) and **merge** by `data.id`, newest `ts` wins: `upsert` sets the record, `delete`
removes it. So a merged PR shows up on the next Netlify build.
- `scripts/compact.mjs` + `.github/workflows/dse-hub-compact.yml` **fold** change files back into the
canonical arrays on merge to `main` (and delete the folded files) — keeps `data/changes/` small at scale.
- **Why single canonical files, not one-per-record:** scales to hundreds without hundreds of files, and gzip
Expand Down Expand Up @@ -59,6 +61,14 @@ schedule (via the app: **Sprints & PIs → Edit → Add to changes → Submit PR
border or the columns go ragged.
- Netlify: set **base directory = `dse-hub`**; the first deploy fails if run before `dse-hub/` exists on the
built branch ("base directory not found") — deploy after the merge lands on `main`.
- **Light-only ⇒ pin native controls.** `<meta name="color-scheme" content="light">` (index.html) +
`:root { color-scheme: light }` (styles.css) stop a dark-mode OS from rendering `<select>`/date-picker
popups dark. The native **`<datalist>` popup ignores `color-scheme` in Chrome** (renders dark + misaligned),
so Team/Category use a custom light **`Combobox`** (`src/meetings/Combobox.tsx`) instead — free text still
allowed. If you add another autocomplete, use `Combobox`, not `<datalist>`.
- **Deleting a meeting is a press-and-HOLD (2s) gesture** (`src/meetings/HoldToDelete.tsx`), not a click —
a fill bar sweeps in `--hold-ms`; releasing early aborts. It only *stages* a `delete` ChangeDoc in the
Changes cart (reviewable, PR-gated), never an instant removal.

## Run / test
```bash
Expand Down
4 changes: 4 additions & 0 deletions dse-hub/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- Light-only app: pin native controls (<select> popups, date picker, scrollbars) to light so a
dark-mode OS doesn't render them as dark. (Team/Category use a custom light Combobox because
the native <datalist> popup ignores color-scheme in Chrome.) -->
<meta name="color-scheme" content="light" />
<link
rel="icon"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Crect width='16' height='16' rx='3' fill='%23005ea2'/%3E%3Crect x='2.5' y='3' width='11' height='2' fill='white'/%3E%3Crect x='2.5' y='6.5' width='3' height='3' fill='%2373b3e7'/%3E%3Crect x='6.5' y='6.5' width='3' height='3' fill='white'/%3E%3Crect x='10.5' y='6.5' width='3' height='3' fill='%23fa9441'/%3E%3C/svg%3E"
Expand Down
9 changes: 6 additions & 3 deletions dse-hub/scripts/compact.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,12 @@ const meetingMap = new Map(meetings.map((m) => [m.id, m]));
const piMap = new Map(pis.map((p) => [p.id, p]));

for (const doc of allDocs) {
if (doc.op !== "upsert") continue;
if (doc.kind === "meeting" && doc.data?.id) meetingMap.set(doc.data.id, doc.data);
if (doc.kind === "pi" && doc.data?.id) piMap.set(doc.data.id, doc.data);
const id = doc.data?.id;
if (!id) continue;
const target = doc.kind === "meeting" ? meetingMap : doc.kind === "pi" ? piMap : null;
if (!target) continue;
if (doc.op === "upsert") target.set(id, doc.data);
else if (doc.op === "delete") target.delete(id);
}

// ---- sort ----
Expand Down
3 changes: 2 additions & 1 deletion dse-hub/src/changesData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ import type { Meeting, Pi } from "./types";

export interface ChangeDoc {
kind: "meeting" | "pi";
op: "upsert";
op: "upsert" | "delete";
ts: string;
// For "delete", data still carries the removed record so loaders/compact can read its `id`.
data: Meeting | Pi;
label: string;
}
Expand Down
20 changes: 10 additions & 10 deletions dse-hub/src/meetings/AddMeetingForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,14 @@ import type { JoinMethod, Meeting, Weekday } from "../types";
import { JOIN_LABEL, JOIN_ORDER } from "../colors";
import { buildMeetingFile, draftToMeeting, draftValid, MeetingDraft } from "./drafts";
import TimePicker from "./TimePicker";
import Combobox from "./Combobox";
import { TZ_OPTIONS } from "../tz";

const CATEGORY_OPTIONS = [
"Sprint Planning", "Sprint Review", "Check In", "Tag Up", "Standup", "Sync",
"Team Lead Sync", "Portal Design", "Backlog Grooming", "Retro", "Project Office",
];

const WEEK_ORDER: Weekday[] = ["MO", "TU", "WE", "TH", "FR", "SA", "SU"];
const WEEK_LABEL: Record<Weekday, string> = {
MO: "Mon", TU: "Tue", WE: "Wed", TH: "Thu", FR: "Fri", SA: "Sat", SU: "Sun",
Expand Down Expand Up @@ -60,8 +66,7 @@ export default function AddMeetingForm({ mode, editId, draft, setDraft, teams, o
<div className="field two">
<div>
<label>Team</label>
<input list="teamlist" value={draft.team} onChange={(e) => patch({ team: e.target.value })} placeholder="Disasters — or a new team" />
<datalist id="teamlist">{teams.map((t) => <option key={t} value={t} />)}</datalist>
<Combobox value={draft.team} onChange={(v) => patch({ team: v })} options={teams} placeholder="Disasters — or a new team" />
</div>
<div>
<label>Meeting name</label>
Expand All @@ -72,17 +77,12 @@ export default function AddMeetingForm({ mode, editId, draft, setDraft, teams, o
<div className="field two">
<div>
<label>Category <span className="opt">(optional)</span></label>
<input
list="catlist"
<Combobox
value={draft.category}
onChange={(e) => patch({ category: e.target.value })}
onChange={(v) => patch({ category: v })}
options={CATEGORY_OPTIONS}
placeholder="Sprint Planning, Check In, …"
/>
<datalist id="catlist">
{["Sprint Planning","Sprint Review","Check In","Tag Up","Standup","Sync","Team Lead Sync","Portal Design","Backlog Grooming","Retro","Project Office"].map((c) => (
<option key={c} value={c} />
))}
</datalist>
</div>
<div>
<label>Purpose <span className="opt">(optional)</span></label>
Expand Down
89 changes: 89 additions & 0 deletions dse-hub/src/meetings/Combobox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { useRef, useState, type KeyboardEvent } from "react";
import { useClickAway } from "../useClickAway";

interface Props {
value: string;
onChange: (v: string) => void;
options: string[];
placeholder?: string;
id?: string;
}

// A light-themed autocomplete that replaces the native <datalist>: on a dark-mode OS Chrome renders
// the datalist popup as an unstyled dark, misaligned dropdown. Free text is allowed — pick a
// suggestion or type a brand-new value (e.g. a new team). Modeled on TimePicker's dropdown pattern.
export default function Combobox({ value, onChange, options, placeholder, id }: Props) {
const [open, setOpen] = useState(false);
const [active, setActive] = useState(-1);
const ref = useRef<HTMLDivElement>(null);
useClickAway(ref, () => setOpen(false), open);

const q = value.trim().toLowerCase();
const matches = q ? options.filter((o) => o.toLowerCase().includes(q)) : options;

function choose(o: string) {
onChange(o);
setOpen(false);
setActive(-1);
}

function onKeyDown(e: KeyboardEvent<HTMLInputElement>) {
if (e.key === "ArrowDown") {
e.preventDefault();
setOpen(true);
setActive((a) => Math.min(a + 1, matches.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActive((a) => Math.max(a - 1, 0));
} else if (e.key === "Enter" && open && active >= 0 && matches[active]) {
e.preventDefault();
choose(matches[active]);
} else if (e.key === "Escape") {
setOpen(false);
}
}

return (
<div className="combo" ref={ref}>
<input
id={id}
className="combo-input"
value={value}
placeholder={placeholder}
autoComplete="off"
role="combobox"
aria-expanded={open}
aria-autocomplete="list"
onChange={(e) => { onChange(e.target.value); setOpen(true); setActive(-1); }}
onFocus={() => setOpen(true)}
onKeyDown={onKeyDown}
/>
<button
type="button"
className="combo-caret"
aria-label="Toggle suggestions"
tabIndex={-1}
onMouseDown={(e) => { e.preventDefault(); setOpen((o) => !o); }}
>
</button>
{open && matches.length > 0 && (
<div className="combo-menu panel" role="listbox">
{matches.map((o, i) => (
<button
key={o}
type="button"
role="option"
aria-selected={value === o}
className={`combo-option ${i === active ? "active" : ""} ${value === o ? "selected" : ""}`}
onMouseEnter={() => setActive(i)}
onMouseDown={(e) => { e.preventDefault(); choose(o); }}
>
{o}
</button>
))}
</div>
)}
</div>
);
}
60 changes: 60 additions & 0 deletions dse-hub/src/meetings/HoldToDelete.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { useRef, useState, type CSSProperties, type KeyboardEvent, type PointerEvent } from "react";

interface Props {
onComplete: () => void;
ms?: number; // hold duration; default 2000
label?: string;
holdingLabel?: string;
}

// Press-and-HOLD to delete: the action only fires after a continuous `ms` hold, and a fill bar
// animates across the button in lockstep. Releasing (pointer up / leave / cancel) before the end
// aborts. Replaces a confirm() dialog with a deliberate, hard-to-trigger-by-accident gesture.
export default function HoldToDelete({
onComplete,
ms = 2000,
label = "🗑 Delete meeting",
holdingLabel = "Hold to delete…",
}: Props) {
const [holding, setHolding] = useState(false);
const timer = useRef<number | null>(null);

function start(primary: boolean) {
if (!primary || holding) return;
setHolding(true);
timer.current = window.setTimeout(() => {
timer.current = null;
setHolding(false);
onComplete();
}, ms);
}
function cancel() {
if (timer.current != null) {
clearTimeout(timer.current);
timer.current = null;
}
setHolding(false);
}

return (
<button
type="button"
className={`btn danger hold-btn ${holding ? "holding" : ""}`}
style={{ "--hold-ms": `${ms}ms` } as CSSProperties}
onPointerDown={(e: PointerEvent) => start(e.button === 0)}
onPointerUp={cancel}
onPointerLeave={cancel}
onPointerCancel={cancel}
onKeyDown={(e: KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); start(true); }
}}
onKeyUp={cancel}
onBlur={cancel}
title={`Press and hold ${ms / 1000}s to delete`}
aria-label={`${label} — press and hold ${ms / 1000} seconds`}
>
<span className="hold-fill" aria-hidden />
<span className="hold-label">{holding ? holdingLabel : label}</span>
</button>
);
}
22 changes: 19 additions & 3 deletions dse-hub/src/meetings/MeetingCalendar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ interface Props {
today: string;
draftIds: Set<string>;
onEdit?: (m: Meeting) => void;
onDelete?: (m: Meeting) => void;
}

// What's shown when a user clicks — either a single meeting or a whole day's list.
Expand All @@ -33,13 +34,15 @@ function MeetingPopoverContent({
isDraft,
onClose,
onEdit,
onDelete,
viewTz,
}: {
meeting: Meeting;
iso: string;
isDraft: boolean;
onClose: () => void;
onEdit?: () => void;
onDelete?: () => void;
viewTz: string;
}) {
const sourceTz = meeting.schedule.tz ?? "America/Chicago";
Expand All @@ -61,7 +64,7 @@ function MeetingPopoverContent({
<button className="modal-x" onClick={onClose} aria-label="Close">×</button>
</div>
<div className="pop-body">
<MeetingDetail meeting={meeting} onEdit={onEdit} />
<MeetingDetail meeting={meeting} onEdit={onEdit} onDelete={onDelete} />
</div>
</>
);
Expand All @@ -73,13 +76,15 @@ function DayPopoverContent({
draftIds,
onClose,
onEdit,
onDelete,
viewTz,
}: {
iso: string;
meetings: Meeting[];
draftIds: Set<string>;
onClose: () => void;
onEdit?: (m: Meeting) => void;
onDelete?: (m: Meeting) => void;
viewTz: string;
}) {
const [expanded, setExpanded] = useState<string | null>(null);
Expand Down Expand Up @@ -119,7 +124,11 @@ function DayPopoverContent({
</button>
{isOpen && (
<div className="pop-day-item-detail">
<MeetingDetail meeting={m} onEdit={onEdit ? () => onEdit(m) : undefined} />
<MeetingDetail
meeting={m}
onEdit={onEdit ? () => onEdit(m) : undefined}
onDelete={onDelete ? () => onDelete(m) : undefined}
/>
</div>
)}
</div>
Expand All @@ -130,7 +139,7 @@ function DayPopoverContent({
);
}

export default function MeetingCalendar({ meetings, pi, today, draftIds, onEdit }: Props) {
export default function MeetingCalendar({ meetings, pi, today, draftIds, onEdit, onDelete }: Props) {
const { tz: viewTz } = useViewTz();
const [month, setMonth] = useState(() => monthKey(parseISO(today)));
const [selection, setSelection] = useState<Selection | null>(null);
Expand Down Expand Up @@ -195,6 +204,11 @@ export default function MeetingCalendar({ meetings, pi, today, draftIds, onEdit
onEdit?.(m);
}

function handleDelete(m: Meeting) {
setSelection(null);
onDelete?.(m);
}

return (
<div className="cal-wrap">
<div className="cal-main">
Expand Down Expand Up @@ -341,6 +355,7 @@ export default function MeetingCalendar({ meetings, pi, today, draftIds, onEdit
isDraft={draftIds.has(selection.meeting.id)}
onClose={closeSelection}
onEdit={onEdit ? () => handleEdit(selection.meeting) : undefined}
onDelete={onDelete ? () => handleDelete(selection.meeting) : undefined}
viewTz={viewTz}
/>
) : (
Expand All @@ -350,6 +365,7 @@ export default function MeetingCalendar({ meetings, pi, today, draftIds, onEdit
draftIds={draftIds}
onClose={closeSelection}
onEdit={onEdit ? handleEdit : undefined}
onDelete={onDelete ? handleDelete : undefined}
viewTz={viewTz}
/>
)}
Expand Down
Loading