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
45 changes: 45 additions & 0 deletions __tests__/lib/relative-time.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { dateGroup, relativeTime, startOfDay } from "~/lib/relative-time";

// Local noon — keeps day-bucket math deterministic regardless of the test TZ.
const NOW = new Date(2026, 5, 11, 12, 0, 0).getTime();
const MIN = 60_000;
const HOUR = 3_600_000;
const DAY = 86_400_000;

describe("dateGroup", () => {
it("buckets by calendar day relative to now", () => {
expect(dateGroup(NOW - HOUR, NOW)).toBe("today");
expect(dateGroup(NOW - DAY, NOW)).toBe("yesterday");
expect(dateGroup(NOW - 3 * DAY, NOW)).toBe("week");
expect(dateGroup(NOW - 10 * DAY, NOW)).toBe("earlier");
});

it("startOfDay is idempotent and lands on local midnight", () => {
const s = startOfDay(NOW);
expect(startOfDay(s)).toBe(s);
expect(new Date(s).getHours()).toBe(0);
});
});

describe("relativeTime", () => {
it("just now under a minute", () => {
expect(relativeTime(NOW - 30_000, NOW)).toEqual({ kind: "justNow" });
});

it("minutes under an hour", () => {
expect(relativeTime(NOW - 5 * MIN, NOW)).toEqual({ kind: "minutes", n: 5 });
});

it("hours within the same day", () => {
expect(relativeTime(NOW - 2 * HOUR, NOW)).toEqual({ kind: "hours", n: 2 });
});

it("yesterday for the prior calendar day", () => {
expect(relativeTime(NOW - DAY, NOW)).toEqual({ kind: "yesterday" });
});

it("falls back to an absolute date for older timestamps", () => {
const ts = NOW - 5 * DAY;
expect(relativeTime(ts, NOW)).toEqual({ kind: "date", ts });
});
});
43 changes: 43 additions & 0 deletions __tests__/lib/search-history-store.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { useSearchHistoryStore } from "~/stores/search-history-store";

describe("search-history-store", () => {
beforeEach(() => {
useSearchHistoryStore.getState().clear();
});

it("records a trimmed query at the front", () => {
useSearchHistoryStore.getState().record(" invoices ");
expect(useSearchHistoryStore.getState().queries).toEqual(["invoices"]);
});

it("ignores blank queries", () => {
useSearchHistoryStore.getState().record(" ");
expect(useSearchHistoryStore.getState().queries).toEqual([]);
});

it("dedupes case-insensitively, moving the match to the front", () => {
useSearchHistoryStore.getState().record("Tasks");
useSearchHistoryStore.getState().record("invoices");
useSearchHistoryStore.getState().record("tasks");
expect(useSearchHistoryStore.getState().queries).toEqual(["tasks", "invoices"]);
});

it("caps history at 8 entries", () => {
for (let i = 0; i < 12; i++) {
useSearchHistoryStore.getState().record(`q${i}`);
}
const { queries } = useSearchHistoryStore.getState();
expect(queries).toHaveLength(8);
expect(queries[0]).toBe("q11");
expect(queries).not.toContain("q0");
});

it("removes a single query and clears all", () => {
useSearchHistoryStore.getState().record("a");
useSearchHistoryStore.getState().record("b");
useSearchHistoryStore.getState().remove("a");
expect(useSearchHistoryStore.getState().queries).toEqual(["b"]);
useSearchHistoryStore.getState().clear();
expect(useSearchHistoryStore.getState().queries).toEqual([]);
});
});
71 changes: 67 additions & 4 deletions app/(tabs)/notifications.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,57 @@
import React from "react";
import React, { useMemo } from "react";
import { View, Text, ScrollView, Pressable } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import * as Haptics from "expo-haptics";
import { webContentMaxWidth } from "~/lib/responsive";
import { Bell, CheckCheck, Circle, WifiOff } from "lucide-react-native";
import { useRouter } from "expo-router";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { cn } from "~/lib/utils";
import { dateGroup, relativeTime, type DateGroup } from "~/lib/relative-time";
import { EmptyState } from "~/components/ui/EmptyState";
import { ListSkeleton } from "~/components/ui/ListSkeleton";
import { useNotifications, type NotificationItem } from "~/hooks/useNotifications";

