mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-10-15 19:45:42 -04:00
fix(native): regroup Upcoming when the day or language changes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
6 files changed
+71
-14
No files matched your search
@@ -12,6 +12,7 @@ import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { LoadMoreFooter } from "@/components/ui/load-more-footer";
|
||||
import { ScaledIcon } from "@/components/ui/scaled-icon";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { useLocalDay } from "@/hooks/use-local-day";
|
||||
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
|
||||
import { orpc } from "@/lib/orpc";
|
||||
import { queryClient } from "@/lib/query-client";
|
||||
@@ -49,7 +50,8 @@ function FilterChip({
|
||||
}
|
||||
|
||||
export default function UpcomingScreen() {
|
||||
const { t } = useLingui();
|
||||
const { t, i18n } = useLingui();
|
||||
const day = useLocalDay();
|
||||
const headerTitleStyle = useResolveClassNames("font-display text-foreground text-xl");
|
||||
const tintColor = useCSSVariable("--color-primary") as string;
|
||||
const backgroundColor = useCSSVariable("--color-background") as string;
|
||||
@@ -87,12 +89,14 @@ export default function UpcomingScreen() {
|
||||
const allItems = useMemo(() => data?.pages.flatMap((p) => p.items) ?? [], [data]);
|
||||
const sections = useMemo(
|
||||
() =>
|
||||
groupByDateBucket(allItems, { past: view === "recent" }).map((b) => ({
|
||||
key: b.key,
|
||||
title: b.label,
|
||||
data: b.items,
|
||||
})),
|
||||
[allItems, view],
|
||||
groupByDateBucket(allItems, { past: view === "recent", today: day, locale: i18n.locale }).map(
|
||||
(b) => ({
|
||||
key: b.key,
|
||||
title: b.label,
|
||||
data: b.items,
|
||||
}),
|
||||
),
|
||||
[allItems, view, day, i18n.locale],
|
||||
);
|
||||
|
||||
const refreshUpcoming = useCallback(
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { AppState } from "react-native";
|
||||
|
||||
import { msUntilNextLocalMidnight } from "@/lib/local-day";
|
||||
import { formatLocalDate } from "@sofa/i18n/date-buckets";
|
||||
|
||||
/** The current local day (YYYY-MM-DD); updates at midnight and when the app returns to the foreground. */
|
||||
export function useLocalDay(): string {
|
||||
const [day, setDay] = useState(() => formatLocalDate(new Date()));
|
||||
useEffect(() => {
|
||||
let timer = setTimeout(function tick() {
|
||||
setDay(formatLocalDate(new Date()));
|
||||
timer = setTimeout(tick, msUntilNextLocalMidnight());
|
||||
}, msUntilNextLocalMidnight());
|
||||
const sub = AppState.addEventListener("change", (state) => {
|
||||
if (state === "active") setDay(formatLocalDate(new Date()));
|
||||
});
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
sub.remove();
|
||||
};
|
||||
}, []);
|
||||
return day;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { msUntilNextLocalMidnight } from "./local-day";
|
||||
|
||||
describe("msUntilNextLocalMidnight", () => {
|
||||
test("counts down to one second past local midnight", () => {
|
||||
expect(msUntilNextLocalMidnight(new Date(2026, 0, 5, 23, 59, 0))).toBe(61_000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
/** Milliseconds until the next local midnight (+1 s slack). */
|
||||
export function msUntilNextLocalMidnight(now: Date = new Date()): number {
|
||||
const next = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 0, 0, 1);
|
||||
return next.getTime() - now.getTime();
|
||||
}
|
||||
@@ -26,11 +26,11 @@ function getEndOfWeek(today: string): string {
|
||||
return addDays(today, 6);
|
||||
}
|
||||
|
||||
function getMonthLabel(dateStr: string): string {
|
||||
function getMonthLabel(dateStr: string, locale: string): string {
|
||||
// Format a UTC instant in UTC: correct regardless of the formatter's default time zone
|
||||
// (the native Intl polyfill defaults to UTC; browsers use the device zone).
|
||||
const d = new Date(`${dateStr}T00:00:00Z`);
|
||||
return new Intl.DateTimeFormat(i18n.locale, { month: "long", timeZone: "UTC" }).format(d);
|
||||
return new Intl.DateTimeFormat(locale, { month: "long", timeZone: "UTC" }).format(d);
|
||||
}
|
||||
|
||||
type BucketKey = "today" | "tomorrow" | "this_week" | "next_week" | string;
|
||||
@@ -53,7 +53,7 @@ function getPastBucketKey(dateStr: string, today: string): BucketKey {
|
||||
return `month_${dateStr.slice(0, 7)}`;
|
||||
}
|
||||
|
||||
function getBucketLabel(key: BucketKey): string {
|
||||
function getBucketLabel(key: BucketKey, locale: string): string {
|
||||
if (key === "today") return i18n._(msg`Today`);
|
||||
if (key === "tomorrow") return i18n._(msg`Tomorrow`);
|
||||
if (key === "yesterday") return i18n._(msg`Yesterday`);
|
||||
@@ -61,17 +61,18 @@ function getBucketLabel(key: BucketKey): string {
|
||||
if (key === "this_week") return i18n._(msg`This Week`);
|
||||
if (key === "next_week") return i18n._(msg`Next Week`);
|
||||
if (key.startsWith("month_")) {
|
||||
return getMonthLabel(`${key.slice(6)}-01`);
|
||||
return getMonthLabel(`${key.slice(6)}-01`, locale);
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
export function groupByDateBucket<T extends { date: string }>(
|
||||
items: T[],
|
||||
options?: { past?: boolean },
|
||||
options?: { past?: boolean; today?: string; locale?: string },
|
||||
): DateBucket<T>[] {
|
||||
const bucketKeyFor = options?.past ? getPastBucketKey : getBucketKey;
|
||||
const today = getToday();
|
||||
const today = options?.today ?? getToday();
|
||||
const locale = options?.locale ?? i18n.locale;
|
||||
const bucketMap = new Map<string, { label: string; items: T[] }>();
|
||||
const bucketOrder: string[] = [];
|
||||
|
||||
@@ -79,7 +80,7 @@ export function groupByDateBucket<T extends { date: string }>(
|
||||
const key = bucketKeyFor(item.date, today);
|
||||
let bucket = bucketMap.get(key);
|
||||
if (!bucket) {
|
||||
bucket = { label: getBucketLabel(key), items: [] };
|
||||
bucket = { label: getBucketLabel(key, locale), items: [] };
|
||||
bucketMap.set(key, bucket);
|
||||
bucketOrder.push(key);
|
||||
}
|
||||
|
||||
@@ -187,4 +187,18 @@ describe("groupByDateBucket past option", () => {
|
||||
const result = groupByDateBucket([{ date: "2024-06-13" }]);
|
||||
expect(result[0].key).toBe("tomorrow");
|
||||
});
|
||||
|
||||
test("an explicit today overrides the real date", () => {
|
||||
const result = groupByDateBucket([{ date: "2026-01-06" }], { today: "2026-01-05" });
|
||||
expect(result[0].key).toBe("tomorrow");
|
||||
});
|
||||
|
||||
test("an explicit locale drives month labels", () => {
|
||||
const result = groupByDateBucket([{ date: "2026-02-20" }], {
|
||||
today: "2026-01-05",
|
||||
locale: "de",
|
||||
});
|
||||
expect(result[0].key).toBe("month_2026-02");
|
||||
expect(result[0].label).toBe("Februar");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user