personal-lifecycle-tracker / frontend / src / services / mockLifecycleService.ts
mockLifecycleService.ts
Raw
// shortcut: Offline mock service retained for local client-only prototyping (Gate 2) and standalone frontend test verification without backend container.
import { ILifecycleService } from './api';
import { getItemUrgency, getTodayDateString } from './dateUtils';
import { buildDailyDigestEmail } from './digestService';
import {
  CreateItemDTO,
  DailyDigestConfig,
  DailyDigestEmail,
  Group,
  LifecycleItem,
  LifecycleType,
  RemindersSummary,
  UpdateItemDTO,
  User,
} from './types';

const STORAGE_KEYS = {
  USERS: 'plt_users_v1',
  CURRENT_USER_ID: 'plt_current_user_id_v1',
  GROUPS: 'plt_groups_v1',
  ITEMS: 'plt_items_v1',
  DIGEST_CONFIG: 'plt_digest_config_v1',
  DIGEST_HISTORY: 'plt_digest_history_v1',
};

function getRelativeDateStr(daysOffset: number): string {
  const d = new Date();
  d.setDate(d.getDate() + daysOffset);
  const year = d.getFullYear();
  const month = String(d.getMonth() + 1).padStart(2, '0');
  const day = String(d.getDate()).padStart(2, '0');
  return `${year}-${month}-${day}`;
}

const DEFAULT_USERS: User[] = [
  {
    id: 'user_alex',
    email: 'alex@example.com',
    name: 'Alex Taylor',
    createdAt: '2026-01-10T08:00:00Z',
  },
  {
    id: 'user_sarah',
    email: 'sarah@example.com',
    name: 'Sarah Jenkins',
    createdAt: '2026-02-14T09:30:00Z',
  },
  {
    id: 'user_jordan',
    email: 'jordan@example.com',
    name: 'Jordan Patel',
    createdAt: '2026-03-01T11:15:00Z',
  },
];

const DEFAULT_GROUPS: Group[] = [
  {
    id: 'group_home',
    name: 'Oakridge Family & Home',
    ownerId: 'user_alex',
    ownerEmail: 'alex@example.com',
    memberEmails: ['alex@example.com', 'sarah@example.com'],
    createdAt: '2026-02-15T10:00:00Z',
  },
  {
    id: 'group_vehicles',
    name: 'Shared Vehicle Maintenance',
    ownerId: 'user_sarah',
    ownerEmail: 'sarah@example.com',
    memberEmails: ['sarah@example.com', 'alex@example.com', 'jordan@example.com'],
    createdAt: '2026-02-20T14:00:00Z',
  },
];

