Redesign mobile time clock, add shortcuts, and improve account management.
Add iOS Shortcuts/Siri intents, local send-reminder notifications, stable client picker with last-client defaults, account refresh/remove, and softer session handling on unauthorized API responses. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import * as Notifications from "expo-notifications";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
const REMINDER_PREFIX = "invoice-send-reminder:";
|
||||
const FIRED_PREFIX = "invoice-reminder-fired:";
|
||||
|
||||
export type InvoiceSendReminderSource = {
|
||||
id: string;
|
||||
status: string;
|
||||
invoiceNumber: string;
|
||||
invoicePrefix: string | null;
|
||||
sendReminderAt: Date | string | null | undefined;
|
||||
client?: { name: string } | null;
|
||||
};
|
||||
|
||||
export function invoiceSendReminderNotificationId(invoiceId: string) {
|
||||
return `${REMINDER_PREFIX}${invoiceId}`;
|
||||
}
|
||||
|
||||
Notifications.setNotificationHandler({
|
||||
handleNotification: async () => ({
|
||||
shouldShowAlert: true,
|
||||
shouldPlaySound: true,
|
||||
shouldSetBadge: false,
|
||||
shouldShowBanner: true,
|
||||
shouldShowList: true,
|
||||
}),
|
||||
});
|
||||
|
||||
async function ensureAndroidChannel() {
|
||||
if (Platform.OS !== "android") return;
|
||||
await Notifications.setNotificationChannelAsync("invoice-reminders", {
|
||||
name: "Invoice reminders",
|
||||
importance: Notifications.AndroidImportance.HIGH,
|
||||
sound: "default",
|
||||
vibrationPattern: [0, 250, 250, 250],
|
||||
});
|
||||
}
|
||||
|
||||
export async function ensureNotificationPermissions(): Promise<boolean> {
|
||||
if (Platform.OS === "web") return false;
|
||||
|
||||
await ensureAndroidChannel();
|
||||
|
||||
const { status: existing } = await Notifications.getPermissionsAsync();
|
||||
if (existing === "granted") return true;
|
||||
|
||||
const { status } = await Notifications.requestPermissionsAsync({
|
||||
ios: {
|
||||
allowAlert: true,
|
||||
allowBadge: false,
|
||||
allowSound: true,
|
||||
},
|
||||
});
|
||||
|
||||
return status === "granted";
|
||||
}
|
||||
|
||||
function reminderContent(invoice: InvoiceSendReminderSource): Notifications.NotificationContentInput {
|
||||
const label = `${invoice.invoicePrefix ?? "#"}${invoice.invoiceNumber}`;
|
||||
const clientName = invoice.client?.name ?? "your client";
|
||||
|
||||
return {
|
||||
title: "Time to send invoice",
|
||||
body: `${label} for ${clientName} is ready to send.`,
|
||||
data: {
|
||||
invoiceId: invoice.id,
|
||||
type: "invoice-send-reminder",
|
||||
},
|
||||
sound: true,
|
||||
...(Platform.OS === "android" ? { channelId: "invoice-reminders" } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function syncInvoiceSendReminders(invoices: InvoiceSendReminderSource[]) {
|
||||
if (Platform.OS === "web") return;
|
||||
|
||||
const granted = await ensureNotificationPermissions();
|
||||
if (!granted) return;
|
||||
|
||||
const scheduled = await Notifications.getAllScheduledNotificationsAsync();
|
||||
const ourScheduled = new Set(
|
||||
scheduled
|
||||
.map((entry) => entry.identifier)
|
||||
.filter((id): id is string => Boolean(id?.startsWith(REMINDER_PREFIX))),
|
||||
);
|
||||
|
||||
const wanted = new Set<string>();
|
||||
const now = Date.now();
|
||||
|
||||
for (const invoice of invoices) {
|
||||
if (invoice.status !== "draft" || !invoice.sendReminderAt) continue;
|
||||
|
||||
const notificationId = invoiceSendReminderNotificationId(invoice.id);
|
||||
wanted.add(notificationId);
|
||||
|
||||
const reminderAt = new Date(invoice.sendReminderAt);
|
||||
const reminderMs = reminderAt.getTime();
|
||||
if (Number.isNaN(reminderMs)) continue;
|
||||
|
||||
const firedKey = `${FIRED_PREFIX}${invoice.id}`;
|
||||
const firedAt = await AsyncStorage.getItem(firedKey);
|
||||
const content = reminderContent(invoice);
|
||||
|
||||
await Notifications.cancelScheduledNotificationAsync(notificationId).catch(() => {});
|
||||
|
||||
if (reminderMs <= now) {
|
||||
const alreadyFiredForThisDate = firedAt === reminderAt.toISOString();
|
||||
if (alreadyFiredForThisDate) continue;
|
||||
|
||||
await Notifications.scheduleNotificationAsync({
|
||||
identifier: notificationId,
|
||||
content,
|
||||
trigger: {
|
||||
type: Notifications.SchedulableTriggerInputTypes.TIME_INTERVAL,
|
||||
seconds: 2,
|
||||
},
|
||||
});
|
||||
await AsyncStorage.setItem(firedKey, reminderAt.toISOString());
|
||||
continue;
|
||||
}
|
||||
|
||||
if (firedAt && firedAt !== reminderAt.toISOString()) {
|
||||
await AsyncStorage.removeItem(firedKey);
|
||||
}
|
||||
|
||||
await Notifications.scheduleNotificationAsync({
|
||||
identifier: notificationId,
|
||||
content,
|
||||
trigger: {
|
||||
type: Notifications.SchedulableTriggerInputTypes.DATE,
|
||||
date: reminderAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
for (const notificationId of ourScheduled) {
|
||||
if (wanted.has(notificationId)) continue;
|
||||
await Notifications.cancelScheduledNotificationAsync(notificationId).catch(() => {});
|
||||
const invoiceId = notificationId.slice(REMINDER_PREFIX.length);
|
||||
await AsyncStorage.removeItem(`${FIRED_PREFIX}${invoiceId}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function cancelInvoiceSendReminder(invoiceId: string) {
|
||||
if (Platform.OS === "web") return;
|
||||
const notificationId = invoiceSendReminderNotificationId(invoiceId);
|
||||
await Notifications.cancelScheduledNotificationAsync(notificationId).catch(() => {});
|
||||
await AsyncStorage.removeItem(`${FIRED_PREFIX}${invoiceId}`);
|
||||
}
|
||||
Reference in New Issue
Block a user