Skip to content
Closed
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
18 changes: 18 additions & 0 deletions apps/mobile/src/Stack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { GitCommitSheet } from "./features/threads/git/GitCommitSheet";
import { GitConfirmSheet } from "./features/threads/git/GitConfirmSheet";
import { GitOverviewSheet } from "./features/threads/git/GitOverviewSheet";
import { ThreadRouteScreen } from "./features/threads/ThreadRouteScreen";
import { SnoozeForRouteScreen } from "./features/threads/SnoozeForRouteScreen";
import { ConnectionsRouteScreen } from "./features/connection/ConnectionsRouteScreen";
import { ConnectionsNewRouteScreen } from "./features/connection/ConnectionsNewRouteScreen";
import { HomeRouteScreen } from "./features/home/HomeRouteScreen";
Expand Down Expand Up @@ -376,6 +377,7 @@ const WORKSPACE_OVERLAY_ROUTES = new Set([
"NewTaskSheet",
"SettingsLegal",
"SettingsSheet",
"SnoozeFor",
"ThreadReviewComment",
"ThreadSettingsSheet",
]);
Expand Down Expand Up @@ -534,6 +536,22 @@ export const RootStack = createNativeStackNavigator({
sheetGrabberVisible: Platform.OS !== "android",
},
}),
SnoozeFor: createNativeStackScreen({
screen: SnoozeForRouteScreen,
linking: `${THREAD_LINKING_PREFIX}/snooze`,
options: {
gestureEnabled: true,
...(Platform.OS === "android"
? { presentation: "card" as const, headerShown: false }
: {
...SHEET_SOLID_HEADER_OPTIONS,
presentation: "formSheet" as const,
sheetAllowedDetents: [0.55, 0.8],
sheetGrabberVisible: true,
title: "Snooze until",
}),
},
}),
ThreadFiles: createNativeStackScreen({
screen: ThreadFilesTreeScreen,
linking: `${THREAD_LINKING_PREFIX}/files`,
Expand Down
11 changes: 11 additions & 0 deletions apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,16 @@ function AdaptiveWorkspaceLayoutContent(
[navigation],
);

const handleSnoozeFor = useCallback(
(thread: EnvironmentThreadShell) => {
navigation.navigate("SnoozeFor", {
environmentId: String(thread.environmentId),
threadId: String(thread.id),
});
},
[navigation],
);

const renderedSidebarWidth = useSharedValue(
panes.primarySidebarVisible ? (layout.listPaneWidth ?? 0) : 0,
);
Expand Down Expand Up @@ -571,6 +581,7 @@ function AdaptiveWorkspaceLayoutContent(
onOpenEnvironmentSettings={handleOpenEnvironmentSettings}
onNewThreadInProject={handleNewThreadInProject}
onNewThreadOnBranch={handleNewThreadOnBranch}
onSnoozeFor={handleSnoozeFor}
onSelectThread={handleSelectThread}
onSearchQueryChange={setPrimarySidebarSearchQuery}
searchQuery={primarySidebarSearchQuery}
Expand Down
136 changes: 136 additions & 0 deletions apps/mobile/src/features/threads/SnoozeForRouteScreen.logic.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { describe, expect, it } from "vite-plus/test";

import {
mergeAndroidPickerValue,
resolveAndroidMinimumDate,
resolveAndroidPickerValue,
resolveAndroidSnoozeValue,
} from "./SnoozeForRouteScreen.logic";

const wallTime = {
year: 2026,
month: 8,
day: 10,
hour: 13,
minute: 45,
} as const;

describe("resolveAndroidPickerValue", () => {
it("seeds Material DatePicker with UTC midnight for the local calendar day", () => {
const originalTimezone = process.env.TZ;
try {
process.env.TZ = "America/Los_Angeles";

expect(resolveAndroidPickerValue(wallTime, "date").toISOString()).toBe(
"2026-08-10T00:00:00.000Z",
);
const timeValue = resolveAndroidPickerValue(wallTime, "time");
expect(timeValue.getHours()).toBe(13);
expect(timeValue.getMinutes()).toBe(45);
} finally {
if (originalTimezone === undefined) {
delete process.env.TZ;
} else {
process.env.TZ = originalTimezone;
}
}
});
});

describe("resolveAndroidMinimumDate", () => {
it("allows the current local calendar day in western timezones", () => {
const originalTimezone = process.env.TZ;
try {
process.env.TZ = "America/Los_Angeles";
const localEvening = new Date(2026, 7, 10, 20, 30);

expect(localEvening.toISOString()).toContain("2026-08-11");
expect(resolveAndroidMinimumDate(localEvening).toISOString()).toBe(
"2026-08-10T00:00:00.000Z",
);
} finally {
if (originalTimezone === undefined) {
delete process.env.TZ;
} else {
process.env.TZ = originalTimezone;
}
}
});
});

describe("mergeAndroidPickerValue", () => {
it("reads Material DatePicker calendar parts from UTC", () => {
const originalTimezone = process.env.TZ;
try {
process.env.TZ = "America/Los_Angeles";
const selected = new Date(Date.UTC(2026, 7, 12));

const merged = mergeAndroidPickerValue(wallTime, selected, "date");

expect(selected.getDate()).toBe(11);
expect(merged).toEqual({ ...wallTime, day: 12 });
} finally {
if (originalTimezone === undefined) {
delete process.env.TZ;
} else {
process.env.TZ = originalTimezone;
}
}
});

it("keeps the current local day when merging a selected time", () => {
const selected = new Date(2026, 7, 10, 18, 30);

const merged = mergeAndroidPickerValue(wallTime, selected, "time");

expect(merged).toEqual({ ...wallTime, hour: 18, minute: 30 });
});

it("preserves a nonexistent Android wall time so validation can reject it", () => {
const originalTimezone = process.env.TZ;
try {
process.env.TZ = "America/Los_Angeles";
const current = { ...wallTime, month: 3, day: 8, hour: 1, minute: 30 };
const selected = new Date(2000, 0, 15, 2, 30);

const merged = mergeAndroidPickerValue(current, selected, "time");
const result = resolveAndroidSnoozeValue(merged, {
now: new Date(2026, 2, 7, 12),
});

expect(merged).toEqual({ ...current, hour: 2, minute: 30 });
expect(result).toEqual({ ok: false, error: "Choose a valid date and time." });
} finally {
if (originalTimezone === undefined) {
delete process.env.TZ;
} else {
process.env.TZ = originalTimezone;
}
}
});

it("uses the still-future occurrence of a repeated Android wall time", () => {
const originalTimezone = process.env.TZ;
try {
process.env.TZ = "America/Los_Angeles";
const current = { ...wallTime, month: 11, day: 1, hour: 0, minute: 30 };
const selected = new Date(2000, 0, 15, 1, 30);
const firstOccurrence = new Date(2026, 10, 1, 1, 30);
const secondOccurrence = new Date(firstOccurrence.getTime() + 60 * 60 * 1_000);

const merged = mergeAndroidPickerValue(current, selected, "time");
const result = resolveAndroidSnoozeValue(merged, {
now: new Date(firstOccurrence.getTime() + 30 * 60 * 1_000),
});

expect(secondOccurrence.getHours()).toBe(1);
expect(result).toEqual({ ok: true, value: secondOccurrence });
} finally {
if (originalTimezone === undefined) {
delete process.env.TZ;
} else {
process.env.TZ = originalTimezone;
}
}
});
});
59 changes: 59 additions & 0 deletions apps/mobile/src/features/threads/SnoozeForRouteScreen.logic.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import {
resolveSnoozeWallTime,
type SnoozeWallTime,
type SnoozeWallTimeResult,
} from "@t3tools/client-runtime/state/thread-settled";

export type AndroidSnoozePicker = "date" | "time";

export function resolveAndroidPickerValue(value: SnoozeWallTime, part: AndroidSnoozePicker): Date {
if (part === "time") return new Date(2000, 0, 15, value.hour, value.minute);
return new Date(Date.UTC(value.year, value.month - 1, value.day));
}

export function resolveAndroidMinimumDate(now: Date): Date {
return new Date(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()));
}

export function mergeAndroidPickerValue(
current: SnoozeWallTime,
selected: Date,
part: AndroidSnoozePicker,
): SnoozeWallTime {
if (part === "date") {
// Material DatePicker returns the chosen calendar day as UTC midnight.
return {
...current,
year: selected.getUTCFullYear(),
month: selected.getUTCMonth() + 1,
day: selected.getUTCDate(),
};
}

return { ...current, hour: selected.getHours(), minute: selected.getMinutes() };
}

export function resolveAndroidSnoozeValue(
value: SnoozeWallTime,
options: { readonly now: Date },
): SnoozeWallTimeResult {
return resolveSnoozeWallTime(value, options);
}

export function formatAndroidSnoozeDate(value: SnoozeWallTime): string {
return new Date(Date.UTC(value.year, value.month - 1, value.day)).toLocaleDateString(undefined, {
weekday: "short",
month: "short",
day: "numeric",
year: "numeric",
timeZone: "UTC",
});
}

export function formatAndroidSnoozeTime(value: SnoozeWallTime): string {
return new Date(Date.UTC(2000, 0, 15, value.hour, value.minute)).toLocaleTimeString(undefined, {
hour: "numeric",
minute: "2-digit",
timeZone: "UTC",
});
}
Loading
Loading