function generateSeedItems(): LifecycleItem[] {
  return [
    // Renewals
    {
      id: 'item_1',
      name: 'Vehicle Auto Insurance Policy',
      lifecycleType: 'renewal',
      dueDate: getRelativeDateStr(-4), // 4 days overdue
      status: 'active',
      ownerId: 'user_alex',
      ownerEmail: 'alex@example.com',
      groupId: 'group_vehicles',
      groupName: 'Shared Vehicle Maintenance',
      createdAt: '2026-08-01T08:00:00Z',
      updatedAt: '2026-08-01T08:00:00Z',
    },
    {
      id: 'item_2',
      name: 'International Passport Renewal',
      lifecycleType: 'renewal',
      dueDate: getRelativeDateStr(12), // 12 days upcoming
      status: 'active',
      ownerId: 'user_alex',
      ownerEmail: 'alex@example.com',
      groupId: null,
      groupName: null,
      createdAt: '2026-08-05T09:00:00Z',
      updatedAt: '2026-08-05T09:00:00Z',
    },
    {
      id: 'item_3',
      name: 'Residential Homeowners Insurance',
      lifecycleType: 'renewal',
      dueDate: getRelativeDateStr(24), // 24 days upcoming
      status: 'active',
      ownerId: 'user_alex',
      ownerEmail: 'alex@example.com',
      groupId: 'group_home',
      groupName: 'Oakridge Family & Home',
      createdAt: '2026-08-10T11:00:00Z',
      updatedAt: '2026-08-10T11:00:00Z',
    },
    {
      id: 'item_4',
      name: 'State Driver License Renewal',
      lifecycleType: 'renewal',
      dueDate: getRelativeDateStr(-30),
      status: 'completed',
      ownerId: 'user_alex',
      ownerEmail: 'alex@example.com',
      groupId: null,
      groupName: null,
      createdAt: '2026-07-01T10:00:00Z',
      updatedAt: '2026-09-01T10:00:00Z',
    },

    // Maintenance
    {
      id: 'item_5',
      name: 'HVAC Air Filter & Coil Servicing',
      lifecycleType: 'maintenance',
      dueDate: getRelativeDateStr(-2), // 2 days overdue
      status: 'active',
      ownerId: 'user_sarah',
      ownerEmail: 'sarah@example.com',
      groupId: 'group_home',
      groupName: 'Oakridge Family & Home',
      createdAt: '2026-08-15T12:00:00Z',
      updatedAt: '2026-08-15T12:00:00Z',
    },
    {
      id: 'item_6',
      name: 'Honda CR-V Brake Inspection & Tire Rotation',
      lifecycleType: 'maintenance',
      dueDate: getRelativeDateStr(9), // 9 days upcoming
      status: 'active',
      ownerId: 'user_sarah',
      ownerEmail: 'sarah@example.com',
      groupId: 'group_vehicles',
      groupName: 'Shared Vehicle Maintenance',
      createdAt: '2026-08-20T14:30:00Z',
      updatedAt: '2026-08-20T14:30:00Z',
    },
    {
      id: 'item_7',
      name: 'Reverse Osmosis Filter Cartridge Replacement',
      lifecycleType: 'maintenance',
      dueDate: undefined, // Missing due date allowed per Decision 29
      status: 'active',
      ownerId: 'user_alex',
      ownerEmail: 'alex@example.com',
      groupId: 'group_home',
      groupName: 'Oakridge Family & Home',
      createdAt: '2026-09-01T09:00:00Z',
      updatedAt: '2026-09-01T09:00:00Z',
    },
    {
      id: 'item_8',
      name: 'Annual Water Heater Flush',
      lifecycleType: 'maintenance',
      dueDate: getRelativeDateStr(-60),
      status: 'completed',
      ownerId: 'user_alex',
      ownerEmail: 'alex@example.com',
      groupId: 'group_home',
      groupName: 'Oakridge Family & Home',
      createdAt: '2026-06-01T08:00:00Z',
      updatedAt: '2026-07-15T08:00:00Z',
    },

    // Warranties
    {
      id: 'item_9',
      name: 'MacBook Pro AppleCare+ Protection',
      lifecycleType: 'warranty',
      dueDate: getRelativeDateStr(5), // 5 days upcoming
      status: 'active',
      ownerId: 'user_alex',
      ownerEmail: 'alex@example.com',
      groupId: null,
      groupName: null,
      createdAt: '2026-08-25T15:00:00Z',
      updatedAt: '2026-08-25T15:00:00Z',
    },
    {
      id: 'item_10',
      name: 'Samsung 65" OLED TV 3-Year Limited Warranty',
      lifecycleType: 'warranty',
      dueDate: getRelativeDateStr(-10), // 10 days overdue
      status: 'active',
      ownerId: 'user_alex',
      ownerEmail: 'alex@example.com',
      groupId: 'group_home',
      groupName: 'Oakridge Family & Home',
      createdAt: '2026-07-10T16:00:00Z',
      updatedAt: '2026-07-10T16:00:00Z',
    },
    {
      id: 'item_11',
      name: 'Bosch Dishwasher 5-Year Parts Warranty',
      lifecycleType: 'warranty',
      dueDate: getRelativeDateStr(120), // far future
      status: 'active',
      ownerId: 'user_alex',
      ownerEmail: 'alex@example.com',
      groupId: 'group_home',
      groupName: 'Oakridge Family & Home',
      createdAt: '2026-08-01T11:00:00Z',
      updatedAt: '2026-08-01T11:00:00Z',
    },
    {
      id: 'item_12',
      name: 'Espresso Machine Manufacturer Warranty',
      lifecycleType: 'warranty',
      dueDate: undefined, // Missing date allowed
      status: 'active',
      ownerId: 'user_alex',
      ownerEmail: 'alex@example.com',
      groupId: null,
      groupName: null,
      createdAt: '2026-09-02T13:00:00Z',
      updatedAt: '2026-09-02T13:00:00Z',
    },
  ];
}