/** Localized compact relative-time label for a notification timestamp. */
function relLabel(createdAt: string, now: number, t: TFunction): string {
const rel = relativeTime(new Date(createdAt).getTime(), now);
switch (rel.kind) {
case "justNow":
return t("time.justNow");
case "minutes":
return t("time.minutesShort", { n: rel.n });
case "hours":
return t("time.hoursShort", { n: rel.n });
case "yesterday":
return t("time.yesterday");
case "date":
return new Date(rel.ts).toLocaleDateString();
}
}

const GROUP_ORDER: DateGroup[] = ["today", "yesterday", "week", "earlier"];
const GROUP_LABEL_KEY: Record<DateGroup, string> = {
today: "notifications.groupToday",
yesterday: "notifications.groupYesterday",
week: "notifications.groupThisWeek",
earlier: "notifications.groupEarlier",
};

/* ------------------------------------------------------------------ */
/* Notification Row */
/* ------------------------------------------------------------------ */

function NotificationRow({
notification,
now,
onPress,
}: {
notification: NotificationItem;
now: number;
onPress: (n: NotificationItem) => void;
}) {
const { t } = useTranslation();
return (
<Pressable
className={cn(
Expand Down Expand Up @@ -53,7 +84,7 @@ function NotificationRow({
{notification.body}
</Text>
<Text className="mt-1 text-xs text-muted-foreground/60">
{new Date(notification.createdAt).toLocaleDateString()}
{relLabel(notification.createdAt, now, t)}
</Text>
</View>
</Pressable>
Expand All @@ -78,6 +109,7 @@ export default function NotificationsScreen() {
const { t } = useTranslation();

const handlePress = (n: NotificationItem) => {
void Haptics.selectionAsync();
if (!n.read) {
void markRead([n.id]);
}
Expand All @@ -87,6 +119,25 @@ export default function NotificationsScreen() {
}
};

// Bucket notifications into Today / Yesterday / This Week / Earlier, keeping
// the server's within-group order. `now` is read once per render.
const now = Date.now();
const sections = useMemo(() => {
const buckets: Record<DateGroup, NotificationItem[]> = {
today: [],
yesterday: [],
week: [],
earlier: [],
};
for (const n of notifications) {
buckets[dateGroup(new Date(n.createdAt).getTime(), now)].push(n);
}
return GROUP_ORDER.map((group) => ({ group, items: buckets[group] })).filter(
(s) => s.items.length > 0,
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [notifications, now]);

return (
<SafeAreaView className="flex-1 bg-background" edges={["top", "left", "right"]}>
{/* Title header */}
Expand Down Expand Up @@ -149,8 +200,20 @@ export default function NotificationsScreen() {
contentContainerClassName="px-1 pb-8 pt-2"
contentContainerStyle={webContentMaxWidth}
>
{notifications.map((n) => (
<NotificationRow key={n.id} notification={n} onPress={handlePress} />
{sections.map(({ group, items }) => (
<View key={group} className="mb-2">
<Text className="px-4 pb-1 pt-3 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{t(GROUP_LABEL_KEY[group])}
</Text>
{items.map((n) => (
<NotificationRow
key={n.id}
notification={n}
now={now}
onPress={handlePress}
/>
))}
</View>
))}
</ScrollView>
)}
Expand Down
97 changes: 81 additions & 16 deletions app/(tabs)/search.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,25 @@
import { View, Text, TouchableOpacity, ScrollView, ActivityIndicator } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import * as Haptics from "expo-haptics";
import { webContentMaxWidth } from "~/lib/responsive";
import { Search as SearchIcon, X, ChevronRight, FileText, SearchX } from "lucide-react-native";
import { Search as SearchIcon, X, ChevronRight, FileText, SearchX, Clock } from "lucide-react-native";
import { useRouter } from "expo-router";
import { useTranslation } from "react-i18next";
import { tCount } from "~/lib/i18n";
import { Input } from "~/components/ui/Input";
import { EmptyState } from "~/components/ui/EmptyState";
import { useGlobalSearch } from "~/hooks/useGlobalSearch";
import { useSearchHistoryStore } from "~/stores/search-history-store";

export default function SearchScreen() {
const router = useRouter();
const { t } = useTranslation();
const { query, setQuery, groups, isSearching, hasSearched, totalCount, objectCount } =
useGlobalSearch();
const recentQueries = useSearchHistoryStore((s) => s.queries);
const recordSearch = useSearchHistoryStore((s) => s.record);
const removeSearch = useSearchHistoryStore((s) => s.remove);
const clearSearches = useSearchHistoryStore((s) => s.clear);

const showResults = query.trim().length > 0;

Expand Down Expand Up @@ -43,18 +49,74 @@ export default function SearchScreen() {
</View>
</View>

{/* Idle empty state */}
{!showResults && (
<EmptyState
icon={SearchIcon}
title={t("search.emptyTitle")}
description={
objectCount > 0
? tCount("search.lookingAcross", objectCount)
: t("search.emptyHint")
}
/>
)}
{/* Idle: recent searches if any, else the empty hint. */}
{!showResults &&
(recentQueries.length > 0 ? (
<ScrollView
className="flex-1"
contentContainerClassName="px-5 pb-8 pt-4"
contentContainerStyle={webContentMaxWidth}
keyboardShouldPersistTaps="handled"
>
<View className="mb-2 flex-row items-center justify-between">
<Text className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{t("search.recentTitle")}
</Text>
<TouchableOpacity
onPress={clearSearches}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={t("search.recentClear")}
>
<Text className="text-xs font-medium text-primary">
{t("search.recentClear")}
</Text>
</TouchableOpacity>
</View>
<View className="overflow-hidden rounded-xl border border-border bg-card">
{recentQueries.map((q, idx) => (
<View
key={q}
className={`flex-row items-center px-4 py-3 ${
idx > 0 ? "border-t border-border/50" : ""
}`}
>
<TouchableOpacity
className="flex-1 flex-row items-center"
onPress={() => {
void Haptics.selectionAsync();
setQuery(q);
}}
accessibilityRole="button"
accessibilityLabel={q}
>
<Clock size={16} color="#94a3b8" />
<Text className="ml-3 flex-1 text-base text-foreground" numberOfLines={1}>
{q}
</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => removeSearch(q)}
hitSlop={8}
accessibilityLabel={t("search.recentClear")}
>
<X size={16} color="#94a3b8" />
</TouchableOpacity>
</View>
))}
</View>
</ScrollView>
) : (
<EmptyState
icon={SearchIcon}
title={t("search.emptyTitle")}
description={
objectCount > 0
? tCount("search.lookingAcross", objectCount)
: t("search.emptyHint")
}
/>
))}

{/* No matches */}
{showResults && !isSearching && hasSearched && totalCount === 0 && (
Expand Down Expand Up @@ -88,11 +150,14 @@ export default function SearchScreen() {
className={`flex-row items-center px-4 py-3 ${
idx > 0 ? "border-t border-border/50" : ""
}`}
onPress={() =>
onPress={() => {
// Tapping a result is a strong signal the query was
// useful — remember it for one-tap re-run.
recordSearch(query);
router.push(
`/(app)/${rec.appName}/${rec.objectName}/${rec.id}` as never,
)
}
);
}}
accessibilityRole="button"
accessibilityLabel={t("search.openLabel", { title: rec.title })}
>
Expand Down
Loading
Loading