diff --git a/src/screens/CalendarIntegrationScreen.tsx b/src/screens/CalendarIntegrationScreen.tsx
index 1f42cf3..4db03dc 100644
--- a/src/screens/CalendarIntegrationScreen.tsx
+++ b/src/screens/CalendarIntegrationScreen.tsx
@@ -1,9 +1,11 @@
-import React, { useEffect } from 'react';
+import React, { useEffect, useCallback } from 'react';
import {
Alert,
Linking,
+ Platform,
SafeAreaView,
ScrollView,
+ Share,
StyleSheet,
Text,
TouchableOpacity,
@@ -17,6 +19,7 @@ import {
CALENDAR_PROVIDERS,
REMINDER_OFFSET_OPTIONS,
REMINDER_PRESETS,
+ SUBSCRIPTION_TIMEZONES,
type CalendarProvider,
} from '../types/calendar';
import { borderRadius, colors, spacing, typography } from '../utils/constants';
@@ -51,6 +54,9 @@ const CalendarIntegrationScreen: React.FC = () => {
pendingAuthorizations,
reminderOffsets,
error,
+ oneTimePayments,
+ scheduleConflicts,
+ timezone,
beginConnection,
completeConnection,
cancelConnection,
@@ -58,6 +64,11 @@ const CalendarIntegrationScreen: React.FC = () => {
setReminderOffsets,
toggleReminderOffset,
clearError,
+ addOneTimePayment,
+ cancelOneTimePayment,
+ checkConflicts,
+ exportCalendar,
+ setTimezone,
} = useCalendarStore();
const subscriptions = useSubscriptionStore((state) => state.subscriptions);
@@ -114,6 +125,51 @@ const CalendarIntegrationScreen: React.FC = () => {
.syncSubscriptions(useSubscriptionStore.getState().subscriptions);
};
+ const handleExportICal = useCallback(async () => {
+ try {
+ const payload = exportCalendar(subscriptions, timezone);
+ await Share.share({
+ message: payload.ical,
+ title: payload.filename,
+ });
+ Alert.alert('Calendar exported', `Exported ${payload.events.length} events to ${payload.filename}`);
+ } catch (exportError) {
+ Alert.alert('Export failed', exportError instanceof Error ? exportError.message : 'Could not export calendar.');
+ }
+ }, [subscriptions, timezone, exportCalendar]);
+
+ const handleCheckConflicts = useCallback(() => {
+ checkConflicts(subscriptions);
+ }, [subscriptions, checkConflicts]);
+
+ const handleScheduleOneTimePayment = useCallback(() => {
+ Alert.prompt
+ ? Alert.prompt(
+ 'Schedule one-time payment',
+ 'Enter subscription ID and amount (e.g., sub-1,29.99)',
+ [
+ { text: 'Cancel', style: 'cancel' },
+ {
+ text: 'Schedule',
+ onPress: (input?: string) => {
+ if (!input) return;
+ const [subId, amountStr] = input.split(',');
+ const amount = parseFloat(amountStr);
+ if (subId && !isNaN(amount)) {
+ addOneTimePayment(subId, amount, 'USD', new Date(), 'One-time payment');
+ Alert.alert('Scheduled', `One-time payment of ${amount} USD for ${subId}`);
+ }
+ },
+ },
+ ],
+ 'plain-text'
+ )
+ : Alert.alert(
+ 'Schedule one-time payment',
+ 'Use the calendar app to schedule one-time payments from the billing screen.'
+ );
+ }, [addOneTimePayment]);
+
const handleConnect = async (provider: CalendarProvider) => {
try {
const authorization = await beginConnection(provider);
@@ -353,6 +409,92 @@ const CalendarIntegrationScreen: React.FC = () => {
)}
+
+ Calendar export
+
+ Export all subscription renewal events as an iCal file for use with any calendar app.
+
+
+ Export iCal (.ics)
+
+
+
+
+ Timezone
+
+ Set your preferred timezone for calendar events. Current: {timezone}.
+
+
+ {SUBSCRIPTION_TIMEZONES.map((tz) => (
+ setTimezone(tz)}>
+
+ {tz}
+
+
+ ))}
+
+
+
+
+ Schedule conflicts
+
+ Detect overlapping subscription billing dates and total charges per day.
+
+
+ Check for conflicts
+
+ {scheduleConflicts.length > 0 ? (
+ scheduleConflicts.slice(0, 5).map((conflict) => (
+
+ {conflict.date}
+
+ {conflict.conflictingSubscriptions.length} subscriptions — {conflict.totalAmount.toFixed(2)} USD total
+
+ {conflict.conflictingSubscriptions.map((sub) => (
+
+ {sub.name}: {sub.currency} {sub.amount.toFixed(2)}
+
+ ))}
+
+ ))
+ ) : scheduleConflicts.length === 0 && (
+ No conflicts detected. Tap "Check for conflicts" to scan.
+ )}
+
+
+
+ One-time payments
+
+ Schedule one-time payments beyond recurring subscriptions.
+
+
+ Schedule payment
+
+ {oneTimePayments.length > 0 ? (
+ oneTimePayments.map((payment) => (
+
+ {payment.description}
+
+ {payment.currency} {payment.amount.toFixed(2)} — {payment.status}
+
+ {new Date(payment.scheduledDate).toLocaleDateString()}
+ {payment.status === 'pending' && (
+ cancelOneTimePayment(payment.id)}>
+ Cancel
+
+ )}
+
+ ))
+ ) : (
+ No one-time payments scheduled.
+ )}
+
+
{error ? (
{error}
@@ -480,6 +622,25 @@ const styles = StyleSheet.create({
borderColor: `${colors.error}66`,
},
errorButtonText: { ...typography.caption, color: colors.error, fontWeight: '600' },
+ timezoneScroll: { marginTop: spacing.sm },
+ timezoneChip: {
+ paddingVertical: spacing.sm,
+ paddingHorizontal: spacing.md,
+ borderRadius: borderRadius.full,
+ borderWidth: 1,
+ borderColor: colors.border,
+ backgroundColor: colors.surface,
+ marginRight: spacing.sm,
+ },
+ conflictRow: {
+ paddingVertical: spacing.sm,
+ borderBottomWidth: 1,
+ borderBottomColor: colors.border,
+ gap: spacing.xs,
+ },
+ conflictDate: { ...typography.body, color: colors.text, fontWeight: '600' },
+ conflictDetail: { ...typography.caption, color: colors.textSecondary },
+ conflictSub: { ...typography.small, color: colors.textSecondary, paddingLeft: spacing.sm },
});
export default CalendarIntegrationScreen;
diff --git a/src/services/calendarService.ts b/src/services/calendarService.ts
index 690f030..d2f4248 100644
--- a/src/services/calendarService.ts
+++ b/src/services/calendarService.ts
@@ -1,11 +1,16 @@
+import { BillingCycle } from '../types/subscription';
import type { Subscription } from '../types/subscription';
import type {
+ CalendarExportPayload,
CalendarOAuthCallbackPayload,
CalendarEventTemplate,
CalendarIntegration,
CalendarProvider,
CalendarSyncedEvent,
+ OneTimeScheduledPayment,
PendingCalendarAuthorization,
+ ProratedAdjustment,
+ ScheduleConflict,
} from '../types/calendar';
const DEFAULT_REDIRECT_URI = 'subtrackr://calendar/callback';
@@ -240,3 +245,236 @@ export async function syncToCalendar(
export async function disconnectCalendar(connectionId: string): Promise<{ connectionId: string }> {
return { connectionId };
}
+
+// ── iCal Export ────────────────────────────────────────────────────────────
+
+function escapeICalText(text: string): string {
+ return text
+ .replace(/\\/g, '\\\\')
+ .replace(/;/g, '\\;')
+ .replace(/,/g, '\\,')
+ .replace(/\n/g, '\\n');
+}
+
+function formatICalDate(isoString: string): string {
+ const d = new Date(isoString);
+ return d.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
+}
+
+export function generateICalendarExport(
+ events: CalendarEventTemplate[],
+ timezone?: string
+): CalendarExportPayload {
+ const now = new Date().toISOString();
+ const lines: string[] = [
+ 'BEGIN:VCALENDAR',
+ 'VERSION:2.0',
+ 'PRODID:-//SubTrackr//Subscription Calendar//EN',
+ 'CALSCALE:GREGORIAN',
+ 'METHOD:PUBLISH',
+ ];
+
+ if (timezone) {
+ lines.push('BEGIN:VTIMEZONE');
+ lines.push(`TZID:${escapeICalText(timezone)}`);
+ lines.push('END:VTIMEZONE');
+ }
+
+ for (const event of events) {
+ const uid = `${event.kind}_${event.startAt}_${Math.random().toString(36).slice(2, 8)}`;
+ lines.push('BEGIN:VEVENT');
+ lines.push(`UID:${uid}@subtrackr`);
+ lines.push(`DTSTAMP:${formatICalDate(now)}`);
+ lines.push(`DTSTART:${formatICalDate(event.startAt)}`);
+ lines.push(`DTEND:${formatICalDate(event.endAt)}`);
+ lines.push(`SUMMARY:${escapeICalText(event.title)}`);
+ lines.push(`DESCRIPTION:${escapeICalText(event.notes)}`);
+
+ if (timezone) {
+ lines.push(`TZID:${escapeICalText(timezone)}`);
+ }
+
+ for (const offsetMinutes of event.reminderOffsets) {
+ const trigger = offsetMinutes > 0 ? `-PT${offsetMinutes}M` : `PT${Math.abs(offsetMinutes)}M`;
+ lines.push('BEGIN:VALARM');
+ lines.push('ACTION:DISPLAY');
+ lines.push(`TRIGGER:${trigger}`);
+ lines.push(`DESCRIPTION:Reminder: ${escapeICalText(event.title)}`);
+ lines.push('END:VALARM');
+ }
+
+ lines.push('END:VEVENT');
+ }
+
+ lines.push('END:VCALENDAR');
+
+ return {
+ ical: lines.join('\r\n'),
+ filename: `subtrackr_events_${Date.now()}.ics`,
+ events,
+ };
+}
+
+// ── Schedule Conflict Detection ────────────────────────────────────────────
+
+export function detectScheduleConflicts(
+ subscriptions: Subscription[],
+ existingEvents: CalendarSyncedEvent[]
+): ScheduleConflict[] {
+ const conflictsByDate = new Map();
+
+ for (const sub of subscriptions) {
+ if (!sub.isActive) continue;
+ const dateKey = new Date(sub.nextBillingDate).toISOString().split('T')[0];
+
+ if (!conflictsByDate.has(dateKey)) {
+ conflictsByDate.set(dateKey, {
+ date: dateKey,
+ conflictingSubscriptions: [],
+ totalAmount: 0,
+ });
+ }
+
+ const entry = conflictsByDate.get(dateKey)!;
+ entry.conflictingSubscriptions.push({
+ id: sub.id,
+ name: sub.name,
+ amount: sub.price,
+ currency: sub.currency,
+ });
+ entry.totalAmount += sub.price;
+ }
+
+ return Array.from(conflictsByDate.values()).filter((c) => c.conflictingSubscriptions.length > 1);
+}
+
+// ── Prorated Adjustment ────────────────────────────────────────────────────
+
+export function calculateProratedAdjustment(
+ subscription: Subscription,
+ newDate: Date,
+ reason: string
+): ProratedAdjustment {
+ const now = new Date();
+ const nextBilling = new Date(subscription.nextBillingDate);
+ const daysInCycle = getDaysInCycle(subscription.billingCycle);
+
+ const msPerDay = 24 * 60 * 60 * 1000;
+ const daysRemaining = Math.max(0, Math.round((nextBilling.getTime() - now.getTime()) / msPerDay));
+
+ const proratedAmount = Number(
+ ((subscription.price / daysInCycle) * daysRemaining).toFixed(2)
+ );
+
+ return {
+ originalAmount: subscription.price,
+ proratedAmount,
+ daysRemaining,
+ daysInCycle,
+ effectiveDate: newDate.toISOString(),
+ reason,
+ };
+}
+
+function getDaysInCycle(cycle: BillingCycle): number {
+ switch (cycle) {
+ case BillingCycle.WEEKLY:
+ return 7;
+ case BillingCycle.MONTHLY:
+ return 30;
+ case BillingCycle.YEARLY:
+ return 365;
+ default:
+ return 30;
+ }
+}
+
+// ── One-Time Scheduled Payment ─────────────────────────────────────────────
+
+export function scheduleOneTimePayment(
+ subscriptionId: string,
+ amount: number,
+ currency: string,
+ scheduledDate: Date,
+ description: string
+): OneTimeScheduledPayment {
+ return {
+ id: `onetime_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
+ subscriptionId,
+ amount,
+ currency,
+ scheduledDate: scheduledDate.toISOString(),
+ description,
+ status: 'pending',
+ createdAt: new Date().toISOString(),
+ };
+}
+
+// ── Timezone-Aware Event Building ─────────────────────────────────────────
+
+export function buildTimezoneAwareEvent(
+ subscription: Subscription,
+ reminderOffsets: number[]
+): CalendarEventTemplate {
+ const event = buildSubscriptionCalendarEvent(subscription, reminderOffsets);
+ const tz = subscription.timezone || 'UTC';
+
+ if (tz !== 'UTC') {
+ const start = new Date(event.startAt);
+ const end = new Date(event.endAt);
+ event.notes += ` Timezone: ${tz}.`;
+ event.startAt = start.toISOString();
+ event.endAt = end.toISOString();
+ }
+
+ return event;
+}
+
+export function formatDateInTimezone(isoString: string, timezone: string): string {
+ try {
+ const date = new Date(isoString);
+ return date.toLocaleString('en-US', { timeZone: timezone });
+ } catch {
+ return new Date(isoString).toLocaleString();
+ }
+}
+
+export function convertToTimezone(isoString: string, targetTimezone: string): string {
+ const date = new Date(isoString);
+ const offset = getTimezoneOffset(date, targetTimezone);
+ const adjusted = new Date(date.getTime() + offset);
+ return adjusted.toISOString();
+}
+
+function getTimezoneOffset(date: Date, timezone: string): number {
+ try {
+ const tzDate = new Date(date.toLocaleString('en-US', { timeZone: timezone }));
+ return tzDate.getTime() - date.getTime();
+ } catch {
+ return 0;
+ }
+}
+
+// ── Check for DST Transition ──────────────────────────────────────────────
+
+export function isDSTTransitionPeriod(date: Date, timezone: string): boolean {
+ const oneWeekLater = new Date(date.getTime() + 7 * 24 * 60 * 60 * 1000);
+ const currentOffset = getTimezoneOffset(date, timezone);
+ const laterOffset = getTimezoneOffset(oneWeekLater, timezone);
+ return currentOffset !== laterOffset;
+}
+
+export function adjustForDST(event: CalendarEventTemplate, timezone: string): CalendarEventTemplate {
+ const startDate = new Date(event.startAt);
+ if (isDSTTransitionPeriod(startDate, timezone)) {
+ const offset = getTimezoneOffset(startDate, timezone);
+ const adjusted = new Date(startDate.getTime() - offset);
+ return {
+ ...event,
+ startAt: adjusted.toISOString(),
+ endAt: new Date(adjusted.getTime() + 30 * 60 * 1000).toISOString(),
+ notes: `${event.notes} (DST adjusted for ${timezone})`,
+ };
+ }
+ return event;
+}
diff --git a/src/store/calendarStore.ts b/src/store/calendarStore.ts
index 19b7649..ca02acd 100644
--- a/src/store/calendarStore.ts
+++ b/src/store/calendarStore.ts
@@ -5,18 +5,26 @@ import { createJSONStorage, persist } from 'zustand/middleware';
import {
beginCalendarOAuth,
buildSubscriptionCalendarEvent,
+ calculateProratedAdjustment,
connectCalendar,
createCalendarOAuthCallbackUrl,
+ detectScheduleConflicts,
disconnectCalendar,
+ generateICalendarExport,
normalizeReminderOffsets,
parseCalendarOAuthCallback,
+ scheduleOneTimePayment,
syncToCalendar,
} from '../services/calendarService';
import type {
+ CalendarExportPayload,
CalendarIntegration,
CalendarProvider,
CalendarSyncedEvent,
+ OneTimeScheduledPayment,
PendingCalendarAuthorization,
+ ProratedAdjustment,
+ ScheduleConflict,
} from '../types/calendar';
import { REMINDER_PRESETS } from '../types/calendar';
import type { Subscription } from '../types/subscription';
@@ -32,6 +40,9 @@ interface CalendarState {
pendingAuthorizations: PendingAuthorizationMap;
isLoading: boolean;
error: string | null;
+ oneTimePayments: OneTimeScheduledPayment[];
+ scheduleConflicts: ScheduleConflict[];
+ timezone: string;
beginConnection: (provider: CalendarProvider) => Promise;
completeConnection: (
provider: CalendarProvider,
@@ -46,6 +57,23 @@ interface CalendarState {
syncSubscriptionToCalendars: (subscription: Subscription) => Promise;
syncSubscriptions: (subscriptions: Subscription[]) => Promise;
removeSubscriptionFromCalendars: (subscriptionId: string) => Promise;
+ addOneTimePayment: (
+ subscriptionId: string,
+ amount: number,
+ currency: string,
+ scheduledDate: Date,
+ description: string
+ ) => void;
+ cancelOneTimePayment: (paymentId: string) => void;
+ getOneTimePayments: () => OneTimeScheduledPayment[];
+ checkConflicts: (subscriptions: Subscription[]) => void;
+ exportCalendar: (subscriptions: Subscription[], timezone?: string) => CalendarExportPayload;
+ calculateProratedCharge: (
+ subscription: Subscription,
+ newDate: Date,
+ reason: string
+ ) => ProratedAdjustment;
+ setTimezone: (timezone: string) => void;
}
function removeProviderPendingState(
@@ -80,6 +108,9 @@ export const useCalendarStore = create()(
pendingAuthorizations: {},
isLoading: false,
error: null,
+ oneTimePayments: [],
+ scheduleConflicts: [],
+ timezone: 'UTC',
beginConnection: async (provider) => {
set({ isLoading: true, error: null });
@@ -203,6 +234,43 @@ export const useCalendarStore = create()(
set({ error: null });
},
+ addOneTimePayment: (subscriptionId, amount, currency, scheduledDate, description) => {
+ const payment = scheduleOneTimePayment(subscriptionId, amount, currency, scheduledDate, description);
+ set((state) => ({
+ oneTimePayments: [...state.oneTimePayments, payment],
+ }));
+ },
+
+ cancelOneTimePayment: (paymentId) => {
+ set((state) => ({
+ oneTimePayments: state.oneTimePayments.map((p) =>
+ p.id === paymentId ? { ...p, status: 'cancelled' as const } : p
+ ),
+ }));
+ },
+
+ getOneTimePayments: () => get().oneTimePayments,
+
+ checkConflicts: (subscriptions) => {
+ const conflicts = detectScheduleConflicts(subscriptions, get().syncedEvents);
+ set({ scheduleConflicts: conflicts });
+ },
+
+ exportCalendar: (subscriptions, timezone) => {
+ const events = subscriptions
+ .filter((s) => s.isActive)
+ .map((s) => buildSubscriptionCalendarEvent(s, get().reminderOffsets));
+ return generateICalendarExport(events, timezone || get().timezone);
+ },
+
+ calculateProratedCharge: (subscription, newDate, reason) => {
+ return calculateProratedAdjustment(subscription, newDate, reason);
+ },
+
+ setTimezone: (timezone) => {
+ set({ timezone });
+ },
+
syncSubscriptionToCalendars: async (subscription) => {
const { integrations, syncedEvents } = get();
const activeIntegrations = integrations.filter(isConnected);
@@ -280,6 +348,8 @@ export const useCalendarStore = create()(
integrations: state.integrations,
syncedEvents: state.syncedEvents,
reminderOffsets: state.reminderOffsets,
+ oneTimePayments: state.oneTimePayments,
+ timezone: state.timezone,
}),
}
)
diff --git a/src/types/calendar.ts b/src/types/calendar.ts
index 736492a..f2158f9 100644
--- a/src/types/calendar.ts
+++ b/src/types/calendar.ts
@@ -27,7 +27,7 @@ export interface CalendarIntegration {
reminderOffsets: number[];
}
-export type CalendarEventKind = 'billing_reminder';
+export type CalendarEventKind = 'billing_reminder' | 'one_time_payment';
export interface CalendarEventTemplate {
kind: CalendarEventKind;
@@ -38,6 +38,56 @@ export interface CalendarEventTemplate {
reminderOffsets: number[];
}
+export interface OneTimeScheduledPayment {
+ id: string;
+ subscriptionId: string;
+ amount: number;
+ currency: string;
+ scheduledDate: string;
+ description: string;
+ status: 'pending' | 'processed' | 'cancelled';
+ createdAt: string;
+}
+
+export interface ScheduleConflict {
+ date: string;
+ conflictingSubscriptions: { id: string; name: string; amount: number; currency: string }[];
+ totalAmount: number;
+}
+
+export interface ProratedAdjustment {
+ originalAmount: number;
+ proratedAmount: number;
+ daysRemaining: number;
+ daysInCycle: number;
+ effectiveDate: string;
+ reason: string;
+}
+
+export interface CalendarExportPayload {
+ ical: string;
+ filename: string;
+ events: CalendarEventTemplate[];
+}
+
+export const SUBSCRIPTION_TIMEZONES = [
+ 'UTC',
+ 'America/New_York',
+ 'America/Chicago',
+ 'America/Denver',
+ 'America/Los_Angeles',
+ 'Europe/London',
+ 'Europe/Berlin',
+ 'Europe/Paris',
+ 'Asia/Tokyo',
+ 'Asia/Shanghai',
+ 'Asia/Kolkata',
+ 'Australia/Sydney',
+ 'Pacific/Auckland',
+] as const;
+
+export type SubscriptionTimezone = typeof SUBSCRIPTION_TIMEZONES[number];
+
export interface CalendarSyncedEvent extends CalendarEventTemplate {
id: string;
subscriptionId: string;
diff --git a/src/types/subscription.ts b/src/types/subscription.ts
index bb5862c..f213bb0 100644
--- a/src/types/subscription.ts
+++ b/src/types/subscription.ts
@@ -18,6 +18,7 @@ export interface Subscription {
totalGasSpent?: number;
chargeCount?: number;
lastGasCost?: number;
+ timezone?: string;
createdAt: Date;
updatedAt: Date;
}