const DEFAULT_DIGEST_CONFIG: DailyDigestConfig = {
  timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone || 'America/New_York',
  deliveryTime: '08:00', // Option B from Question 34
  enabled: true, // Both reminder channels always enabled
};

export class MockLifecycleService implements ILifecycleService {
  private users: User[] = [];
  private currentUserId: string | null = null;
  private groups: Group[] = [];
  private items: LifecycleItem[] = [];
  private digestConfig: DailyDigestConfig = { ...DEFAULT_DIGEST_CONFIG };
  private digestHistory: DailyDigestEmail[] = [];

  constructor() {
    this.loadState();
  }

  private isStorageAvailable(): boolean {
    return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined';
  }

  private loadState(): void {
    if (!this.isStorageAvailable()) {
      this.initDefaults();
      return;
    }

    try {
      const storedUsers = localStorage.getItem(STORAGE_KEYS.USERS);
      const storedUserId = localStorage.getItem(STORAGE_KEYS.CURRENT_USER_ID);
      const storedGroups = localStorage.getItem(STORAGE_KEYS.GROUPS);
      const storedItems = localStorage.getItem(STORAGE_KEYS.ITEMS);
      const storedConfig = localStorage.getItem(STORAGE_KEYS.DIGEST_CONFIG);
      const storedHistory = localStorage.getItem(STORAGE_KEYS.DIGEST_HISTORY);

      if (storedUsers) {
        this.users = JSON.parse(storedUsers);
        this.currentUserId = storedUserId || this.users[0]?.id || null;
        this.groups = storedGroups ? JSON.parse(storedGroups) : DEFAULT_GROUPS;
        this.items = storedItems ? JSON.parse(storedItems) : generateSeedItems();
        this.digestConfig = storedConfig ? JSON.parse(storedConfig) : DEFAULT_DIGEST_CONFIG;
        this.digestHistory = storedHistory ? JSON.parse(storedHistory) : [];
      } else {
        this.initDefaults();
      }
    } catch {
      this.initDefaults();
    }
  }

  private initDefaults(): void {
    this.users = [...DEFAULT_USERS];
    this.currentUserId = DEFAULT_USERS[0].id;
    this.groups = [...DEFAULT_GROUPS];
    this.items = generateSeedItems();
    this.digestConfig = { ...DEFAULT_DIGEST_CONFIG };
    this.digestHistory = [];
    this.saveState();
  }

  private saveState(): void {
    if (!this.isStorageAvailable()) return;
    try {
      localStorage.setItem(STORAGE_KEYS.USERS, JSON.stringify(this.users));
      if (this.currentUserId) {
        localStorage.setItem(STORAGE_KEYS.CURRENT_USER_ID, this.currentUserId);
      } else {
        localStorage.removeItem(STORAGE_KEYS.CURRENT_USER_ID);
      }
      localStorage.setItem(STORAGE_KEYS.GROUPS, JSON.stringify(this.groups));
      localStorage.setItem(STORAGE_KEYS.ITEMS, JSON.stringify(this.items));
      localStorage.setItem(STORAGE_KEYS.DIGEST_CONFIG, JSON.stringify(this.digestConfig));
      localStorage.setItem(STORAGE_KEYS.DIGEST_HISTORY, JSON.stringify(this.digestHistory));
    } catch {
      // Storage quota or error ignore
    }
  }

