- Notifications
You must be signed in to change notification settings - Fork 0
Registry follow-ups: multi-user auto-seed, index reconciliation (prepared), favourites hydration#238
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Uh oh!
There was an error while loading. Please reload this page.
Registry follow-ups: multi-user auto-seed, index reconciliation (prepared), favourites hydration #238
Changes from all commits
cb0657ff4fa3bc85d4f4f38ccaeff0dd441036fffe2d0032aFile filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,74 @@ | ||
| "use client"; | ||
| import { ClipboardList, Stethoscope } from "lucide-react"; | ||
| import { useEffect, useMemo, useState } from "react"; | ||
| import type { FavouriteItem } from "@/components/clinical-dashboard/favourites-prototype-data"; | ||
| import type { ServiceRecord } from "@/lib/services"; | ||
| import { useRegistryRecords } from "@/lib/use-registry-records"; | ||
| // localStorage keys written by the service/form detail pages when a record is | ||
| // saved (see service-detail-page.tsx / form-detail-page.tsx). | ||
| const savedServicesKey = "clinical-kb-saved-services"; | ||
| const savedFormsKey = "clinical-kb-saved-forms"; | ||
| function readSavedSlugs(key: string): string[] { | ||
| try { | ||
| const raw = window.localStorage.getItem(key); | ||
| const parsed = raw ? JSON.parse(raw) : []; | ||
| return Array.isArray(parsed) ? parsed.filter((item): item is string => typeof item === "string") : []; | ||
| } catch { | ||
| return []; | ||
| } | ||
| } | ||
| function recordToFavourite(record: ServiceRecord, type: "services" | "forms"): FavouriteItem { | ||
| return { | ||
| id: `${type}:${record.slug}`, | ||
| title: record.title, | ||
| type, | ||
| set: "", | ||
| meta: record.subtitle ?? (type === "services" ? "Saved service" : "Saved form"), | ||
| sourceMeta: type === "services" ? "Service" : "Form", | ||
| primaryAction: "Open", | ||
| icon: type === "services" ? Stethoscope : ClipboardList, | ||
| keywords: [record.title, record.subtitle, ...(record.tags ?? [])].filter(Boolean).join(" ").toLowerCase(), | ||
| }; | ||
| } | ||
| /** | ||
| * Hydrate the user's saved services/forms into the FavouriteItem shape the hub | ||
| * renders. Slugs live in localStorage; titles/metadata come from the owner's | ||
| * registry via useRegistryRecords (demo mode serves fixtures, so this also | ||
| * works env-less). Fetching is gated on there being saved slugs, so the common | ||
| * "nothing saved" case makes no request. | ||
| */ | ||
| export function useSavedRegistryFavourites(): FavouriteItem[] { | ||
| const [savedServices, setSavedServices] = useState<string[]>([]); | ||
| const [savedForms, setSavedForms] = useState<string[]>([]); | ||
| useEffect(() => { | ||
| const refresh = () => { | ||
| setSavedServices(readSavedSlugs(savedServicesKey)); | ||
| setSavedForms(readSavedSlugs(savedFormsKey)); | ||
Comment on lines
+52
to
+53
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
These fixed localStorage keys are read without considering the authenticated user, so if two accounts use the same browser profile the second account's Useful? React with 👍 / 👎. | ||
| }; | ||
| refresh(); | ||
| window.addEventListener("storage", refresh); | ||
| return () => window.removeEventListener("storage", refresh); | ||
| }, []); | ||
| const services = useRegistryRecords("service", { enabled: savedServices.length > 0 }); | ||
| const forms = useRegistryRecords("form", { enabled: savedForms.length > 0 }); | ||
| return useMemo(() => { | ||
| const savedServiceSet = new Set(savedServices); | ||
| const savedFormSet = new Set(savedForms); | ||
| const serviceItems = services.records | ||
| .filter((record) => savedServiceSet.has(record.slug)) | ||
| .map((record) => recordToFavourite(record, "services")); | ||
| const formItems = forms.records | ||
| .filter((record) => savedFormSet.has(record.slug)) | ||
| .map((record) => recordToFavourite(record, "forms")); | ||
| return [...serviceItems, ...formItems]; | ||
| }, [services.records, forms.records, savedServices, savedForms]); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,51 @@ | ||
| import { formRecords } from "@/lib/forms"; | ||
| import { | ||
| recordToRow, | ||
| type RegistryRecordInsert, | ||
| type RegistryRecordKind, | ||
| type RegistryRecordRow, | ||
| } from "@/lib/registry-records"; | ||
| import { serviceRecords } from "@/lib/services"; | ||
| // Type-only reference to the admin client so this module carries no runtime | ||
| // dependency on the Supabase admin singleton — the CLI can import the row | ||
| // builders without pulling in service-role env, and callers pass their own | ||
| // client into `ensureRegistrySeeded`. | ||
| type AdminClient = ReturnType<typeof import("@/lib/supabase/admin").createAdminClient>; | ||
| /** The curated default registry fixtures for a kind — the same set the CLI | ||
| * seeds and the API falls back to when an owner has no records yet. */ | ||
| export function defaultRegistryRecords(kind: RegistryRecordKind) { | ||
| return kind === "form" ? formRecords : serviceRecords; | ||
| } | ||
| /** Build insertable rows for an owner from the default fixtures. Shared by the | ||
| * CLI (`scripts/seed-registry-records.ts`) and the lazy API auto-seed so both | ||
| * map fixtures → rows identically. */ | ||
| export function buildDefaultRegistryRows(ownerId: string, kind: RegistryRecordKind): RegistryRecordInsert[] { | ||
| return defaultRegistryRecords(kind).map((record) => recordToRow(record, ownerId, kind)); | ||
| } | ||
| /** | ||
| * Idempotently seed the curated default registry records for an owner + kind | ||
| * and return the stored rows. Called lazily by the registry API when an | ||
| * authenticated owner has no records yet, so new accounts get populated | ||
| * Services/Forms instead of the empty state. Safe under concurrent first | ||
| * requests — the (owner_id, kind, slug) conflict target dedupes the upsert. | ||
| * | ||
| * First-seed helper only: it does NOT preserve post-seed governance edits, so | ||
| * the reseed path (the CLI) layers its own preservation on top. | ||
| */ | ||
| export async function ensureRegistrySeeded( | ||
| supabase: AdminClient, | ||
| ownerId: string, | ||
| kind: RegistryRecordKind, | ||
| ): Promise<RegistryRecordRow[]> { | ||
| const rows = buildDefaultRegistryRows(ownerId, kind); | ||
| const { data, error } = await supabase | ||
| .from("clinical_registry_records") | ||
| .upsert(rows, { onConflict: "owner_id,kind,slug" }) | ||
| .select("*"); | ||
| if (error) throw new Error(`Registry seed failed: ${error.message}`); | ||
| return (data ?? []) as RegistryRecordRow[]; | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For saved services/forms this new item only carries
id: ${type}:${record.slug}and labels the action asOpen;FavouriteItemRowrenders that as a plain button with no handler, and the mobile chevron is also inert. When a user opens/favouritesafter saving a service or form, the hydrated item appears but cannot navigate back to/services/{slug}or/forms/{slug}, so the new saved-registry favourite is display-only. Carry an href/slug through this shape and route the primary/mobile action based ontype.Useful? React with 👍 / 👎.