From cbce25de25b360b49f467ce3da91ac5cba70419f Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Thu, 11 Jun 2026 15:30:10 +0500 Subject: [PATCH] feat(ux): home recently-viewed records + global quick-create MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two ways to get back into the work faster from the home screen. Recent: - A persistent, app-global recent-store (zustand + MMKV) records every record the detail screen opens — deduped by app/object/record, newest-first, capped at 20. Home surfaces the first five in a "Recent" section with a Clear action; tapping a row reopens the record. Quick-create: - A "+" in the home header opens a sheet listing every creatable object across the installed apps (flattened from their navigation trees); picking one opens its blank create form. Both new icons are theme-aware. New strings (home.recent*/quickCreate*) added in en/zh/ar. recent-store covered by unit tests (dedupe, ordering, 20-cap, clear). Co-Authored-By: Claude Opus 4.8 (1M context) --- __tests__/lib/recent-store.test.ts | 61 ++++++++++++ app/(app)/[appName]/[objectName]/[id].tsx | 14 +++ app/(tabs)/index.tsx | 93 ++++++++++++++++-- components/home/QuickCreateSheet.tsx | 113 ++++++++++++++++++++++ locales/ar.json | 7 +- locales/en.json | 7 +- locales/zh.json | 7 +- stores/recent-store.ts | 69 +++++++++++++ 8 files changed, 362 insertions(+), 9 deletions(-) create mode 100644 __tests__/lib/recent-store.test.ts create mode 100644 components/home/QuickCreateSheet.tsx create mode 100644 stores/recent-store.ts diff --git a/__tests__/lib/recent-store.test.ts b/__tests__/lib/recent-store.test.ts new file mode 100644 index 0000000..aaf4a41 --- /dev/null +++ b/__tests__/lib/recent-store.test.ts @@ -0,0 +1,61 @@ +import { useRecentStore } from "~/stores/recent-store"; + +const base = { appId: "todo_app", object: "todo_task" }; + +describe("recent-store", () => { + beforeEach(() => { + useRecentStore.getState().clear(); + }); + + it("tracks a viewed record at the front", () => { + useRecentStore.getState().track({ ...base, recordId: "a", title: "Task A" }); + const { records } = useRecentStore.getState(); + expect(records).toHaveLength(1); + expect(records[0]).toMatchObject({ recordId: "a", title: "Task A" }); + expect(typeof records[0].accessedAt).toBe("number"); + }); + + it("most-recent-first ordering", () => { + useRecentStore.getState().track({ ...base, recordId: "a", title: "A" }); + useRecentStore.getState().track({ ...base, recordId: "b", title: "B" }); + expect(useRecentStore.getState().records.map((r) => r.recordId)).toEqual([ + "b", + "a", + ]); + }); + + it("re-viewing a record dedupes and moves it to the front", () => { + useRecentStore.getState().track({ ...base, recordId: "a", title: "A" }); + useRecentStore.getState().track({ ...base, recordId: "b", title: "B" }); + useRecentStore.getState().track({ ...base, recordId: "a", title: "A (again)" }); + const { records } = useRecentStore.getState(); + expect(records).toHaveLength(2); + expect(records.map((r) => r.recordId)).toEqual(["a", "b"]); + expect(records[0].title).toBe("A (again)"); + }); + + it("treats the same record id under a different object as distinct", () => { + useRecentStore.getState().track({ ...base, recordId: "a", title: "Task A" }); + useRecentStore + .getState() + .track({ appId: "todo_app", object: "todo_note", recordId: "a", title: "Note A" }); + expect(useRecentStore.getState().records).toHaveLength(2); + }); + + it("caps the history at 20 entries", () => { + for (let i = 0; i < 25; i++) { + useRecentStore.getState().track({ ...base, recordId: `r${i}`, title: `R${i}` }); + } + const { records } = useRecentStore.getState(); + expect(records).toHaveLength(20); + // The newest (r24) leads; the oldest five (r0–r4) fell off. + expect(records[0].recordId).toBe("r24"); + expect(records.some((r) => r.recordId === "r0")).toBe(false); + }); + + it("clear empties the history", () => { + useRecentStore.getState().track({ ...base, recordId: "a", title: "A" }); + useRecentStore.getState().clear(); + expect(useRecentStore.getState().records).toHaveLength(0); + }); +}); diff --git a/app/(app)/[appName]/[objectName]/[id].tsx b/app/(app)/[appName]/[objectName]/[id].tsx index 9d7c517..6870db2 100644 --- a/app/(app)/[appName]/[objectName]/[id].tsx +++ b/app/(app)/[appName]/[objectName]/[id].tsx @@ -19,6 +19,7 @@ import { useToast } from "~/components/ui/Toast"; import { useConfirm } from "~/components/ui/ConfirmDialog"; import { isActionVisible } from "~/lib/record-actions"; import { renderRecordTitle } from "~/lib/record-title"; +import { useRecentStore } from "~/stores/recent-store"; export default function ObjectDetailScreen() { const { appName, objectName, id } = useLocalSearchParams<{ @@ -72,6 +73,19 @@ export default function ObjectDetailScreen() { const displayName = renderRecordTitle(meta, record, "Record Detail"); + // Remember this record for the Home "Recent" section once it has loaded. + const trackRecent = useRecentStore((s) => s.track); + useEffect(() => { + if (!record || !objectName || !id || !appName) return; + trackRecent({ + appId: appName, + object: objectName, + recordId: id, + title: String(displayName), + subtitle: meta?.label as string | undefined, + }); + }, [record, objectName, id, appName, displayName, meta, trackRecent]); + const formView: FormViewMeta | undefined = viewData ?? undefined; /* ---- Navigation handlers ---- */ diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index e3f39a8..0ed59e5 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -1,5 +1,6 @@ -import { View, Text, ScrollView, RefreshControl } from "react-native"; +import { View, Text, ScrollView, Pressable, RefreshControl } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; +import { useColorScheme } from "nativewind"; import { webContentMaxWidth } from "~/lib/responsive"; import { LayoutDashboard, @@ -7,6 +8,8 @@ import { Inbox, AlertCircle, Sparkles, + Plus, + Clock, } from "lucide-react-native"; import { useClient } from "@objectstack/client-react"; import { useRouter } from "expo-router"; @@ -16,7 +19,9 @@ import { authClient } from "~/lib/auth-client"; import { PressableCard } from "~/components/ui/PressableCard"; import { EmptyState } from "~/components/ui/EmptyState"; import { ListSkeleton } from "~/components/ui/ListSkeleton"; +import { QuickCreateSheet } from "~/components/home/QuickCreateSheet"; import { useApps } from "~/hooks/useApps"; +import { useRecentStore } from "~/stores/recent-store"; /** Pick a time-of-day greeting i18n key from the local hour. */ function greetingKey(hour: number): "greetingMorning" | "greetingAfternoon" | "greetingEvening" { @@ -44,8 +49,13 @@ export default function HomeScreen() { const client = useClient(); const router = useRouter(); const { t } = useTranslation(); + const { colorScheme } = useColorScheme(); + const accent = colorScheme === "dark" ? "#60a5fa" : "#1e40af"; const { data: session } = authClient.useSession(); const { apps, isLoading: appsLoading, refetch: refetchApps } = useApps(); + const recents = useRecentStore((s) => s.records); + const clearRecents = useRecentStore((s) => s.clear); + const [quickCreateOpen, setQuickCreateOpen] = useState(false); const firstName = (session?.user?.name ?? "").trim().split(/\s+/)[0]; const greeting = t(`home.${greetingKey(new Date().getHours())}`); @@ -122,11 +132,22 @@ export default function HomeScreen() { } > - - {heading} - - {t("home.subtitle")} - + + + {heading} + + {t("home.subtitle")} + + + {/* Global quick-create — pick any object and open its blank form. */} + setQuickCreateOpen(true)} + accessibilityRole="button" + accessibilityLabel={t("home.quickCreate")} + > + + {/* AI Assistant quick entry — surfaces the assistant on the home screen @@ -151,6 +172,60 @@ export default function HomeScreen() { + {/* Recently viewed records — quick re-entry to what you were working on. */} + {recents.length > 0 && ( + + + + {t("home.recentTitle")} + + + + {t("home.recentClear")} + + + + + {recents.slice(0, 5).map((r) => ( + + // Dynamic route — cast, matching the other record links. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + router.push(`/(app)/${r.appId}/${r.object}/${r.recordId}` as any) + } + accessibilityRole="button" + accessibilityLabel={r.title} + > + + + + + + {r.title} + + {r.subtitle ? ( + + {r.subtitle} + + ) : null} + + + + ))} + + + )} + {loading ? ( ) : error ? ( @@ -212,6 +287,12 @@ export default function HomeScreen() { )} + + ); } diff --git a/components/home/QuickCreateSheet.tsx b/components/home/QuickCreateSheet.tsx new file mode 100644 index 0000000..c077504 --- /dev/null +++ b/components/home/QuickCreateSheet.tsx @@ -0,0 +1,113 @@ +import React, { useMemo } from "react"; +import { View, Text, Pressable, ScrollView } from "react-native"; +import { useTranslation } from "react-i18next"; +import { useColorScheme } from "nativewind"; +import { useRouter } from "expo-router"; +import { ChevronRight } from "lucide-react-native"; +import { BottomSheet } from "~/components/ui/BottomSheet"; +import { getIcon } from "~/lib/getIcon"; +import type { AppMeta, NavigationItem } from "~/hooks/useApps"; + +interface CreatableObject { + appId: string; + appLabel: string; + object: string; + label: string; + icon?: string; +} + +/** + * Flatten every app's navigation tree into its creatable objects. `group` + * items are walked recursively; the first occurrence of an `app/object` pair + * wins (an object surfaced under several views appears once). + */ +function collectObjects(apps: AppMeta[]): CreatableObject[] { + const out: CreatableObject[] = []; + const seen = new Set(); + const walk = (app: AppMeta, items: NavigationItem[]) => { + for (const item of items) { + if (item.type === "group") { + walk(app, item.children ?? []); + continue; + } + if (item.type === "object" && item.objectName) { + const key = `${app.name}/${item.objectName}`; + if (seen.has(key)) continue; + seen.add(key); + out.push({ + appId: app.name, + appLabel: app.label, + object: item.objectName, + label: item.label, + icon: item.icon, + }); + } + } + }; + apps.forEach((app) => walk(app, app.navigation ?? [])); + return out; +} + +/** + * Global quick-create — a sheet listing every creatable object across the + * installed apps. Picking one opens its blank create form. + */ +export function QuickCreateSheet({ + open, + onOpenChange, + apps, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + apps: AppMeta[]; +}) { + const { t } = useTranslation(); + const router = useRouter(); + const { colorScheme } = useColorScheme(); + const accent = colorScheme === "dark" ? "#60a5fa" : "#1e40af"; + const objects = useMemo(() => collectObjects(apps), [apps]); + + return ( + + {objects.length === 0 ? ( + + {t("home.quickCreateEmpty")} + + ) : ( + + {objects.map((o) => { + const Icon = getIcon(o.icon); + return ( + { + onOpenChange(false); + // Dynamic route — expo-router's typed routes can't express the + // computed object path, so cast (matches the detail screens). + // eslint-disable-next-line @typescript-eslint/no-explicit-any + router.push(`/(app)/${o.appId}/${o.object}/new` as any); + }} + > + + + + + {o.label} + {o.appLabel} + + + + ); + })} + + )} + + ); +} diff --git a/locales/ar.json b/locales/ar.json index b31c9f9..3dc1012 100644 --- a/locales/ar.json +++ b/locales/ar.json @@ -230,7 +230,12 @@ "dashboardsTitle": "لوحات المعلومات", "noDashboardsTitle": "لا توجد لوحات معلومات", "noDashboardsDesc": "لم تنشر أي من تطبيقاتك المثبتة لوحة معلومات بعد.", - "loadErrorTitle": "تعذّر تحميل لوحات المعلومات" + "loadErrorTitle": "تعذّر تحميل لوحات المعلومات", + "recentTitle": "الأخيرة", + "recentClear": "مسح", + "quickCreate": "إنشاء", + "quickCreateTitle": "إنشاء سجل", + "quickCreateEmpty": "لا توجد كائنات متاحة للإنشاء." }, "search": { "title": "بحث", diff --git a/locales/en.json b/locales/en.json index 40f460b..83d57fb 100644 --- a/locales/en.json +++ b/locales/en.json @@ -227,7 +227,12 @@ "dashboardsTitle": "Dashboards", "noDashboardsTitle": "No Dashboards", "noDashboardsDesc": "None of your installed apps publish a dashboard yet.", - "loadErrorTitle": "Couldn't Load Dashboards" + "loadErrorTitle": "Couldn't Load Dashboards", + "recentTitle": "Recent", + "recentClear": "Clear", + "quickCreate": "Create", + "quickCreateTitle": "Create record", + "quickCreateEmpty": "No objects available to create." }, "search": { "title": "Search", diff --git a/locales/zh.json b/locales/zh.json index 65d0fee..d49922a 100644 --- a/locales/zh.json +++ b/locales/zh.json @@ -225,7 +225,12 @@ "dashboardsTitle": "仪表盘", "noDashboardsTitle": "暂无仪表盘", "noDashboardsDesc": "你已安装的应用尚未发布任何仪表盘。", - "loadErrorTitle": "无法加载仪表盘" + "loadErrorTitle": "无法加载仪表盘", + "recentTitle": "最近", + "recentClear": "清除", + "quickCreate": "新建", + "quickCreateTitle": "新建记录", + "quickCreateEmpty": "没有可创建的对象。" }, "search": { "title": "搜索", diff --git a/stores/recent-store.ts b/stores/recent-store.ts new file mode 100644 index 0000000..06b538b --- /dev/null +++ b/stores/recent-store.ts @@ -0,0 +1,69 @@ +import { create } from "zustand"; +import { createMMKV } from "react-native-mmkv"; + +/** + * A record the user has opened, remembered for the Home "Recent" section. + * Identified by its route triple (app / object / record) so the same record + * opened twice moves to the front instead of duplicating. + */ +export interface RecentRecord { + /** App route segment the record opens under. */ + appId: string; + /** Object (table) name. */ + object: string; + /** Record id. */ + recordId: string; + /** Display title (the record's resolved name). */ + title: string; + /** Optional secondary line (e.g. the object label). */ + subtitle?: string; + /** Epoch ms of the most recent access — drives ordering. */ + accessedAt: number; +} + +const storage = createMMKV({ id: "objectstack-recents" }); +const KEY = "records"; +/** Cap the history; the Home section only surfaces the first handful. */ +const MAX_RECENTS = 20; + +function keyOf(r: { appId: string; object: string; recordId: string }): string { + return `${r.appId}/${r.object}/${r.recordId}`; +} + +function load(): RecentRecord[] { + try { + const raw = storage.getString(KEY); + if (!raw) return []; + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? (parsed as RecentRecord[]) : []; + } catch { + return []; + } +} + +interface RecentState { + records: RecentRecord[]; + /** Record (or re-order to front) a viewed record. */ + track: (record: Omit) => void; + /** Forget everything. */ + clear: () => void; +} + +export const useRecentStore = create((set) => ({ + records: load(), + track: (record) => + set((state) => { + const k = keyOf(record); + const deduped = state.records.filter((r) => keyOf(r) !== k); + const next = [ + { ...record, accessedAt: Date.now() }, + ...deduped, + ].slice(0, MAX_RECENTS); + storage.set(KEY, JSON.stringify(next)); + return { records: next }; + }), + clear: () => { + storage.set(KEY, JSON.stringify([])); + set({ records: [] }); + }, +}));