  public async resetToDefaultSeed(): Promise<void> {
    this.initDefaults();
  }

  // ------------------ AUTH & USER MANAGEMENT ------------------

  public async getCurrentUser(): Promise<User | null> {
    if (!this.currentUserId) return null;
    return this.users.find((u) => u.id === this.currentUserId) || null;
  }

  public async login(email: string, _password?: string): Promise<User> {
    const trimmed = email.trim().toLowerCase();
    let user = this.users.find((u) => u.email.toLowerCase() === trimmed);
    if (!user) {
      // Automatically provision user for testing email & password sign in
      user = {
        id: `user_${Date.now()}_${Math.random().toString(36).substring(2, 6)}`,
        email: trimmed,
        name: trimmed.split('@')[0],
        createdAt: new Date().toISOString(),
      };
      this.users.push(user);
    }
    this.currentUserId = user.id;
    this.saveState();
    return user;
  }

  public async signup(email: string, _password?: string, name?: string): Promise<User> {
    const trimmed = email.trim().toLowerCase();
    const existing = this.users.find((u) => u.email.toLowerCase() === trimmed);
    if (existing) {
      this.currentUserId = existing.id;
      this.saveState();
      return existing;
    }

    const newUser: User = {
      id: `user_${Date.now()}_${Math.random().toString(36).substring(2, 6)}`,
      email: trimmed,
      name: name?.trim() || trimmed.split('@')[0],
      createdAt: new Date().toISOString(),
    };
    this.users.push(newUser);
    this.currentUserId = newUser.id;
    this.saveState();
    return newUser;
  }

  public async logout(): Promise<void> {
    this.currentUserId = null;
    this.saveState();
  }

  public async switchUser(userId: string): Promise<User> {
    const user = this.users.find((u) => u.id === userId);
    if (!user) {
      throw new Error('User not found');
    }
    this.currentUserId = user.id;
    this.saveState();
    return user;
  }

  public async listUsers(): Promise<User[]> {
    return [...this.users];
  }

  /**
   * Decision 23: "Account deletion removes everything associated with the account, including its group items."
   * - Deletes user
   * - Deletes user's personal items
   * - Deletes groups owned by user and all items in those groups (Decision 30)
   * - Removes user from any groups they belonged to as member
   * - If user owned items in other shared groups, per Decision 23 those are removed too
   */
  public async deleteAccount(userId: string): Promise<void> {
    const userToDelete = this.users.find((u) => u.id === userId);
    if (!userToDelete) return;

    // 1. Find groups owned by user
    const ownedGroupIds = this.groups.filter((g) => g.ownerId === userId).map((g) => g.id);

    // 2. Remove items belonging to user or owned groups
    this.items = this.items.filter(
      (item) => item.ownerId !== userId && (!item.groupId || !ownedGroupIds.includes(item.groupId))
    );

    // 3. Remove groups owned by user
    this.groups = this.groups.filter((g) => g.ownerId !== userId);

    // 4. Remove user email from any remaining groups
    this.groups = this.groups.map((g) => ({
      ...g,
      memberEmails: g.memberEmails.filter((em) => em.toLowerCase() !== userToDelete.email.toLowerCase()),
    }));

    // 5. Remove user
    this.users = this.users.filter((u) => u.id !== userId);

    // 6. If currently logged in as this user, reset or switch
    if (this.currentUserId === userId) {
      this.currentUserId = this.users[0]?.id || null;
    }

    this.saveState();
  }

  // ------------------ ITEMS MANAGEMENT ------------------

  private async requireCurrentUser(): Promise<User> {
    const user = await this.getCurrentUser();
    if (!user) {
      throw new Error('Authentication required');
    }
    return user;
  }

