Skip to content
Merged
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
61 changes: 61 additions & 0 deletions __tests__/lib/recent-store.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
14 changes: 14 additions & 0 deletions app/(app)/[appName]/[objectName]/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<{
Expand Down Expand Up @@ -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 ---- */
Expand Down
93 changes: 87 additions & 6 deletions app/(tabs)/index.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
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,
ChevronRight,
Inbox,
AlertCircle,
Sparkles,
Plus,
Clock,
} from "lucide-react-native";
import { useClient } from "@objectstack/client-react";
import { useRouter } from "expo-router";
Expand All @@ -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" {
Expand Down Expand Up @@ -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())}`);
Expand Down Expand Up @@ -122,11 +132,22 @@ export default function HomeScreen() {
<RefreshControl refreshing={isRefreshing} onRefresh={onRefresh} tintColor="#1e40af" />
}
>
<View className="mb-5">
<Text className="text-2xl font-bold text-foreground">{heading}</Text>
<Text className="mt-1 text-sm text-muted-foreground">
{t("home.subtitle")}
</Text>
<View className="mb-5 flex-row items-start justify-between">
<View className="flex-1 pr-3">
<Text className="text-2xl font-bold text-foreground">{heading}</Text>
<Text className="mt-1 text-sm text-muted-foreground">
{t("home.subtitle")}
</Text>
</View>
{/* Global quick-create — pick any object and open its blank form. */}
<Pressable
className="h-11 w-11 items-center justify-center rounded-full bg-primary active:opacity-80"
onPress={() => setQuickCreateOpen(true)}
accessibilityRole="button"
accessibilityLabel={t("home.quickCreate")}
>
<Plus size={24} color="#ffffff" />
</Pressable>
</View>

{/* AI Assistant quick entry — surfaces the assistant on the home screen
Expand All @@ -151,6 +172,60 @@ export default function HomeScreen() {
<ChevronRight size={20} color="#94a3b8" />
</PressableCard>

{/* Recently viewed records — quick re-entry to what you were working on. */}
{recents.length > 0 && (
<View className="mb-5">
<View className="mb-2 flex-row items-center justify-between">
<Text className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{t("home.recentTitle")}
</Text>
<Pressable
onPress={clearRecents}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={t("home.recentClear")}
>
<Text className="text-xs font-medium text-primary">
{t("home.recentClear")}
</Text>
</Pressable>
</View>
<View className="gap-2">
{recents.slice(0, 5).map((r) => (
<PressableCard
key={`${r.appId}/${r.object}/${r.recordId}`}
className="flex-row items-center p-3.5"
onPress={() =>
// 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}
>
<View className="rounded-xl bg-primary/10 p-2.5">
<Clock size={18} color={accent} />
</View>
<View className="ml-3 flex-1">
<Text
className="text-base font-medium text-card-foreground"
numberOfLines={1}
>
{r.title}
</Text>
{r.subtitle ? (
<Text className="text-xs text-muted-foreground" numberOfLines={1}>
{r.subtitle}
</Text>
) : null}
</View>
<ChevronRight size={18} color="#94a3b8" />
</PressableCard>
))}
</View>
</View>
)}

{loading ? (
<ListSkeleton count={4} />
) : error ? (
Expand Down Expand Up @@ -212,6 +287,12 @@ export default function HomeScreen() {
</View>
)}
</ScrollView>

<QuickCreateSheet
open={quickCreateOpen}
onOpenChange={setQuickCreateOpen}
apps={apps}
/>
</SafeAreaView>
);
}
113 changes: 113 additions & 0 deletions components/home/QuickCreateSheet.tsx
Original file line number Diff line number Diff line change
@@ -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<string>();
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 (
<BottomSheet
open={open}
onOpenChange={onOpenChange}
title={t("home.quickCreateTitle")}
>
{objects.length === 0 ? (
<Text className="px-3 py-6 text-center text-sm text-muted-foreground">
{t("home.quickCreateEmpty")}
</Text>
) : (
<ScrollView style={{ maxHeight: 380 }} showsVerticalScrollIndicator={false}>
{objects.map((o) => {
const Icon = getIcon(o.icon);
return (
<Pressable
key={`${o.appId}/${o.object}`}
className="flex-row items-center rounded-lg px-2 py-3 active:bg-muted"
accessibilityRole="button"
accessibilityLabel={o.label}
onPress={() => {
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);
}}
>
<View className="rounded-lg bg-primary/10 p-2">
<Icon size={18} color={accent} />
</View>
<View className="ml-3 flex-1">
<Text className="text-base text-foreground">{o.label}</Text>
<Text className="text-xs text-muted-foreground">{o.appLabel}</Text>
</View>
<ChevronRight size={18} color="#94a3b8" />
</Pressable>
);
})}
</ScrollView>
)}
</BottomSheet>
);
}
7 changes: 6 additions & 1 deletion locales/ar.json
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,12 @@
"dashboardsTitle": "لوحات المعلومات",
"noDashboardsTitle": "لا توجد لوحات معلومات",
"noDashboardsDesc": "لم تنشر أي من تطبيقاتك المثبتة لوحة معلومات بعد.",
"loadErrorTitle": "تعذّر تحميل لوحات المعلومات"
"loadErrorTitle": "تعذّر تحميل لوحات المعلومات",
"recentTitle": "الأخيرة",
"recentClear": "مسح",
"quickCreate": "إنشاء",
"quickCreateTitle": "إنشاء سجل",
"quickCreateEmpty": "لا توجد كائنات متاحة للإنشاء."
},
"search": {
"title": "بحث",
Expand Down
7 changes: 6 additions & 1 deletion locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 6 additions & 1 deletion locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,12 @@
"dashboardsTitle": "仪表盘",
"noDashboardsTitle": "暂无仪表盘",
"noDashboardsDesc": "你已安装的应用尚未发布任何仪表盘。",
"loadErrorTitle": "无法加载仪表盘"
"loadErrorTitle": "无法加载仪表盘",
"recentTitle": "最近",
"recentClear": "清除",
"quickCreate": "新建",
"quickCreateTitle": "新建记录",
"quickCreateEmpty": "没有可创建的对象。"
},
"search": {
"title": "搜索",
Expand Down
Loading
Loading