diff --git a/__tests__/lib/relative-time.test.ts b/__tests__/lib/relative-time.test.ts new file mode 100644 index 0000000..9c41d34 --- /dev/null +++ b/__tests__/lib/relative-time.test.ts @@ -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 }); + }); +}); diff --git a/__tests__/lib/search-history-store.test.ts b/__tests__/lib/search-history-store.test.ts new file mode 100644 index 0000000..9f4f7d8 --- /dev/null +++ b/__tests__/lib/search-history-store.test.ts @@ -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([]); + }); +}); diff --git a/app/(tabs)/notifications.tsx b/app/(tabs)/notifications.tsx index 7392bb2..2a9a334 100644 --- a/app/(tabs)/notifications.tsx +++ b/app/(tabs)/notifications.tsx @@ -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 = { + 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 ( - {new Date(notification.createdAt).toLocaleDateString()} + {relLabel(notification.createdAt, now, t)} @@ -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]); } @@ -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 = { + 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 ( {/* Title header */} @@ -149,8 +200,20 @@ export default function NotificationsScreen() { contentContainerClassName="px-1 pb-8 pt-2" contentContainerStyle={webContentMaxWidth} > - {notifications.map((n) => ( - + {sections.map(({ group, items }) => ( + + + {t(GROUP_LABEL_KEY[group])} + + {items.map((n) => ( + + ))} + ))} )} diff --git a/app/(tabs)/search.tsx b/app/(tabs)/search.tsx index e3351e5..6745537 100644 --- a/app/(tabs)/search.tsx +++ b/app/(tabs)/search.tsx @@ -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; @@ -43,18 +49,74 @@ export default function SearchScreen() { - {/* Idle empty state */} - {!showResults && ( - 0 - ? tCount("search.lookingAcross", objectCount) - : t("search.emptyHint") - } - /> - )} + {/* Idle: recent searches if any, else the empty hint. */} + {!showResults && + (recentQueries.length > 0 ? ( + + + + {t("search.recentTitle")} + + + + {t("search.recentClear")} + + + + + {recentQueries.map((q, idx) => ( + 0 ? "border-t border-border/50" : "" + }`} + > + { + void Haptics.selectionAsync(); + setQuery(q); + }} + accessibilityRole="button" + accessibilityLabel={q} + > + + + {q} + + + removeSearch(q)} + hitSlop={8} + accessibilityLabel={t("search.recentClear")} + > + + + + ))} + + + ) : ( + 0 + ? tCount("search.lookingAcross", objectCount) + : t("search.emptyHint") + } + /> + ))} {/* No matches */} {showResults && !isSearching && hasSearched && totalCount === 0 && ( @@ -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 })} > diff --git a/lib/relative-time.ts b/lib/relative-time.ts new file mode 100644 index 0000000..21eb77c --- /dev/null +++ b/lib/relative-time.ts @@ -0,0 +1,56 @@ +/** + * Time helpers for grouping and labelling timestamps in feeds (notifications, + * activity). Kept pure — the i18n layer maps the returned descriptors to + * localized strings, so these are unit-testable without a `t` function and + * sidestep Hermes's missing `Intl.PluralRules` (the labels use compact "5m" / + * "2h" forms with no pluralization). + */ + +/** Coarse bucket a timestamp falls into, relative to `now`. */ +export type DateGroup = "today" | "yesterday" | "week" | "earlier"; + +/** Midnight (local) of the day containing `ms`, as epoch ms. */ +export function startOfDay(ms: number): number { + const d = new Date(ms); + d.setHours(0, 0, 0, 0); + return d.getTime(); +} + +const DAY_MS = 86_400_000; + +/** Which day-bucket `ts` belongs to relative to `now` (both epoch ms). */ +export function dateGroup(ts: number, now: number): DateGroup { + const days = Math.round((startOfDay(now) - startOfDay(ts)) / DAY_MS); + if (days <= 0) return "today"; + if (days === 1) return "yesterday"; + if (days < 7) return "week"; + return "earlier"; +} + +/** + * A relative-time descriptor for a timestamp. The component formats it via + * i18n; older-than-yesterday falls back to an absolute calendar date. + */ +export type RelTime = + | { kind: "justNow" } + | { kind: "minutes"; n: number } + | { kind: "hours"; n: number } + | { kind: "yesterday" } + | { kind: "date"; ts: number }; + +const MIN_MS = 60_000; +const HOUR_MS = 3_600_000; + +/** Compact relative-time descriptor for `ts` relative to `now` (epoch ms). */ +export function relativeTime(ts: number, now: number): RelTime { + const diff = now - ts; + if (diff < MIN_MS) return { kind: "justNow" }; + if (diff < HOUR_MS) return { kind: "minutes", n: Math.floor(diff / MIN_MS) }; + // Within the same calendar day → hours; otherwise fall to day buckets so a + // 23:00→01:00 span reads "Yesterday", not "2h". + if (dateGroup(ts, now) === "today") { + return { kind: "hours", n: Math.max(1, Math.floor(diff / HOUR_MS)) }; + } + if (dateGroup(ts, now) === "yesterday") return { kind: "yesterday" }; + return { kind: "date", ts }; +} diff --git a/locales/ar.json b/locales/ar.json index 3dc1012..7014f05 100644 --- a/locales/ar.json +++ b/locales/ar.json @@ -169,7 +169,17 @@ "emptyTitle": "لا توجد إشعارات", "emptyDesc": "لقد اطّلعت على كل شيء. ستظهر الإشعارات الجديدة هنا.", "unavailableTitle": "الإشعارات غير متاحة", - "unavailableDesc": "تعذّر الوصول إلى خدمة الإشعارات. اسحب للتحديث أو حاول مرة أخرى." + "unavailableDesc": "تعذّر الوصول إلى خدمة الإشعارات. اسحب للتحديث أو حاول مرة أخرى.", + "groupToday": "اليوم", + "groupYesterday": "أمس", + "groupThisWeek": "هذا الأسبوع", + "groupEarlier": "أقدم" + }, + "time": { + "justNow": "الآن", + "minutesShort": "{{n}} د", + "hoursShort": "{{n}} س", + "yesterday": "أمس" }, "more": { "profileFallbackName": "مستخدم", @@ -258,7 +268,9 @@ "resultCount_few": "{{count}} نتائج", "resultCount_many": "{{count}} نتيجة", "resultCount_other": "{{n}} نتيجة", - "openLabel": "فتح {{title}}" + "openLabel": "فتح {{title}}", + "recentTitle": "عمليات البحث الأخيرة", + "recentClear": "مسح" }, "offline": { "offlineMode": "وضع عدم الاتصال", diff --git a/locales/en.json b/locales/en.json index 83d57fb..eabb7a6 100644 --- a/locales/en.json +++ b/locales/en.json @@ -166,7 +166,17 @@ "emptyTitle": "No Notifications", "emptyDesc": "You're all caught up. New notifications will appear here.", "unavailableTitle": "Notifications Unavailable", - "unavailableDesc": "We couldn't reach the notifications service. Pull to refresh or try again." + "unavailableDesc": "We couldn't reach the notifications service. Pull to refresh or try again.", + "groupToday": "Today", + "groupYesterday": "Yesterday", + "groupThisWeek": "This Week", + "groupEarlier": "Earlier" + }, + "time": { + "justNow": "Just now", + "minutesShort": "{{n}}m", + "hoursShort": "{{n}}h", + "yesterday": "Yesterday" }, "more": { "profileFallbackName": "User", @@ -247,7 +257,9 @@ "noResultsBody": "Nothing matched “{{query}}”. Try a different keyword.", "resultCount_one": "{{n}} result", "resultCount_other": "{{n}} results", - "openLabel": "Open {{title}}" + "openLabel": "Open {{title}}", + "recentTitle": "Recent searches", + "recentClear": "Clear" }, "offline": { "offlineMode": "Offline Mode", diff --git a/locales/zh.json b/locales/zh.json index d49922a..6b26ef4 100644 --- a/locales/zh.json +++ b/locales/zh.json @@ -164,7 +164,17 @@ "emptyTitle": "暂无通知", "emptyDesc": "你已全部处理完毕。新通知将显示在这里。", "unavailableTitle": "通知不可用", - "unavailableDesc": "无法连接通知服务。下拉刷新或稍后重试。" + "unavailableDesc": "无法连接通知服务。下拉刷新或稍后重试。", + "groupToday": "今天", + "groupYesterday": "昨天", + "groupThisWeek": "本周", + "groupEarlier": "更早" + }, + "time": { + "justNow": "刚刚", + "minutesShort": "{{n}}分钟", + "hoursShort": "{{n}}小时", + "yesterday": "昨天" }, "more": { "profileFallbackName": "用户", @@ -243,7 +253,9 @@ "noResultsTitle": "无结果", "noResultsBody": "没有与“{{query}}”匹配的结果。请尝试其他关键词。", "resultCount_other": "{{n}} 条结果", - "openLabel": "打开 {{title}}" + "openLabel": "打开 {{title}}", + "recentTitle": "最近搜索", + "recentClear": "清除" }, "offline": { "offlineMode": "离线模式", diff --git a/stores/search-history-store.ts b/stores/search-history-store.ts new file mode 100644 index 0000000..40318ed --- /dev/null +++ b/stores/search-history-store.ts @@ -0,0 +1,55 @@ +import { create } from "zustand"; +import { createMMKV } from "react-native-mmkv"; + +/** + * Recent global-search queries, persisted so the search screen can offer them + * for one-tap re-run. Case-insensitively deduped, newest-first, capped small. + */ +const storage = createMMKV({ id: "objectstack-search-history" }); +const KEY = "queries"; +const MAX_QUERIES = 8; + +function load(): string[] { + try { + const raw = storage.getString(KEY); + const parsed = raw ? JSON.parse(raw) : []; + return Array.isArray(parsed) ? (parsed as string[]) : []; + } catch { + return []; + } +} + +interface SearchHistoryState { + queries: string[]; + /** Remember a query (trimmed; no-op when empty). */ + record: (query: string) => void; + /** Forget a single query. */ + remove: (query: string) => void; + /** Forget everything. */ + clear: () => void; +} + +export const useSearchHistoryStore = create((set) => ({ + queries: load(), + record: (query) => + set((state) => { + const trimmed = query.trim(); + if (!trimmed) return state; + const deduped = state.queries.filter( + (q) => q.toLowerCase() !== trimmed.toLowerCase(), + ); + const next = [trimmed, ...deduped].slice(0, MAX_QUERIES); + storage.set(KEY, JSON.stringify(next)); + return { queries: next }; + }), + remove: (query) => + set((state) => { + const next = state.queries.filter((q) => q !== query); + storage.set(KEY, JSON.stringify(next)); + return { queries: next }; + }), + clear: () => { + storage.set(KEY, JSON.stringify([])); + set({ queries: [] }); + }, +}));