  /**
   * Returns items accessible to current user:
   * 1. Personal items owned by user (groupId === null)
   * 2. Shared group items for groups where user is a member
   */
  public async getItems(options?: {
    groupId?: string | null;
    type?: LifecycleType;
    searchQuery?: string;
  }): Promise<LifecycleItem[]> {
    const currentUser = await this.requireCurrentUser();

    // Find groups the user belongs to
    const userGroupIds = this.groups
      .filter((g) => g.memberEmails.map((e) => e.toLowerCase()).includes(currentUser.email.toLowerCase()))
      .map((g) => g.id);

    let accessible = this.items.filter((item) => {
      // Personal items owned by user
      if (!item.groupId) {
        return item.ownerId === currentUser.id;
      }
      // Group items accessible if member of that group
      return userGroupIds.includes(item.groupId);
    });

    if (options?.groupId !== undefined) {
      if (options.groupId === null) {
        accessible = accessible.filter((item) => !item.groupId);
      } else {
        accessible = accessible.filter((item) => item.groupId === options.groupId);
      }
    }

    if (options?.type) {
      accessible = accessible.filter((item) => item.lifecycleType === options.type);
    }

    if (options?.searchQuery && options.searchQuery.trim() !== '') {
      const q = options.searchQuery.toLowerCase().trim();
      accessible = accessible.filter(
        (item) => item.name.toLowerCase().includes(q) || (item.groupName && item.groupName.toLowerCase().includes(q))
      );
    }

    // Sort order:
    // Overdue first, then upcoming (by due date ascending), then undated, then completed
    const todayStr = getTodayDateString();
    return accessible.sort((a, b) => {
      const urgencyA = getItemUrgency(a, todayStr);
      const urgencyB = getItemUrgency(b, todayStr);

      const rank = (urgency: string) => {
        if (urgency === 'overdue') return 1;
        if (urgency === 'upcoming') return 2;
        if (urgency === 'normal') return 3;
        if (urgency === 'no_date') return 4;
        return 5; // completed
      };

      const diff = rank(urgencyA) - rank(urgencyB);
      if (diff !== 0) return diff;

      if (a.dueDate && b.dueDate) {
        return a.dueDate.localeCompare(b.dueDate);
      }
      return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
    });
  }

  public async getItem(id: string): Promise<LifecycleItem> {
    const items = await this.getItems();
    const item = items.find((i) => i.id === id);
    if (!item) {
      throw new Error(`Item ${id} not found or not accessible`);
    }
    return item;
  }

  public async createItem(data: CreateItemDTO): Promise<LifecycleItem> {
    const currentUser = await this.requireCurrentUser();

    let groupName: string | null = null;
    if (data.groupId) {
      const group = this.groups.find((g) => g.id === data.groupId);
      if (!group) throw new Error('Target group not found');
      // Ensure current user is a member
      if (!group.memberEmails.map((e) => e.toLowerCase()).includes(currentUser.email.toLowerCase())) {
        throw new Error('Not authorized to add items to this group');
      }
      groupName = group.name;
    }

    const newItem: LifecycleItem = {
      id: `item_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`,
      name: data.name.trim(),
      lifecycleType: data.lifecycleType,
      dueDate: data.dueDate ? data.dueDate.trim().slice(0, 10) : undefined,
      status: data.status || 'active',
      ownerId: currentUser.id,
      ownerEmail: currentUser.email,
      groupId: data.groupId || null,
      groupName,
      createdAt: new Date().toISOString(),
      updatedAt: new Date().toISOString(),
    };

    this.items.unshift(newItem);
    this.saveState();
    return newItem;
  }

