diff --git a/.github/workflows/release-android.yml b/.github/workflows/release-android.yml index 50d7872b..712d02a6 100644 --- a/.github/workflows/release-android.yml +++ b/.github/workflows/release-android.yml @@ -112,7 +112,7 @@ jobs: if [ -n "$TAG" ]; then echo "Will attach APK to release: $TAG" else - echo "No release tag resolved — APK is in workflow artifacts only." + echo "No release tag resolved - APK is in workflow artifacts only." fi - name: Attach APK to release diff --git a/App.tsx b/App.tsx index 529120c3..8640aefb 100644 --- a/App.tsx +++ b/App.tsx @@ -190,7 +190,7 @@ export default function App() { }, []); // Listen for FCM messages so the app can refresh state even when woken - // from the background — the FirebaseMessagingService also posts the system + // from the background - the FirebaseMessagingService also posts the system // notification so the user sees it regardless of RN runtime state. React.useEffect(() => { const unsubscribe = addMessageListener((payload) => { diff --git a/CALENDAR_PLAN.md b/CALENDAR_PLAN.md index 6f241db2..cacfbe70 100644 --- a/CALENDAR_PLAN.md +++ b/CALENDAR_PLAN.md @@ -1,4 +1,4 @@ -# React Native Calendar — Parity Plan +# React Native Calendar - Parity Plan Bring `repos/react-native/` calendar to parity with the webmail calendar at `components/calendar/`. Webmail totals ~5,940 lines across 19 components plus @@ -7,52 +7,55 @@ essentials and explicit deferrals for things that don't fit a phone UX. ## Current state -- `src/screens/CalendarScreenNew.tsx` — month grid + agenda-for-selected-day, +- `src/screens/CalendarScreenNew.tsx` - month grid + agenda-for-selected-day, all rendering `MOCK_EVENTS`. Doesn't touch the store. -- `src/stores/calendar-store.ts` — CRUD wiring exists (`fetchCalendars`, +- `src/stores/calendar-store.ts` - CRUD wiring exists (`fetchCalendars`, `fetchEvents`, `createEvent`, `updateEvent`, `deleteEvent`) but no `handleStateChange`, no per-calendar visibility, no range tracking. -- `src/api/calendar.ts` — JMAP methods wired (`Calendar/get`, +- `src/api/calendar.ts` - JMAP methods wired (`Calendar/get`, `CalendarEvent/query`, `CalendarEvent/get`, `CalendarEvent/set`). -- `src/api/types.ts` — `CalendarEvent` and `Calendar` types match webmail. +- `src/api/types.ts` - `CalendarEvent` and `Calendar` types match webmail. - No event modal, no detail view, no week/day view, no sidebar, no recurrence. ## In scope -### Phase 1 — Data layer (foundation for everything else) +### Phase 1 - Data layer (foundation for everything else) + +**`src/stores/calendar-store.ts`** - expand to match webmail's store semantics: -**`src/stores/calendar-store.ts`** — expand to match webmail's store semantics: - `hiddenCalendarIds: Set` persisted via AsyncStorage (matches webmail's `CalendarSidebar` toggle state). -- `loadedRange: { after: string; before: string } | null` — track what's fetched +- `loadedRange: { after: string; before: string } | null` - track what's fetched so view changes don't re-fetch unnecessarily. -- `ensureRange(after, before)` — fetch only if the requested window isn't +- `ensureRange(after, before)` - fetch only if the requested window isn't covered by `loadedRange`; expand the loaded range in place instead of replacing events wholesale, so switching Month → Week doesn't drop data. -- `handleStateChange(change)` — subscribe to `Calendar` and `CalendarEvent` +- `handleStateChange(change)` - subscribe to `Calendar` and `CalendarEvent` typeState keys; on push, refetch calendars and re-query the current window. - Already referenced from `App.tsx` push handler — this finally implements it. + Already referenced from `App.tsx` push handler - this finally implements it. - `toggleCalendarVisibility(id)` / `setCalendarHidden(id, hidden)`. - Selectors: `visibleCalendars`, `visibleEvents` (filtered by hidden set). -**`src/lib/calendar-utils.ts`** (new) — port from webmail's `lib/calendar-utils.ts`: -- `parseLocalDateTime(iso)` — JSCalendar start strings are floating local time +**`src/lib/calendar-utils.ts`** (new) - port from webmail's `lib/calendar-utils.ts`: + +- `parseLocalDateTime(iso)` - JSCalendar start strings are floating local time (`2026-04-20T09:00:00`, no Z). Parse without timezone shifts. -- `parseDuration(iso8601)` — `PT1H30M` → milliseconds. -- `eventTimeRange(event)` — returns `{ start: Date, end: Date, allDay: boolean }` +- `parseDuration(iso8601)` - `PT1H30M` → milliseconds. +- `eventTimeRange(event)` - returns `{ start: Date, end: Date, allDay: boolean }` handling `showWithoutTime`. -- `eventsOnDay(events, day)` — day overlap check (not just start-matches-day). -- `getCalendarColor(cal)` — fallback to a deterministic palette when `color` +- `eventsOnDay(events, day)` - day overlap check (not just start-matches-day). +- `getCalendarColor(cal)` - fallback to a deterministic palette when `color` is missing, matching `theme/tokens.ts` `colors.calendar.*`. -**`src/lib/recurrence-expansion.ts`** (new) — minimal port of webmail's RRULE +**`src/lib/recurrence-expansion.ts`** (new) - minimal port of webmail's RRULE expander. Webmail's version supports `byDay`/`byMonth`/`byMonthDay`/`count`/`until`. -Mobile port: same surface, same tests. This is the hard part — allocate ~400 +Mobile port: same surface, same tests. This is the hard part - allocate ~400 lines. Drives correct rendering of recurring events across the visible window. -### Phase 2 — Views +### Phase 2 - Views + +**`src/screens/CalendarScreenNew.tsx`** - replace mocks, gain view switching: -**`src/screens/CalendarScreenNew.tsx`** — replace mocks, gain view switching: - On mount: `fetchCalendars()`, then `ensureRange` for visible window. - View mode toggle → Month / Week / Agenda (Day view folded into Week for phone). - Header shows month/week label; prev/next shifts by the current view's step. @@ -60,12 +63,13 @@ lines. Drives correct rendering of recurring events across the visible window. - FAB opens create modal with `start = selectedDate at next half hour`. **`src/components/calendar/MonthView.tsx`** (extract from current screen, -~180 lines) — real events, up to 3 colored dots per day, "+N" indicator when +~180 lines) - real events, up to 3 colored dots per day, "+N" indicator when more. Tap day → selects + scrolls agenda below. Long-press day → opens create modal with that date. -**`src/components/calendar/WeekView.tsx`** (new, ~350 lines) — port of +**`src/components/calendar/WeekView.tsx`** (new, ~350 lines) - port of `calendar-week-view.tsx` (511 lines) trimmed for mobile: + - 7 day columns, hour rows from 6am–10pm scrollable to 0–24. - All-day strip at top. - Events positioned absolutely by `utcStart`/`utcEnd`, width shared among @@ -73,35 +77,37 @@ modal with that date. - Tap event → detail modal. Long-press empty slot → create modal pre-filled. - Horizontal swipe → prev/next week (react-native-gesture-handler already available via react-navigation). -- *Deferred*: drag-to-reschedule, drag-to-resize. Phone targets are too small +- _Deferred_: drag-to-reschedule, drag-to-resize. Phone targets are too small for it to be better than tap-edit. -**`src/components/calendar/AgendaView.tsx`** (new, ~120 lines) — port of +**`src/components/calendar/AgendaView.tsx`** (new, ~120 lines) - port of `calendar-agenda-view.tsx`. Flat list of upcoming events grouped by day, 30 days forward. Taps open detail modal. -### Phase 3 — Event interaction +### Phase 3 - Event interaction -**`src/components/calendar/EventDetailSheet.tsx`** (new, ~280 lines) — bottom +**`src/components/calendar/EventDetailSheet.tsx`** (new, ~280 lines) - bottom sheet presenting a single event, port of `event-detail-popover.tsx` (631 lines -on web — phone version drops the hover card chrome and the inline-edit fields): +on web - phone version drops the hover card chrome and the inline-edit fields): + - Header: calendar color bar, title, date/time range (or "All day"), calendar name. - Sections: location, description (linkified), participants with RSVP status, - recurrence summary (e.g. "Weekly on Mon, Wed — until 2026-06-01"), reminders. + recurrence summary (e.g. "Weekly on Mon, Wed - until 2026-06-01"), reminders. - Actions row: Edit, Delete, Duplicate, "Open in email" if created from invite. - If recurring: delete/edit prompts via `RecurrenceScopeDialog` ("This event only" / "This and following" / "All events"). -**`src/components/calendar/EventModal.tsx`** (new, ~550 lines) — full-screen +**`src/components/calendar/EventModal.tsx`** (new, ~550 lines) - full-screen modal for create + edit, port of `event-modal.tsx` (1,072 lines on web, trimmed): + - Title, calendar picker (color swatches), all-day toggle. -- Start/end date+time pickers (`@react-native-community/datetimepicker` — add dep). +- Start/end date+time pickers (`@react-native-community/datetimepicker` - add dep). - Location, description (multiline). - Participant input with autocomplete from contacts store (port of `participant-input.tsx`, ~180 lines). - Repeat picker: None / Daily / Weekly (weekday chips) / Monthly / Yearly, plus end: Never / After N / On date. Advanced iCal rules (BYSETPOS etc.) - are deferred — covers the 95% case. + are deferred - covers the 95% case. - Alerts: None / At start / 5m / 15m / 30m / 1h / 1d before, mapping to `Alert.trigger.offset`. - Timezone: default to device TZ; optional override picker hidden behind @@ -109,25 +115,26 @@ modal for create + edit, port of `event-modal.tsx` (1,072 lines on web, trimmed) - Save path handles "this instance only" override vs master update for recurring events (write to `recurrenceOverrides[recurrenceId]`). -**`src/components/calendar/RecurrenceScopeDialog.tsx`** (new, ~90 lines) — +**`src/components/calendar/RecurrenceScopeDialog.tsx`** (new, ~90 lines) - direct port of `recurrence-scope-dialog.tsx`. Three-button picker before edit/delete of a recurring instance. -### Phase 4 — Multi-calendar +### Phase 4 - Multi-calendar -**`src/components/calendar/CalendarSidebarDrawer.tsx`** (new, ~200 lines) — +**`src/components/calendar/CalendarSidebarDrawer.tsx`** (new, ~200 lines) - port of `calendar-sidebar-panel.tsx` (273 lines). Slides in from the left (reuses `SidebarDrawer.tsx`): + - List of calendars with color swatch + visibility toggle + name. - "My calendars" vs "Subscribed" sections if `myRights` differs. - Tap calendar → highlight; long-press → rename/color/hide (optional, phase 4.5). - Footer: "Add calendar" (deferred to Phase 6). -**`src/theme/tokens.ts`** — audit `colors.calendar.*` palette against webmail's +**`src/theme/tokens.ts`** - audit `colors.calendar.*` palette against webmail's calendar color constants (`lib/calendar-utils.ts` `CALENDAR_COLOR_PALETTE`); add any missing shades. -### Phase 5 — Polish +### Phase 5 - Polish - Pull-to-refresh on each view → forces re-fetch of current window. - Empty states per view (no calendars yet / no events this month). @@ -141,28 +148,28 @@ add any missing shades. These are in the webmail but don't make sense for a first mobile pass: - **iCal import/subscribe modals** (`ical-import-modal.tsx` 455 lines, - `ical-subscription-modal.tsx` 220 lines) — file picker + URL subscription + `ical-subscription-modal.tsx` 220 lines) - file picker + URL subscription with auth flows. Better as a dedicated settings flow later. -- **Tasks module** (`task-list-view.tsx`, `task-modal.tsx`, `task-toolbar.tsx`) — +- **Tasks module** (`task-list-view.tsx`, `task-modal.tsx`, `task-toolbar.tsx`) - JSCalendar `@type: 'Task'` is a separate surface from events; worth its own screen, not bolted onto calendar. -- **Drag to reschedule / resize** — mobile tap targets are too small for the +- **Drag to reschedule / resize** - mobile tap targets are too small for the fine-grained control the web drag gives; tap-edit is the mobile idiom. -- **Quick event input** (`quick-event-input.tsx`) — NLP parser ("Lunch tomorrow +- **Quick event input** (`quick-event-input.tsx`) - NLP parser ("Lunch tomorrow at noon"). Neat on desktop, marginal on phone where typing is already slow. -- **Event context menu** (`event-context-menu.tsx`) — right-click is desktop- +- **Event context menu** (`event-context-menu.tsx`) - right-click is desktop- only. Actions move into the detail sheet's action row. -- **Mini-calendar** (`mini-calendar.tsx`) — web uses it as a sidebar date +- **Mini-calendar** (`mini-calendar.tsx`) - web uses it as a sidebar date picker. Mobile already has the main month view; redundant. -- **Birthday calendar auto-derived events** — lower priority; leave the +- **Birthday calendar auto-derived events** - lower priority; leave the contacts-store integration for a follow-up. ## Dependencies to add -- `@react-native-community/datetimepicker` — native date/time pickers. +- `@react-native-community/datetimepicker` - native date/time pickers. - `react-native-reanimated` is already in (via react-navigation); reuse for bottom-sheet animations on `EventDetailSheet`. -- No new calendar library — reimplement RRULE expansion in ~400 lines rather +- No new calendar library - reimplement RRULE expansion in ~400 lines rather than pulling in `rrule.js` (which adds ~180KB and expects browser APIs). ## File map (new / modified) @@ -207,14 +214,14 @@ repos/react-native/src/ ## Build order (suggested) -1. Phase 1 in full — nothing else works without real data. -2. MonthView + EventDetailSheet (read path) — first thing user sees. -3. EventModal create path — unblocks manual testing of write path. -4. AgendaView + CalendarSidebarDrawer — pulls the rest of month together. -5. WeekView — biggest UI, easiest to defer if time runs short. -6. EventModal edit + recurrence scope — finishes the edit surface. +1. Phase 1 in full - nothing else works without real data. +2. MonthView + EventDetailSheet (read path) - first thing user sees. +3. EventModal create path - unblocks manual testing of write path. +4. AgendaView + CalendarSidebarDrawer - pulls the rest of month together. +5. WeekView - biggest UI, easiest to defer if time runs short. +6. EventModal edit + recurrence scope - finishes the edit surface. 7. Phase 5 polish. Rough size estimate: ~2,500 lines of new RN code + ~300 lines of store/util -changes. Each phase is ship-safe on its own — the app stays functional even if +changes. Each phase is ship-safe on its own - the app stays functional even if we stop at Phase 2. diff --git a/README.md b/README.md new file mode 100644 index 00000000..f0acb19e --- /dev/null +++ b/README.md @@ -0,0 +1,47 @@ +
+ + + + + Bulwark Webmail + + +
+ +# Bulwark Mobile + +> **Beta - work in progress.** Many features are unfinished or rough. Expect bugs, missing functionality, and breaking changes between releases. Do not rely on this for primary email yet. + +React Native (Expo SDK 54) client for [Bulwark Webmail](https://github.com/bulwarkmail/webmail) - a JMAP-based mail, calendar, and contacts app. + +## What works today + +- Sign in to any JMAP server (e.g. Stalwart) +- Multiple accounts +- Email list, threads, compose +- Calendar (basic) +- Contacts (basic) +- Push notifications via FCM relay +- In-app sideload updates from GitHub Releases + +## What's missing or rough + +- iOS build is untested +- Filters & rules, S/MIME, plugins, themes, file storage - UI stubs only +- Calendar editing is partial; contacts editing is basic +- No Play Store distribution yet (sideload APK from Releases) + +## Run locally + +```bash +npm install +npx expo start +``` + +Then press `a` for Android, `i` for iOS, or scan the QR with Expo Go. + +For a release APK build see [.github/workflows/release-android.yml](.github/workflows/release-android.yml). + +## License + +AGPL-3.0-only. See [LICENSE](LICENSE). diff --git a/android/app/build.gradle b/android/app/build.gradle index 65ecd40d..043fd4ee 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -166,7 +166,7 @@ dependencies { // The version of react-native is set by the React Native Gradle Plugin implementation("com.facebook.react:react-android") - // Firebase Cloud Messaging — delivers pushes via Google Play services. + // Firebase Cloud Messaging - delivers pushes via Google Play services. // Requires android/app/google-services.json at build time. implementation(platform("com.google.firebase:firebase-bom:33.7.0")) implementation("com.google.firebase:firebase-messaging") diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkMessagingService.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkMessagingService.kt index c05d0cf4..ba033420 100644 --- a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkMessagingService.kt +++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkMessagingService.kt @@ -26,7 +26,7 @@ class BulwarkMessagingService : FirebaseMessagingService() { val data = message.data // Hand off to JS via a headless task only when the app isn't already - // running in the foreground — HeadlessJsTaskContext throws if started + // running in the foreground - HeadlessJsTaskContext throws if started // while foreground. When the app is open, the main JS instance already // receives JMAP push directly, so the fcm:message event below is // sufficient for it to refresh state. diff --git a/src/api/jmap-client.ts b/src/api/jmap-client.ts index 0566af0c..eac21ca7 100644 --- a/src/api/jmap-client.ts +++ b/src/api/jmap-client.ts @@ -11,7 +11,7 @@ import { generateAccountId } from '../lib/account-utils'; const LEGACY_CREDENTIALS_KEY = 'jmap_credentials'; const CREDENTIALS_PREFIX = 'jmap_credentials__'; -// SecureStore keys: letters, digits, ".", "-", "_" only — no "@" or "/". +// SecureStore keys: letters, digits, ".", "-", "_" only - no "@" or "/". function credentialsKey(accountId: string): string { return CREDENTIALS_PREFIX + accountId.replace(/[^a-zA-Z0-9._-]/g, '_'); } @@ -30,7 +30,7 @@ export class JMAPClient { get accountId(): string { if (!this._accountId) { - throw new Error('Not authenticated — call connect() first'); + throw new Error('Not authenticated - call connect() first'); } return this._accountId; } @@ -89,7 +89,7 @@ export class JMAPClient { return this.session; } - // Legacy single-slot restore — kept for backward-compat tests. New code + // Legacy single-slot restore - kept for backward-compat tests. New code // should use loadAccount(accountId) driven by the account registry. async restoreSession(): Promise { const stored = await SecureStore.getItemAsync(LEGACY_CREDENTIALS_KEY); @@ -294,7 +294,7 @@ export class JMAPClient { // Expand the RFC 6570 level-1 template the JMAP server advertises. getBlobDownloadUrl(blobId: string, name?: string, type?: string): string { if (!this.session?.downloadUrl) { - throw new Error('Download URL not available — not connected'); + throw new Error('Download URL not available - not connected'); } return this.session.downloadUrl .replace('{accountId}', encodeURIComponent(this.accountId)) diff --git a/src/api/push.ts b/src/api/push.ts index 396aeaa4..1c30b5ab 100644 --- a/src/api/push.ts +++ b/src/api/push.ts @@ -55,7 +55,7 @@ export async function createPushSubscription(params: { } /** - * Send the verification code back to the server — the call that flips the + * Send the verification code back to the server - the call that flips the * subscription from pending to active (RFC 8620 §7.2.2). */ export async function verifyPushSubscription( diff --git a/src/components/SidebarDrawer.tsx b/src/components/SidebarDrawer.tsx index 6e4a50d8..cdf88bea 100644 --- a/src/components/SidebarDrawer.tsx +++ b/src/components/SidebarDrawer.tsx @@ -521,7 +521,7 @@ const styles = StyleSheet.create({ accountName: { ...typography.bodyMedium, color: colors.text }, accountEmail: { ...typography.caption, color: colors.textMuted, marginTop: 1 }, - // Account menu — floating popover card under the header + // Account menu - floating popover card under the header accountMenu: { marginHorizontal: spacing.sm, marginTop: spacing.xs, diff --git a/src/components/calendar/AgendaView.tsx b/src/components/calendar/AgendaView.tsx index c59f5788..79ff9071 100644 --- a/src/components/calendar/AgendaView.tsx +++ b/src/components/calendar/AgendaView.tsx @@ -56,7 +56,7 @@ export function AgendaView({ day.setDate(start.getDate() + i); const dayEvents = eventsOnDayFromIndex(index, day).slice().sort((a, b) => { // All-day events first within a day, then by start time, then title - // (stable tie-break) — mirrors the comparator the webmail uses for + // (stable tie-break) - mirrors the comparator the webmail uses for // packing week segments and keeps multi-event days predictable. if (a.showWithoutTime !== b.showWithoutTime) { return a.showWithoutTime ? -1 : 1; diff --git a/src/components/calendar/WeekView.tsx b/src/components/calendar/WeekView.tsx index 47484379..8604c0ed 100644 --- a/src/components/calendar/WeekView.tsx +++ b/src/components/calendar/WeekView.tsx @@ -84,7 +84,7 @@ export function WeekView({ ? allDayRowCount * (ALL_DAY_CHIP_HEIGHT + ALL_DAY_GAP) + spacing.xs : 0; - // Timed grid excludes events that fill the full day on that day — they're + // Timed grid excludes events that fill the full day on that day - they're // already promoted to the all-day strip above. const layoutsByDay = React.useMemo(() => { return weekDays.map((day) => { @@ -170,7 +170,7 @@ export function WeekView({ {weekDays.map((day) => ( ))} - {/* Segments overlay — multi-day events span their date range */} + {/* Segments overlay - multi-day events span their date range */} {allDaySegments.map((segment) => { const color = getEventColor(segment.event, calendars); diff --git a/src/components/settings/AccountSecuritySettings.tsx b/src/components/settings/AccountSecuritySettings.tsx index 5d557a62..00344140 100644 --- a/src/components/settings/AccountSecuritySettings.tsx +++ b/src/components/settings/AccountSecuritySettings.tsx @@ -14,7 +14,7 @@ interface Session { } const MOCK_SESSIONS: Session[] = [ - { id: '1', device: 'iPhone 15 Pro — Bulwark Mobile', location: 'Berlin, DE', current: true }, + { id: '1', device: 'iPhone 15 Pro - Bulwark Mobile', location: 'Berlin, DE', current: true }, { id: '2', device: 'Chrome on macOS', location: 'Berlin, DE', current: false }, ]; diff --git a/src/components/settings/NotificationSettings.tsx b/src/components/settings/NotificationSettings.tsx index 8e67bc03..fd8d8c5e 100644 --- a/src/components/settings/NotificationSettings.tsx +++ b/src/components/settings/NotificationSettings.tsx @@ -112,7 +112,7 @@ export function NotificationSettings() { diff --git a/src/components/settings/UpdatesSettings.tsx b/src/components/settings/UpdatesSettings.tsx index 68fe43eb..b55c088d 100644 --- a/src/components/settings/UpdatesSettings.tsx +++ b/src/components/settings/UpdatesSettings.tsx @@ -54,7 +54,7 @@ export function UpdatesSettings() { label="Latest version" description={cachedLatest?.publishedAt ? `Published ${new Date(cachedLatest.publishedAt).toLocaleDateString()}` : undefined} > - {cachedLatest ? cachedLatest.tag : '—'} + {cachedLatest ? cachedLatest.tag : '-'} ): CalendarEvent { } as CalendarEvent; } -describe('expandRecurringEvents — daily', () => { +describe('expandRecurringEvents - daily', () => { it('expands a daily rule with count', () => { const rule: RecurrenceRule = { frequency: 'daily', count: 3 }; const result = expandRecurringEvents( @@ -52,9 +52,9 @@ describe('expandRecurringEvents — daily', () => { }); }); -describe('expandRecurringEvents — weekly', () => { +describe('expandRecurringEvents - weekly', () => { it('expands a weekly rule on specific byDay values', () => { - // April 6, 2026 is a Monday — expand Mon/Wed for two weeks. + // April 6, 2026 is a Monday - expand Mon/Wed for two weeks. const rule: RecurrenceRule = { frequency: 'weekly', byDay: [{ day: 'mo' }, { day: 'we' }], @@ -74,7 +74,7 @@ describe('expandRecurringEvents — weekly', () => { }); }); -describe('expandRecurringEvents — monthly', () => { +describe('expandRecurringEvents - monthly', () => { it('expands monthly on the 6th', () => { const rule: RecurrenceRule = { frequency: 'monthly', count: 3 }; const result = expandRecurringEvents( @@ -108,7 +108,7 @@ describe('expandRecurringEvents — monthly', () => { }); }); -describe('expandRecurringEvents — overrides', () => { +describe('expandRecurringEvents - overrides', () => { it('skips occurrences marked excluded', () => { const rule: RecurrenceRule = { frequency: 'daily', count: 3 }; const event = ev({ @@ -146,7 +146,7 @@ describe('expandRecurringEvents — overrides', () => { }); }); -describe('expandRecurringEvents — non-recurring passthrough', () => { +describe('expandRecurringEvents - non-recurring passthrough', () => { it('returns non-recurring events unchanged', () => { const single = ev({ id: 'one', recurrenceRules: undefined }); const result = expandRecurringEvents( @@ -159,7 +159,7 @@ describe('expandRecurringEvents — non-recurring passthrough', () => { }); }); -describe('expandRecurringEvents — range filtering', () => { +describe('expandRecurringEvents - range filtering', () => { it('returns occurrences only within the requested window', () => { const rule: RecurrenceRule = { frequency: 'daily', count: 30 }; const result = expandRecurringEvents( diff --git a/src/lib/avatar-utils.ts b/src/lib/avatar-utils.ts index 9b90ff9a..723b6a56 100644 --- a/src/lib/avatar-utils.ts +++ b/src/lib/avatar-utils.ts @@ -1,10 +1,10 @@ // Two avatar schemes, ported verbatim from the webmail: // -// 1. Account avatar (account-utils.ts) — 12-color fixed palette, hashed from +// 1. Account avatar (account-utils.ts) - 12-color fixed palette, hashed from // the account's email. Used for the sidebar account block and account // switcher. // -// 2. Email-row avatar (components/ui/avatar.tsx) — HSL hue from a hash of +// 2. Email-row avatar (components/ui/avatar.tsx) - HSL hue from a hash of // name or email. Used for per-message avatars in the email list. // // Both variants also define `getInitials` with slightly different rules, so we @@ -68,7 +68,7 @@ export function getEmailInitials(name: string, email?: string): string { // logo from DuckDuckGo. The webmail goes through `/api/favicon?domain=...` as // a caching proxy; native has no CORS constraint, so we hit DuckDuckGo direct. -// Multi-part TLDs (trimmed to the webmail client-side list — not the giant +// Multi-part TLDs (trimmed to the webmail client-side list - not the giant // server PSL). Covers the common cases so subdomains resolve to the right // registrable domain (e.g. newsletter.example.co.uk → example.co.uk). const MULTI_PART_TLDS = new Set([ @@ -100,7 +100,7 @@ const MULTI_PART_TLDS = new Set([ 'co.ke', 'or.ke', 'ac.ke', 'go.ke', 'ne.ke', ]); -// Personal mail providers — the favicon here is the mail provider's logo, not +// Personal mail providers - the favicon here is the mail provider's logo, not // anything specific to the sender, so we skip it. const PERSONAL_DOMAINS = new Set([ 'gmail.com', 'googlemail.com', 'outlook.com', 'hotmail.com', 'live.com', diff --git a/src/lib/calendar-utils.ts b/src/lib/calendar-utils.ts index eb78ca64..074aac7e 100644 --- a/src/lib/calendar-utils.ts +++ b/src/lib/calendar-utils.ts @@ -87,7 +87,7 @@ export function eventsOnDay(events: CalendarEvent[], day: Date): CalendarEvent[] // O(days × events) with two parseISO calls per event per day. For ~200 events // that's ~16k parseISO calls per month render, redone on every swipe. // `buildEventDayIndex` parses each event's start/end once, then walks only the -// days that event actually touches — typically 1 day. Result is a map from +// days that event actually touches - typically 1 day. Result is a map from // local-date key (yyyy-MM-dd) to the events on that day. export type EventDayIndex = Map; @@ -104,7 +104,7 @@ export function buildEventDayIndex(events: CalendarEvent[]): EventDayIndex { const start = getEventStartDate(event); const end = getEventDisplayEndDate(event); let day = startOfDay(start); - // Cap at 366 days: matches the original semantics (end > dayStart) — we + // Cap at 366 days: matches the original semantics (end > dayStart) - we // include day while day < end. Safety bound guards against malformed // multi-year events that would otherwise blow up the loop. let safety = 0; @@ -193,7 +193,7 @@ export function getTimedEventBoundsForDay( // ─── Timed events that fill the day ────────────────────── // Events shown in the timed grid that effectively span 00:00–24:00 on the // given day get promoted to the all-day strip in the week/day views (matches -// webmail behavior — keeps the timed grid usable when an event covers the day). +// webmail behavior - keeps the timed grid usable when an event covers the day). export function isTimedEventFullDayOnDate(event: CalendarEvent, day: Date): boolean { const bounds = getTimedEventBoundsForDay(event, day); return bounds?.startMinutes === 0 && bounds?.endMinutes === 1440; @@ -315,7 +315,7 @@ export function buildTimedFullDayWeekSegments( // begins after every prior event has ended. Within a cluster every event gets // the same `totalColumns`; across clusters, events get to use the full width // they actually need. The previous global packing forced an event with no -// neighbors at 17:00 to share columns with a 9am cluster — visible squish. +// neighbors at 17:00 to share columns with a 9am cluster - visible squish. export interface TimedEventLayout { event: CalendarEvent; column: number; diff --git a/src/lib/email-html.ts b/src/lib/email-html.ts index 2c1989d7..bd1d8140 100644 --- a/src/lib/email-html.ts +++ b/src/lib/email-html.ts @@ -6,7 +6,7 @@ // so inline