  public async updateItem(id: string, data: UpdateItemDTO): Promise<LifecycleItem> {
    const currentUser = await this.requireCurrentUser();
    const itemIdx = this.items.findIndex((i) => i.id === id);
    if (itemIdx === -1) throw new Error('Item not found');

    const item = this.items[itemIdx];

    // Check permissions:
    // If personal: must be owner.
    // If group item: any group member can edit (Decision 14).
    if (item.groupId) {
      const group = this.groups.find((g) => g.id === item.groupId);
      if (!group || !group.memberEmails.map((e) => e.toLowerCase()).includes(currentUser.email.toLowerCase())) {
        throw new Error('Not authorized to edit this shared item');
      }
    } else if (item.ownerId !== currentUser.id) {
      throw new Error('Not authorized to edit this item');
    }

    let groupName = item.groupName;
    let groupId = item.groupId;

    if (data.groupId !== undefined) {
      if (data.groupId === null) {
        groupId = null;
        groupName = null;
      } else {
        const group = this.groups.find((g) => g.id === data.groupId);
        if (!group) throw new Error('Target group not found');
        groupId = group.id;
        groupName = group.name;
      }
    }

    const updated: LifecycleItem = {
      ...item,
      name: data.name !== undefined ? data.name.trim() : item.name,
      lifecycleType: data.lifecycleType !== undefined ? data.lifecycleType : item.lifecycleType,
      dueDate:
        data.dueDate !== undefined ? (data.dueDate ? data.dueDate.trim().slice(0, 10) : undefined) : item.dueDate,
      status: data.status !== undefined ? data.status : item.status,
      groupId,
      groupName,
      updatedAt: new Date().toISOString(),
    };

    this.items[itemIdx] = updated;
    this.saveState();
    return updated;
  }

  /**
   * Decision 8 & 28:
   * Mark complete and retain in main list.
   * Can toggle between 'active' and 'completed'.
   */
  public async toggleItemCompletion(id: string): Promise<LifecycleItem> {
    const item = await this.getItem(id);
    const newStatus = item.status === 'completed' ? 'active' : 'completed';
    return this.updateItem(id, { status: newStatus });
  }

  /**
   * Decision 19: Permanent deletion
   */
  public async deleteItem(id: string): Promise<void> {
    const currentUser = await this.requireCurrentUser();
    const item = this.items.find((i) => i.id === id);
    if (!item) return;

    if (item.groupId) {
      const group = this.groups.find((g) => g.id === item.groupId);
      if (!group || !group.memberEmails.map((e) => e.toLowerCase()).includes(currentUser.email.toLowerCase())) {
        throw new Error('Not authorized to delete this shared item');
      }
    } else if (item.ownerId !== currentUser.id) {
      throw new Error('Not authorized to delete this item');
    }

    this.items = this.items.filter((i) => i.id !== id);
    this.saveState();
  }

  // ------------------ GROUPS & SHARING ------------------

  public async getGroups(): Promise<Group[]> {
    const currentUser = await this.requireCurrentUser();
    return this.groups.filter((g) =>
      g.memberEmails.map((e) => e.toLowerCase()).includes(currentUser.email.toLowerCase())
    );
  }

  public async getGroup(id: string): Promise<Group> {
    const groups = await this.getGroups();
    const group = groups.find((g) => g.id === id);
    if (!group) throw new Error('Group not found');
    return group;
  }

  public async createGroup(name: string): Promise<Group> {
    const currentUser = await this.requireCurrentUser();
    const newGroup: Group = {
      id: `group_${Date.now()}_${Math.random().toString(36).substring(2, 6)}`,
      name: name.trim(),
      ownerId: currentUser.id,
      ownerEmail: currentUser.email,
      memberEmails: [currentUser.email],
      createdAt: new Date().toISOString(),
    };
    this.groups.push(newGroup);
    this.saveState();
    return newGroup;
  }

  public async inviteMember(groupId: string, memberEmail: string): Promise<Group> {
    const currentUser = await this.requireCurrentUser();
    const group = this.groups.find((g) => g.id === groupId);
    if (!group) throw new Error('Group not found');

    // Any member or owner can invite
    if (!group.memberEmails.map((e) => e.toLowerCase()).includes(currentUser.email.toLowerCase())) {
      throw new Error('Not authorized to invite to this group');
    }

    const cleanEmail = memberEmail.trim().toLowerCase();
    if (!group.memberEmails.map((e) => e.toLowerCase()).includes(cleanEmail)) {
      group.memberEmails.push(cleanEmail);
      this.saveState();
    }
    return group;
  }

  public async removeMember(groupId: string, memberEmail: string): Promise<Group> {
    const currentUser = await this.requireCurrentUser();
    const group = this.groups.find((g) => g.id === groupId);
    if (!group) throw new Error('Group not found');

    if (group.ownerId !== currentUser.id) {
      throw new Error('Only the group owner can remove members');
    }

    const cleanEmail = memberEmail.trim().toLowerCase();
    // Cannot remove owner
    if (cleanEmail === group.ownerEmail.toLowerCase()) {
      throw new Error('Cannot remove the group owner');
    }

    group.memberEmails = group.memberEmails.filter((e) => e.toLowerCase() !== cleanEmail);
    // Decision 18: Shared group items remain with the group when a member departs!
    this.saveState();
    return group;
  }

  public async leaveGroup(groupId: string): Promise<void> {
    const currentUser = await this.requireCurrentUser();
    const group = this.groups.find((g) => g.id === groupId);
    if (!group) return;

    if (group.ownerId === currentUser.id) {
      throw new Error('Group owner cannot leave the group. Delete the group or transfer ownership.');
    }

    group.memberEmails = group.memberEmails.filter((e) => e.toLowerCase() !== currentUser.email.toLowerCase());
    // Decision 18: Shared group items remain with the group
    this.saveState();
  }

  /**
   * Decision 30: "Deleting a group deletes the group and all its shared items."
   */
  public async deleteGroup(groupId: string): Promise<void> {
    const currentUser = await this.requireCurrentUser();
    const group = this.groups.find((g) => g.id === groupId);
    if (!group) return;

    if (group.ownerId !== currentUser.id) {
      throw new Error('Only group owner can delete the group');
    }

    // 1. Delete all items in this group
    this.items = this.items.filter((item) => item.groupId !== groupId);

    // 2. Delete the group
    this.groups = this.groups.filter((g) => g.id !== groupId);

    this.saveState();
  }

  // ------------------ REMINDERS & DAILY DIGEST ------------------

  public async getRemindersSummary(): Promise<RemindersSummary> {
    const items = await this.getItems();
    const todayStr = getTodayDateString();

    const overdue: LifecycleItem[] = [];
    const upcoming: LifecycleItem[] = [];

    for (const item of items) {
      const urgency = getItemUrgency(item, todayStr);
      if (urgency === 'overdue') {
        overdue.push(item);
      } else if (urgency === 'upcoming') {
        upcoming.push(item);
      }
    }

    return {
      overdue,
      upcoming,
      totalActiveReminders: overdue.length + upcoming.length,
    };
  }

  public async getDigestConfig(): Promise<DailyDigestConfig> {
    return { ...this.digestConfig };
  }

  public async updateDigestConfig(config: Partial<DailyDigestConfig>): Promise<DailyDigestConfig> {
    this.digestConfig = {
      ...this.digestConfig,
      ...config,
      enabled: true, // both reminder channels are always enabled per Decision 22
    };
    this.saveState();
    return { ...this.digestConfig };
  }

  public async generateAndSendDailyDigest(recipientEmail?: string): Promise<DailyDigestEmail> {
    const currentUser = await this.requireCurrentUser();
    const targetEmail = recipientEmail || currentUser.email;
    const reminders = await this.getRemindersSummary();

    const email = buildDailyDigestEmail({
      recipientEmail: targetEmail,
      recipientName: currentUser.name,
      config: this.digestConfig,
      overdueItems: reminders.overdue,
      upcomingItems: reminders.upcoming,
    });

    this.digestHistory.unshift(email);
    // keep up to 20 past digests
    if (this.digestHistory.length > 20) {
      this.digestHistory = this.digestHistory.slice(0, 20);
    }
    this.saveState();

    return email;
  }

  public async getDigestHistory(): Promise<DailyDigestEmail[]> {
    return [...this.digestHistory];
  }
}