diff --git a/CLAUDE.md b/CLAUDE.md
index 6fd5d02..2ef2826 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -10,7 +10,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
- `npm run preview` - Preview production build locally
- `npm run analyze` - Build with bundle analysis (opens visualization)
-**Electron Development**
+**Electron Development**
- `npm run electron:dev` - Run both Vite dev server and Electron concurrently
- `npm run electron:build` - Full build and package for Electron
- `npm run start` - Start Electron using Forge
@@ -26,7 +26,7 @@ This is a modular Vue 3 + TypeScript + Vite application with Electron support, f
The application uses a plugin-based modular architecture with dependency injection for service management:
**Core Modules:**
-- **Base Module** (`src/modules/base/`) - Core infrastructure (Nostr, Auth, PWA)
+- **Base Module** (`src/modules/base/`) - Core infrastructure (Nostr, Auth, PWA, Image Upload)
- **Wallet Module** (`src/modules/wallet/`) - Lightning wallet management with real-time balance updates
- **Nostr Feed Module** (`src/modules/nostr-feed/`) - Social feed functionality
- **Chat Module** (`src/modules/chat/`) - Encrypted Nostr chat
@@ -90,6 +90,12 @@ const relayHub = injectService(SERVICE_TOKENS.RELAY_HUB)
- `SERVICE_TOKENS.VISIBILITY_SERVICE` - App visibility and connection management
- `SERVICE_TOKENS.WALLET_SERVICE` - Wallet operations (send, receive, transactions)
- `SERVICE_TOKENS.WALLET_WEBSOCKET_SERVICE` - Real-time wallet balance updates via WebSocket
+- `SERVICE_TOKENS.STORAGE_SERVICE` - Local storage management
+- `SERVICE_TOKENS.TOAST_SERVICE` - Toast notification system
+- `SERVICE_TOKENS.INVOICE_SERVICE` - Lightning invoice creation and management
+- `SERVICE_TOKENS.LNBITS_API` - LNbits API client
+- `SERVICE_TOKENS.IMAGE_UPLOAD_SERVICE` - Image upload to pictrs server
+- `SERVICE_TOKENS.NOSTR_METADATA_SERVICE` - Nostr user metadata (NIP-01 kind 0)
**Core Stack:**
- Vue 3 with Composition API (`
+
+
+
+
+
+
diff --git a/src/components/ui/alert-dialog/AlertDialogAction.vue b/src/components/ui/alert-dialog/AlertDialogAction.vue
new file mode 100644
index 0000000..09cf6fc
--- /dev/null
+++ b/src/components/ui/alert-dialog/AlertDialogAction.vue
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
diff --git a/src/components/ui/alert-dialog/AlertDialogCancel.vue b/src/components/ui/alert-dialog/AlertDialogCancel.vue
new file mode 100644
index 0000000..e261894
--- /dev/null
+++ b/src/components/ui/alert-dialog/AlertDialogCancel.vue
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
diff --git a/src/components/ui/alert-dialog/AlertDialogContent.vue b/src/components/ui/alert-dialog/AlertDialogContent.vue
new file mode 100644
index 0000000..73e4fcb
--- /dev/null
+++ b/src/components/ui/alert-dialog/AlertDialogContent.vue
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/ui/alert-dialog/AlertDialogDescription.vue b/src/components/ui/alert-dialog/AlertDialogDescription.vue
new file mode 100644
index 0000000..b6d165e
--- /dev/null
+++ b/src/components/ui/alert-dialog/AlertDialogDescription.vue
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
diff --git a/src/components/ui/alert-dialog/AlertDialogFooter.vue b/src/components/ui/alert-dialog/AlertDialogFooter.vue
new file mode 100644
index 0000000..c764e73
--- /dev/null
+++ b/src/components/ui/alert-dialog/AlertDialogFooter.vue
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
diff --git a/src/components/ui/alert-dialog/AlertDialogHeader.vue b/src/components/ui/alert-dialog/AlertDialogHeader.vue
new file mode 100644
index 0000000..b5e5540
--- /dev/null
+++ b/src/components/ui/alert-dialog/AlertDialogHeader.vue
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
diff --git a/src/components/ui/alert-dialog/AlertDialogTitle.vue b/src/components/ui/alert-dialog/AlertDialogTitle.vue
new file mode 100644
index 0000000..b829392
--- /dev/null
+++ b/src/components/ui/alert-dialog/AlertDialogTitle.vue
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
diff --git a/src/components/ui/alert-dialog/AlertDialogTrigger.vue b/src/components/ui/alert-dialog/AlertDialogTrigger.vue
new file mode 100644
index 0000000..f104dcc
--- /dev/null
+++ b/src/components/ui/alert-dialog/AlertDialogTrigger.vue
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
diff --git a/src/components/ui/alert-dialog/index.ts b/src/components/ui/alert-dialog/index.ts
new file mode 100644
index 0000000..cf1b45d
--- /dev/null
+++ b/src/components/ui/alert-dialog/index.ts
@@ -0,0 +1,9 @@
+export { default as AlertDialog } from "./AlertDialog.vue"
+export { default as AlertDialogAction } from "./AlertDialogAction.vue"
+export { default as AlertDialogCancel } from "./AlertDialogCancel.vue"
+export { default as AlertDialogContent } from "./AlertDialogContent.vue"
+export { default as AlertDialogDescription } from "./AlertDialogDescription.vue"
+export { default as AlertDialogFooter } from "./AlertDialogFooter.vue"
+export { default as AlertDialogHeader } from "./AlertDialogHeader.vue"
+export { default as AlertDialogTitle } from "./AlertDialogTitle.vue"
+export { default as AlertDialogTrigger } from "./AlertDialogTrigger.vue"
diff --git a/src/components/ui/button/Button.vue b/src/components/ui/button/Button.vue
index 17dc84d..374320b 100644
--- a/src/components/ui/button/Button.vue
+++ b/src/components/ui/button/Button.vue
@@ -1,22 +1,25 @@
svg]:px-3",
+ "sm": "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
+ "lg": "h-10 rounded-md px-6 has-[>svg]:px-4",
+ "icon": "size-9",
+ "icon-sm": "size-8",
+ "icon-lg": "size-10",
},
},
defaultVariants: {
- variant: 'default',
- size: 'default',
+ variant: "default",
+ size: "default",
},
},
)
diff --git a/src/composables/useModularNavigation.ts b/src/composables/useModularNavigation.ts
index af47f66..5ff695f 100644
--- a/src/composables/useModularNavigation.ts
+++ b/src/composables/useModularNavigation.ts
@@ -61,30 +61,40 @@ export function useModularNavigation() {
// Events module items
if (appConfig.modules.events.enabled) {
- items.push({
- name: 'My Tickets',
- href: '/my-tickets',
+ items.push({
+ name: 'My Tickets',
+ href: '/my-tickets',
icon: 'Ticket',
- requiresAuth: true
+ requiresAuth: true
})
}
- // Market module items
+ // Market module items
if (appConfig.modules.market.enabled) {
- items.push({
- name: 'Market Dashboard',
- href: '/market-dashboard',
+ items.push({
+ name: 'Market Dashboard',
+ href: '/market-dashboard',
icon: 'Store',
- requiresAuth: true
+ requiresAuth: true
+ })
+ }
+
+ // Expenses module items
+ if (appConfig.modules.expenses.enabled) {
+ items.push({
+ name: 'My Transactions',
+ href: '/expenses/transactions',
+ icon: 'Receipt',
+ requiresAuth: true
})
}
// Base module items (always available)
- items.push({
- name: 'Relay Hub Status',
- href: '/relay-hub-status',
+ items.push({
+ name: 'Relay Hub Status',
+ href: '/relay-hub-status',
icon: 'Activity',
- requiresAuth: true
+ requiresAuth: true
})
return items
diff --git a/src/composables/useQuickActions.ts b/src/composables/useQuickActions.ts
new file mode 100644
index 0000000..7046fbb
--- /dev/null
+++ b/src/composables/useQuickActions.ts
@@ -0,0 +1,95 @@
+import { computed } from 'vue'
+import { pluginManager } from '@/core/plugin-manager'
+import type { QuickAction } from '@/core/types'
+
+/**
+ * Composable for dynamic quick actions based on enabled modules
+ *
+ * Quick actions are module-provided action buttons that appear in the floating
+ * action button (FAB) menu. Each module can register its own quick actions
+ * for common tasks like composing notes, sending payments, adding expenses, etc.
+ *
+ * @example
+ * ```typescript
+ * const { quickActions, getActionsByCategory } = useQuickActions()
+ *
+ * // Get all actions
+ * const actions = quickActions.value
+ *
+ * // Get actions by category
+ * const composeActions = getActionsByCategory('compose')
+ * ```
+ */
+export function useQuickActions() {
+ /**
+ * Get all quick actions from installed modules
+ * Actions are sorted by order (lower = higher priority)
+ */
+ const quickActions = computed(() => {
+ const actions: QuickAction[] = []
+
+ // Iterate through installed modules
+ const installedModules = pluginManager.getInstalledModules()
+
+ for (const moduleName of installedModules) {
+ const module = pluginManager.getModule(moduleName)
+ if (module?.plugin.quickActions) {
+ actions.push(...module.plugin.quickActions)
+ }
+ }
+
+ // Sort by order (lower = higher priority), then by label
+ return actions.sort((a, b) => {
+ const orderA = a.order ?? 999
+ const orderB = b.order ?? 999
+ if (orderA !== orderB) {
+ return orderA - orderB
+ }
+ return a.label.localeCompare(b.label)
+ })
+ })
+
+ /**
+ * Get actions filtered by category
+ */
+ const getActionsByCategory = (category: string) => {
+ return computed(() => {
+ return quickActions.value.filter(action => action.category === category)
+ })
+ }
+
+ /**
+ * Get a specific action by ID
+ */
+ const getActionById = (id: string) => {
+ return computed(() => {
+ return quickActions.value.find(action => action.id === id)
+ })
+ }
+
+ /**
+ * Check if any actions are available
+ */
+ const hasActions = computed(() => quickActions.value.length > 0)
+
+ /**
+ * Get all unique categories
+ */
+ const categories = computed(() => {
+ const cats = new Set()
+ quickActions.value.forEach(action => {
+ if (action.category) {
+ cats.add(action.category)
+ }
+ })
+ return Array.from(cats).sort()
+ })
+
+ return {
+ quickActions,
+ getActionsByCategory,
+ getActionById,
+ hasActions,
+ categories
+ }
+}
diff --git a/src/core/di-container.ts b/src/core/di-container.ts
index 32221f5..0d27524 100644
--- a/src/core/di-container.ts
+++ b/src/core/di-container.ts
@@ -136,6 +136,7 @@ export const SERVICE_TOKENS = {
FEED_SERVICE: Symbol('feedService'),
PROFILE_SERVICE: Symbol('profileService'),
REACTION_SERVICE: Symbol('reactionService'),
+ SCHEDULED_EVENT_SERVICE: Symbol('scheduledEventService'),
// Nostr metadata services
NOSTR_METADATA_SERVICE: Symbol('nostrMetadataService'),
@@ -159,6 +160,9 @@ export const SERVICE_TOKENS = {
// Image upload services
IMAGE_UPLOAD_SERVICE: Symbol('imageUploadService'),
+
+ // Expenses services
+ EXPENSES_API: Symbol('expensesAPI'),
} as const
// Type-safe injection helpers
diff --git a/src/core/types.ts b/src/core/types.ts
index 9ce1e47..c866653 100644
--- a/src/core/types.ts
+++ b/src/core/types.ts
@@ -1,37 +1,64 @@
import type { App, Component } from 'vue'
import type { RouteRecordRaw } from 'vue-router'
+// Quick action interface for modular action buttons
+export interface QuickAction {
+ /** Unique action ID */
+ id: string
+
+ /** Display label for the action */
+ label: string
+
+ /** Lucide icon name */
+ icon: string
+
+ /** Component to render when action is selected */
+ component: Component
+
+ /** Display order (lower = higher priority) */
+ order?: number
+
+ /** Action category (e.g., 'compose', 'wallet', 'utilities') */
+ category?: string
+
+ /** Whether action requires authentication */
+ requiresAuth?: boolean
+}
+
// Base module plugin interface
export interface ModulePlugin {
/** Unique module name */
name: string
-
+
/** Module version */
version: string
-
+
/** Required dependencies (other module names) */
dependencies?: string[]
-
+
/** Module configuration */
config?: Record
-
+
/** Install the module */
install(app: App, options?: any): Promise | void
-
+
/** Uninstall the module (cleanup) */
uninstall?(): Promise | void
-
+
/** Routes provided by this module */
routes?: RouteRecordRaw[]
-
+
/** Components provided by this module */
components?: Record
-
+
/** Services provided by this module */
services?: Record
-
+
/** Composables provided by this module */
composables?: Record
+
+ /** Quick actions provided by this module */
+ quickActions?: QuickAction[]
}
// Module configuration for app setup
diff --git a/src/modules/base/nostr/relay-hub.ts b/src/modules/base/nostr/relay-hub.ts
index 486d3f2..7cdd00f 100644
--- a/src/modules/base/nostr/relay-hub.ts
+++ b/src/modules/base/nostr/relay-hub.ts
@@ -540,9 +540,13 @@ export class RelayHub extends BaseService {
const successful = results.filter(result => result.status === 'fulfilled').length
const total = results.length
-
this.emit('eventPublished', { eventId: event.id, success: successful, total })
+ // Throw error if no relays accepted the event
+ if (successful === 0) {
+ throw new Error(`Failed to publish event - none of the ${total} relay(s) accepted it`)
+ }
+
return { success: successful, total }
}
diff --git a/src/modules/expenses/components/AccountSelector.vue b/src/modules/expenses/components/AccountSelector.vue
new file mode 100644
index 0000000..2d92207
--- /dev/null
+++ b/src/modules/expenses/components/AccountSelector.vue
@@ -0,0 +1,270 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Loading accounts...
+
+
+
+
+
+
{{ error }}
+
+
+
+
+
+
+
+
+
+
+
+
No accounts available
+
+
+
+
+
+
+
+
+
Selected Account
+
{{ selectedAccount.name }}
+
+
+
{{ selectedAccount.account_type }}
+
+
+
+
+
diff --git a/src/modules/expenses/components/AddExpense.vue b/src/modules/expenses/components/AddExpense.vue
new file mode 100644
index 0000000..99f3462
--- /dev/null
+++ b/src/modules/expenses/components/AddExpense.vue
@@ -0,0 +1,469 @@
+
+
+
+
+
diff --git a/src/modules/expenses/components/admin/GrantPermissionDialog.vue b/src/modules/expenses/components/admin/GrantPermissionDialog.vue
new file mode 100644
index 0000000..20e7d87
--- /dev/null
+++ b/src/modules/expenses/components/admin/GrantPermissionDialog.vue
@@ -0,0 +1,256 @@
+
+
+
+
+
diff --git a/src/modules/expenses/components/admin/PermissionManager.vue b/src/modules/expenses/components/admin/PermissionManager.vue
new file mode 100644
index 0000000..99eac98
--- /dev/null
+++ b/src/modules/expenses/components/admin/PermissionManager.vue
@@ -0,0 +1,399 @@
+
+
+
+
+
+
+
Permission Management
+
+ Manage user access to expense accounts
+
+
+
+
+
+
+
+
+
+ Account Permissions
+
+
+ View and manage all account permissions. Permissions on parent accounts cascade to
+ children.
+
+
+
+
+
+
+
+ By User
+
+
+
+ By Account
+
+
+
+
+
+
+
+
+
+
+
No permissions granted yet
+
+
+
+
+
User: {{ userId }}
+
+
+
+ Account
+ Permission
+ Granted
+ Expires
+ Notes
+ Actions
+
+
+
+
+
+ {{ getAccountName(permission.account_id) }}
+
+
+
+ {{ getPermissionLabel(permission.permission_type) }}
+
+
+ {{ formatDate(permission.granted_at) }}
+
+ {{ permission.expires_at ? formatDate(permission.expires_at) : 'Never' }}
+
+
+
+ {{ permission.notes || '-' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
No permissions granted yet
+
+
+
+
+
Account: {{ getAccountName(accountId) }}
+
+
+
+ User
+ Permission
+ Granted
+ Expires
+ Notes
+ Actions
+
+
+
+
+ {{ permission.user_id }}
+
+
+ {{ getPermissionLabel(permission.permission_type) }}
+
+
+ {{ formatDate(permission.granted_at) }}
+
+ {{ permission.expires_at ? formatDate(permission.expires_at) : 'Never' }}
+
+
+
+ {{ permission.notes || '-' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Revoke Permission?
+
+ Are you sure you want to revoke this permission? The user will immediately lose access.
+
+
Permission Details:
+
+ User: {{ permissionToRevoke.user_id }}
+
+
+ Account: {{ getAccountName(permissionToRevoke.account_id) }}
+
+
+ Type: {{ getPermissionLabel(permissionToRevoke.permission_type) }}
+
+
+
+
+
+ Cancel
+
+ Revoke
+
+
+
+
+
+
diff --git a/src/modules/expenses/index.ts b/src/modules/expenses/index.ts
new file mode 100644
index 0000000..1ee1f3c
--- /dev/null
+++ b/src/modules/expenses/index.ts
@@ -0,0 +1,71 @@
+/**
+ * Expenses Module
+ *
+ * Provides expense tracking and submission functionality
+ * integrated with castle LNbits extension.
+ */
+
+import type { App } from 'vue'
+import { markRaw } from 'vue'
+import type { ModulePlugin } from '@/core/types'
+import { container, SERVICE_TOKENS } from '@/core/di-container'
+import { ExpensesAPI } from './services/ExpensesAPI'
+import AddExpense from './components/AddExpense.vue'
+import TransactionsPage from './views/TransactionsPage.vue'
+
+export const expensesModule: ModulePlugin = {
+ name: 'expenses',
+ version: '1.0.0',
+ dependencies: ['base'],
+
+ routes: [
+ {
+ path: '/expenses/transactions',
+ name: 'ExpenseTransactions',
+ component: TransactionsPage,
+ meta: {
+ requiresAuth: true,
+ title: 'My Transactions'
+ }
+ }
+ ],
+
+ quickActions: [
+ {
+ id: 'add-expense',
+ label: 'Expense',
+ icon: 'DollarSign',
+ component: markRaw(AddExpense),
+ category: 'finance',
+ order: 10,
+ requiresAuth: true
+ }
+ ],
+
+ async install(app: App) {
+ console.log('[Expenses Module] Installing...')
+
+ // 1. Create and register service
+ const expensesAPI = new ExpensesAPI()
+ container.provide(SERVICE_TOKENS.EXPENSES_API, expensesAPI)
+
+ // 2. Initialize service (wait for dependencies)
+ await expensesAPI.initialize({
+ waitForDependencies: true,
+ maxRetries: 3,
+ retryDelay: 1000
+ })
+
+ console.log('[Expenses Module] ExpensesAPI initialized')
+
+ // 3. Register components globally (optional, for use outside quick actions)
+ app.component('AddExpense', AddExpense)
+
+ console.log('[Expenses Module] Installed successfully')
+ }
+}
+
+export default expensesModule
+
+// Export types for use in other modules
+export type { Account, AccountNode, ExpenseEntry, ExpenseEntryRequest } from './types'
diff --git a/src/modules/expenses/services/ExpensesAPI.ts b/src/modules/expenses/services/ExpensesAPI.ts
new file mode 100644
index 0000000..9beaaa5
--- /dev/null
+++ b/src/modules/expenses/services/ExpensesAPI.ts
@@ -0,0 +1,450 @@
+/**
+ * API service for castle extension expense operations
+ */
+
+import { BaseService } from '@/core/base/BaseService'
+import type {
+ Account,
+ ExpenseEntryRequest,
+ ExpenseEntry,
+ AccountNode,
+ UserInfo,
+ AccountPermission,
+ GrantPermissionRequest,
+ TransactionListResponse
+} from '../types'
+import { appConfig } from '@/app.config'
+
+export class ExpensesAPI extends BaseService {
+ protected readonly metadata = {
+ name: 'ExpensesAPI',
+ version: '1.0.0',
+ dependencies: [] // No dependencies - wallet key is passed as parameter
+ }
+
+ private get config() {
+ return appConfig.modules.expenses.config
+ }
+
+ private get baseUrl() {
+ return this.config?.apiConfig?.baseUrl || ''
+ }
+
+ protected async onInitialize(): Promise {
+ console.log('[ExpensesAPI] Initialized with config:', {
+ baseUrl: this.baseUrl,
+ timeout: this.config?.apiConfig?.timeout
+ })
+ }
+
+ /**
+ * Get authentication headers with provided wallet key
+ */
+ private getHeaders(walletKey: string): HeadersInit {
+ return {
+ 'Content-Type': 'application/json',
+ 'X-Api-Key': walletKey
+ }
+ }
+
+ /**
+ * Get all accounts from castle
+ *
+ * @param walletKey - Wallet key for authentication
+ * @param filterByUser - If true, only return accounts the user has permissions for
+ * @param excludeVirtual - If true, exclude virtual parent accounts (default true for user views)
+ */
+ async getAccounts(
+ walletKey: string,
+ filterByUser: boolean = false,
+ excludeVirtual: boolean = true
+ ): Promise {
+ try {
+ const url = new URL(`${this.baseUrl}/castle/api/v1/accounts`)
+ if (filterByUser) {
+ url.searchParams.set('filter_by_user', 'true')
+ }
+ if (excludeVirtual) {
+ url.searchParams.set('exclude_virtual', 'true')
+ }
+
+ const response = await fetch(url.toString(), {
+ method: 'GET',
+ headers: this.getHeaders(walletKey),
+ signal: AbortSignal.timeout(this.config?.apiConfig?.timeout || 30000)
+ })
+
+ if (!response.ok) {
+ throw new Error(`Failed to fetch accounts: ${response.statusText}`)
+ }
+
+ const accounts = await response.json()
+ return accounts as Account[]
+ } catch (error) {
+ console.error('[ExpensesAPI] Error fetching accounts:', error)
+ throw error
+ }
+ }
+
+ /**
+ * Get accounts in hierarchical tree structure
+ *
+ * Converts flat account list to nested tree based on colon-separated names
+ * e.g., "Expenses:Groceries:Organic" becomes nested structure
+ *
+ * @param walletKey - Wallet key for authentication
+ * @param rootAccount - Optional root account to filter by (e.g., "Expenses")
+ * @param filterByUser - If true, only return accounts the user has permissions for
+ * @param excludeVirtual - If true, exclude virtual parent accounts (default true for user views)
+ */
+ async getAccountHierarchy(
+ walletKey: string,
+ rootAccount?: string,
+ filterByUser: boolean = false,
+ excludeVirtual: boolean = true
+ ): Promise {
+ const accounts = await this.getAccounts(walletKey, filterByUser, excludeVirtual)
+
+ // Filter by root account if specified
+ let filteredAccounts = accounts
+ if (rootAccount) {
+ filteredAccounts = accounts.filter(
+ (acc) =>
+ acc.name === rootAccount || acc.name.startsWith(`${rootAccount}:`)
+ )
+ }
+
+ // Build hierarchy
+ const accountMap = new Map()
+
+ // First pass: Create nodes for all accounts
+ for (const account of filteredAccounts) {
+ const parts = account.name.split(':')
+ const level = parts.length - 1
+ const parentName = parts.slice(0, -1).join(':')
+
+ accountMap.set(account.name, {
+ account: {
+ ...account,
+ level,
+ parent_account: parentName || undefined,
+ has_children: false
+ },
+ children: []
+ })
+ }
+
+ // Second pass: Build parent-child relationships
+ const rootNodes: AccountNode[] = []
+
+ for (const [_name, node] of accountMap.entries()) {
+ const parentName = node.account.parent_account
+
+ if (parentName && accountMap.has(parentName)) {
+ // Add to parent's children
+ const parent = accountMap.get(parentName)!
+ parent.children.push(node)
+ parent.account.has_children = true
+ } else {
+ // Root level node
+ rootNodes.push(node)
+ }
+ }
+
+ // Sort children by name at each level
+ const sortNodes = (nodes: AccountNode[]) => {
+ nodes.sort((a, b) => a.account.name.localeCompare(b.account.name))
+ nodes.forEach((node) => sortNodes(node.children))
+ }
+ sortNodes(rootNodes)
+
+ return rootNodes
+ }
+
+ /**
+ * Submit expense entry to castle
+ */
+ async submitExpense(walletKey: string, request: ExpenseEntryRequest): Promise {
+ try {
+ const response = await fetch(`${this.baseUrl}/castle/api/v1/entries/expense`, {
+ method: 'POST',
+ headers: this.getHeaders(walletKey),
+ body: JSON.stringify(request),
+ signal: AbortSignal.timeout(this.config?.apiConfig?.timeout || 30000)
+ })
+
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => ({}))
+ const errorMessage =
+ errorData.detail || `Failed to submit expense: ${response.statusText}`
+ throw new Error(errorMessage)
+ }
+
+ const entry = await response.json()
+ return entry as ExpenseEntry
+ } catch (error) {
+ console.error('[ExpensesAPI] Error submitting expense:', error)
+ throw error
+ }
+ }
+
+ /**
+ * Get user's expense entries
+ */
+ async getUserExpenses(walletKey: string): Promise {
+ try {
+ const response = await fetch(`${this.baseUrl}/castle/api/v1/entries/user`, {
+ method: 'GET',
+ headers: this.getHeaders(walletKey),
+ signal: AbortSignal.timeout(this.config?.apiConfig?.timeout || 30000)
+ })
+
+ if (!response.ok) {
+ throw new Error(
+ `Failed to fetch user expenses: ${response.statusText}`
+ )
+ }
+
+ const entries = await response.json()
+ return entries as ExpenseEntry[]
+ } catch (error) {
+ console.error('[ExpensesAPI] Error fetching user expenses:', error)
+ throw error
+ }
+ }
+
+ /**
+ * Get user's balance with castle
+ */
+ async getUserBalance(walletKey: string): Promise<{ balance: number; currency: string }> {
+ try {
+ const response = await fetch(`${this.baseUrl}/castle/api/v1/balance`, {
+ method: 'GET',
+ headers: this.getHeaders(walletKey),
+ signal: AbortSignal.timeout(this.config?.apiConfig?.timeout || 30000)
+ })
+
+ if (!response.ok) {
+ throw new Error(`Failed to fetch balance: ${response.statusText}`)
+ }
+
+ return await response.json()
+ } catch (error) {
+ console.error('[ExpensesAPI] Error fetching balance:', error)
+ throw error
+ }
+ }
+
+ /**
+ * Get available currencies from LNbits instance
+ */
+ async getCurrencies(): Promise {
+ try {
+ const response = await fetch(`${this.baseUrl}/api/v1/currencies`, {
+ method: 'GET',
+ signal: AbortSignal.timeout(this.config?.apiConfig?.timeout || 30000)
+ })
+
+ if (!response.ok) {
+ throw new Error(`Failed to fetch currencies: ${response.statusText}`)
+ }
+
+ return await response.json()
+ } catch (error) {
+ console.error('[ExpensesAPI] Error fetching currencies:', error)
+ throw error
+ }
+ }
+
+ /**
+ * Get default currency from LNbits instance
+ */
+ async getDefaultCurrency(): Promise {
+ try {
+ const response = await fetch(`${this.baseUrl}/api/v1/default-currency`, {
+ method: 'GET',
+ signal: AbortSignal.timeout(this.config?.apiConfig?.timeout || 30000)
+ })
+
+ if (!response.ok) {
+ throw new Error(`Failed to fetch default currency: ${response.statusText}`)
+ }
+
+ const data = await response.json()
+ return data.default_currency
+ } catch (error) {
+ console.error('[ExpensesAPI] Error fetching default currency:', error)
+ throw error
+ }
+ }
+
+ /**
+ * Get user information including equity eligibility
+ *
+ * @param walletKey - Wallet key for authentication (invoice key)
+ */
+ async getUserInfo(walletKey: string): Promise {
+ try {
+ const response = await fetch(`${this.baseUrl}/castle/api/v1/user/info`, {
+ method: 'GET',
+ headers: this.getHeaders(walletKey),
+ signal: AbortSignal.timeout(this.config?.apiConfig?.timeout || 30000)
+ })
+
+ if (!response.ok) {
+ throw new Error(`Failed to fetch user info: ${response.statusText}`)
+ }
+
+ return await response.json()
+ } catch (error) {
+ console.error('[ExpensesAPI] Error fetching user info:', error)
+ // Return default non-eligible user on error
+ return {
+ user_id: '',
+ is_equity_eligible: false
+ }
+ }
+ }
+
+ /**
+ * List all account permissions (admin only)
+ *
+ * @param adminKey - Admin key for authentication
+ */
+ async listPermissions(adminKey: string): Promise {
+ try {
+ const response = await fetch(`${this.baseUrl}/castle/api/v1/permissions`, {
+ method: 'GET',
+ headers: this.getHeaders(adminKey),
+ signal: AbortSignal.timeout(this.config?.apiConfig?.timeout || 30000)
+ })
+
+ if (!response.ok) {
+ throw new Error(`Failed to list permissions: ${response.statusText}`)
+ }
+
+ const permissions = await response.json()
+ return permissions as AccountPermission[]
+ } catch (error) {
+ console.error('[ExpensesAPI] Error listing permissions:', error)
+ throw error
+ }
+ }
+
+ /**
+ * Grant account permission to a user (admin only)
+ *
+ * @param adminKey - Admin key for authentication
+ * @param request - Permission grant request
+ */
+ async grantPermission(
+ adminKey: string,
+ request: GrantPermissionRequest
+ ): Promise {
+ try {
+ const response = await fetch(`${this.baseUrl}/castle/api/v1/permissions`, {
+ method: 'POST',
+ headers: this.getHeaders(adminKey),
+ body: JSON.stringify(request),
+ signal: AbortSignal.timeout(this.config?.apiConfig?.timeout || 30000)
+ })
+
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => ({}))
+ const errorMessage =
+ errorData.detail || `Failed to grant permission: ${response.statusText}`
+ throw new Error(errorMessage)
+ }
+
+ const permission = await response.json()
+ return permission as AccountPermission
+ } catch (error) {
+ console.error('[ExpensesAPI] Error granting permission:', error)
+ throw error
+ }
+ }
+
+ /**
+ * Revoke account permission (admin only)
+ *
+ * @param adminKey - Admin key for authentication
+ * @param permissionId - ID of the permission to revoke
+ */
+ async revokePermission(adminKey: string, permissionId: string): Promise {
+ try {
+ const response = await fetch(
+ `${this.baseUrl}/castle/api/v1/permissions/${permissionId}`,
+ {
+ method: 'DELETE',
+ headers: this.getHeaders(adminKey),
+ signal: AbortSignal.timeout(this.config?.apiConfig?.timeout || 30000)
+ }
+ )
+
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => ({}))
+ const errorMessage =
+ errorData.detail || `Failed to revoke permission: ${response.statusText}`
+ throw new Error(errorMessage)
+ }
+ } catch (error) {
+ console.error('[ExpensesAPI] Error revoking permission:', error)
+ throw error
+ }
+ }
+
+ /**
+ * Get user's transactions from journal
+ *
+ * @param walletKey - Wallet key for authentication (invoice key)
+ * @param options - Query options for filtering and pagination
+ */
+ async getUserTransactions(
+ walletKey: string,
+ options?: {
+ limit?: number
+ offset?: number
+ days?: number // 15, 30, or 60 (default: 15)
+ start_date?: string // ISO format: YYYY-MM-DD
+ end_date?: string // ISO format: YYYY-MM-DD
+ filter_user_id?: string
+ filter_account_type?: string
+ }
+ ): Promise {
+ try {
+ const url = new URL(`${this.baseUrl}/castle/api/v1/entries/user`)
+
+ // Add query parameters
+ if (options?.limit) url.searchParams.set('limit', String(options.limit))
+ if (options?.offset) url.searchParams.set('offset', String(options.offset))
+
+ // Custom date range takes precedence over days
+ if (options?.start_date && options?.end_date) {
+ url.searchParams.set('start_date', options.start_date)
+ url.searchParams.set('end_date', options.end_date)
+ } else if (options?.days) {
+ url.searchParams.set('days', String(options.days))
+ }
+
+ if (options?.filter_user_id)
+ url.searchParams.set('filter_user_id', options.filter_user_id)
+ if (options?.filter_account_type)
+ url.searchParams.set('filter_account_type', options.filter_account_type)
+
+ const response = await fetch(url.toString(), {
+ method: 'GET',
+ headers: this.getHeaders(walletKey),
+ signal: AbortSignal.timeout(this.config?.apiConfig?.timeout || 30000)
+ })
+
+ if (!response.ok) {
+ throw new Error(`Failed to fetch transactions: ${response.statusText}`)
+ }
+
+ return await response.json()
+ } catch (error) {
+ console.error('[ExpensesAPI] Error fetching transactions:', error)
+ throw error
+ }
+ }
+}
diff --git a/src/modules/expenses/types/index.ts b/src/modules/expenses/types/index.ts
new file mode 100644
index 0000000..c830a9e
--- /dev/null
+++ b/src/modules/expenses/types/index.ts
@@ -0,0 +1,164 @@
+/**
+ * Types for the Expenses module
+ */
+
+/**
+ * Account types in the castle double-entry accounting system
+ */
+export enum AccountType {
+ ASSET = 'asset',
+ LIABILITY = 'liability',
+ EQUITY = 'equity',
+ REVENUE = 'revenue',
+ EXPENSE = 'expense'
+}
+
+/**
+ * Account with hierarchical structure
+ */
+export interface Account {
+ id: string
+ name: string // e.g., "Expenses:Groceries:Organic"
+ account_type: AccountType
+ description?: string
+ user_id?: string
+ // Hierarchical metadata (added by frontend)
+ parent_account?: string
+ level?: number
+ has_children?: boolean
+}
+
+/**
+ * Account with user-specific permission metadata
+ * (Will be available once castle API implements permissions)
+ */
+export interface AccountWithPermissions extends Account {
+ user_permissions?: PermissionType[]
+ inherited_from?: string
+}
+
+/**
+ * Permission types for account access control
+ */
+export enum PermissionType {
+ READ = 'read',
+ SUBMIT_EXPENSE = 'submit_expense',
+ MANAGE = 'manage'
+}
+
+/**
+ * Expense entry request payload
+ */
+export interface ExpenseEntryRequest {
+ description: string
+ amount: number // Amount in the specified currency (or satoshis if currency is None)
+ expense_account: string // Account name or ID
+ is_equity: boolean
+ user_wallet: string
+ reference?: string
+ currency?: string // If None, amount is in satoshis. Otherwise, fiat currency code (e.g., "EUR", "USD")
+ entry_date?: string // ISO datetime string
+}
+
+/**
+ * Expense entry response from castle API
+ */
+export interface ExpenseEntry {
+ id: string
+ journal_id: string
+ description: string
+ amount: number
+ expense_account: string
+ is_equity: boolean
+ user_wallet: string
+ reference?: string
+ currency?: string
+ entry_date: string
+ created_at: string
+ status: 'pending' | 'approved' | 'rejected' | 'void'
+}
+
+/**
+ * Hierarchical account tree node for UI rendering
+ */
+export interface AccountNode {
+ account: Account
+ children: AccountNode[]
+}
+
+/**
+ * User information including equity eligibility
+ */
+export interface UserInfo {
+ user_id: string
+ is_equity_eligible: boolean
+ equity_account_name?: string
+}
+
+/**
+ * Account permission for user access control
+ */
+export interface AccountPermission {
+ id: string
+ user_id: string
+ account_id: string
+ permission_type: PermissionType
+ granted_at: string
+ granted_by: string
+ expires_at?: string
+ notes?: string
+}
+
+/**
+ * Grant permission request payload
+ */
+export interface GrantPermissionRequest {
+ user_id: string
+ account_id: string
+ permission_type: PermissionType
+ expires_at?: string
+ notes?: string
+}
+
+/**
+ * Transaction entry from journal (user view)
+ */
+export interface Transaction {
+ id: string
+ date: string
+ entry_date: string
+ flag?: string // *, !, #, x for cleared, pending, flagged, voided
+ description: string
+ payee?: string
+ tags: string[]
+ links: string[]
+ amount: number // Amount in satoshis
+ user_id?: string
+ username?: string
+ reference?: string
+ meta?: Record
+ fiat_amount?: number
+ fiat_currency?: string
+}
+
+/**
+ * Transaction list response with pagination
+ */
+export interface TransactionListResponse {
+ entries: Transaction[]
+ total: number
+ limit: number
+ offset: number
+ has_next: boolean
+ has_prev: boolean
+}
+
+/**
+ * Module configuration
+ */
+export interface ExpensesConfig {
+ apiConfig: {
+ baseUrl: string
+ timeout: number
+ }
+}
diff --git a/src/modules/expenses/views/TransactionsPage.vue b/src/modules/expenses/views/TransactionsPage.vue
new file mode 100644
index 0000000..de67fbc
--- /dev/null
+++ b/src/modules/expenses/views/TransactionsPage.vue
@@ -0,0 +1,367 @@
+
+
+
+
+
+
+
+
+
Transaction History
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Found {{ transactionsToDisplay.length }} matching transaction{{ transactionsToDisplay.length === 1 ? '' : 's' }}
+
+
+ {{ transactions.length }} transaction{{ transactions.length === 1 ? '' : 's' }}
+
+
+
+
+
+
+
+ Loading transactions...
+
+
+
+
+
+
No transactions found
+
+ {{ searchResults.length > 0 ? 'Try a different search term' : 'Try selecting a different time period' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ transaction.description }}
+
+
+
+ {{ formatDate(transaction.date) }}
+
+
+
+
+
+
+ {{ formatAmount(transaction.amount) }} sats
+
+
+ {{ transaction.fiat_amount.toFixed(2) }} {{ transaction.fiat_currency }}
+
+
+
+
+
+
+
+
+ Payee: {{ transaction.payee }}
+
+
+
+
+ Ref: {{ transaction.reference }}
+
+
+
+
+ User: {{ transaction.username }}
+
+
+
+
+
+ {{ tag }}
+
+
+
+
+
+ Source: {{ transaction.meta.source }}
+
+
+
+
+
+
+
+
+
+
diff --git a/src/modules/nostr-feed/components/NostrFeed.vue b/src/modules/nostr-feed/components/NostrFeed.vue
index 64790ef..529b7d7 100644
--- a/src/modules/nostr-feed/components/NostrFeed.vue
+++ b/src/modules/nostr-feed/components/NostrFeed.vue
@@ -9,13 +9,16 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
-import { Megaphone, RefreshCw, AlertCircle } from 'lucide-vue-next'
+import { Megaphone, RefreshCw, AlertCircle, ChevronLeft, ChevronRight } from 'lucide-vue-next'
import { useFeed } from '../composables/useFeed'
import { useProfiles } from '../composables/useProfiles'
import { useReactions } from '../composables/useReactions'
+import { useScheduledEvents } from '../composables/useScheduledEvents'
import ThreadedPost from './ThreadedPost.vue'
+import ScheduledEventCard from './ScheduledEventCard.vue'
import appConfig from '@/app.config'
import type { ContentFilter, FeedPost } from '../services/FeedService'
+import type { ScheduledEvent } from '../services/ScheduledEventService'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { AuthService } from '@/modules/base/auth/auth-service'
import type { RelayHub } from '@/modules/base/nostr/relay-hub'
@@ -95,6 +98,78 @@ const { getDisplayName, fetchProfiles } = useProfiles()
// Use reactions service for likes/hearts
const { getEventReactions, subscribeToReactions, toggleLike } = useReactions()
+// Use scheduled events service
+const {
+ getEventsForSpecificDate,
+ getCompletion,
+ getTaskStatus,
+ claimTask,
+ startTask,
+ completeEvent,
+ unclaimTask,
+ deleteTask,
+ allCompletions
+} = useScheduledEvents()
+
+// Selected date for viewing scheduled tasks (defaults to today)
+const selectedDate = ref(new Date().toISOString().split('T')[0])
+
+// Get scheduled tasks for the selected date (reactive)
+const scheduledEventsForDate = computed(() => getEventsForSpecificDate(selectedDate.value))
+
+// Navigate to previous day
+function goToPreviousDay() {
+ const date = new Date(selectedDate.value)
+ date.setDate(date.getDate() - 1)
+ selectedDate.value = date.toISOString().split('T')[0]
+}
+
+// Navigate to next day
+function goToNextDay() {
+ const date = new Date(selectedDate.value)
+ date.setDate(date.getDate() + 1)
+ selectedDate.value = date.toISOString().split('T')[0]
+}
+
+// Go back to today
+function goToToday() {
+ selectedDate.value = new Date().toISOString().split('T')[0]
+}
+
+// Check if selected date is today
+const isToday = computed(() => {
+ const today = new Date().toISOString().split('T')[0]
+ return selectedDate.value === today
+})
+
+// Format date for display
+const dateDisplayText = computed(() => {
+ const today = new Date().toISOString().split('T')[0]
+ const yesterday = new Date()
+ yesterday.setDate(yesterday.getDate() - 1)
+ const yesterdayStr = yesterday.toISOString().split('T')[0]
+ const tomorrow = new Date()
+ tomorrow.setDate(tomorrow.getDate() + 1)
+ const tomorrowStr = tomorrow.toISOString().split('T')[0]
+
+ if (selectedDate.value === today) {
+ return "Today's Tasks"
+ } else if (selectedDate.value === yesterdayStr) {
+ return "Yesterday's Tasks"
+ } else if (selectedDate.value === tomorrowStr) {
+ return "Tomorrow's Tasks"
+ } else {
+ // Format as "Tasks for Mon, Jan 15"
+ const date = new Date(selectedDate.value + 'T00:00:00')
+ const formatted = date.toLocaleDateString('en-US', {
+ weekday: 'short',
+ month: 'short',
+ day: 'numeric'
+ })
+ return `Tasks for ${formatted}`
+ }
+})
+
// Watch for new posts and fetch their profiles and reactions
watch(notes, async (newNotes) => {
if (newNotes.length > 0) {
@@ -109,6 +184,38 @@ watch(notes, async (newNotes) => {
}
}, { immediate: true })
+// Watch for scheduled events and fetch profiles for event authors and completers
+watch(scheduledEventsForDate, async (events) => {
+ if (events.length > 0) {
+ const pubkeys = new Set()
+
+ // Add event authors
+ events.forEach((event: ScheduledEvent) => {
+ pubkeys.add(event.pubkey)
+
+ // Add completer pubkey if event is completed
+ const eventAddress = `31922:${event.pubkey}:${event.dTag}`
+ const completion = getCompletion(eventAddress)
+ if (completion) {
+ pubkeys.add(completion.pubkey)
+ }
+ })
+
+ // Fetch all profiles
+ if (pubkeys.size > 0) {
+ await fetchProfiles([...pubkeys])
+ }
+ }
+}, { immediate: true })
+
+// Watch for new completions and fetch profiles for completers
+watch(allCompletions, async (completions) => {
+ if (completions.length > 0) {
+ const pubkeys = completions.map(c => c.pubkey)
+ await fetchProfiles(pubkeys)
+ }
+}, { immediate: true })
+
// Check if we have admin pubkeys configured
const hasAdminPubkeys = computed(() => adminPubkeys.length > 0)
@@ -158,6 +265,52 @@ async function onToggleLike(note: FeedPost) {
}
}
+// Task action handlers
+async function onClaimTask(event: ScheduledEvent, occurrence?: string) {
+ console.log('👋 NostrFeed: Claiming task:', event.title)
+ try {
+ await claimTask(event, '', occurrence)
+ } catch (error) {
+ console.error('❌ Failed to claim task:', error)
+ }
+}
+
+async function onStartTask(event: ScheduledEvent, occurrence?: string) {
+ console.log('▶️ NostrFeed: Starting task:', event.title)
+ try {
+ await startTask(event, '', occurrence)
+ } catch (error) {
+ console.error('❌ Failed to start task:', error)
+ }
+}
+
+async function onCompleteTask(event: ScheduledEvent, occurrence?: string) {
+ console.log('✅ NostrFeed: Completing task:', event.title)
+ try {
+ await completeEvent(event, occurrence, '')
+ } catch (error) {
+ console.error('❌ Failed to complete task:', error)
+ }
+}
+
+async function onUnclaimTask(event: ScheduledEvent, occurrence?: string) {
+ console.log('🔙 NostrFeed: Unclaiming task:', event.title)
+ try {
+ await unclaimTask(event, occurrence)
+ } catch (error) {
+ console.error('❌ Failed to unclaim task:', error)
+ }
+}
+
+async function onDeleteTask(event: ScheduledEvent) {
+ console.log('🗑️ NostrFeed: Deleting task:', event.title)
+ try {
+ await deleteTask(event)
+ } catch (error) {
+ console.error('❌ Failed to delete task:', error)
+ }
+}
+
// Handle collapse toggle with cascading behavior
function onToggleCollapse(postId: string) {
const newCollapsed = new Set(collapsedPosts.value)
@@ -356,20 +509,75 @@ function cancelDelete() {
-
-
-
-
- No posts yet
-
-
- Check back later for community updates.
-
-
-
-
+
+
+
+
+
+
+
+
+
+ 📅 {{ dateDisplayText }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ isToday ? 'no tasks today' : 'no tasks for this day' }}
+
+
+
+
+
+
+ 💬 Posts
+
+
+
+
+
+ No posts yet
+
+
+ Check back later for community updates.
+
+
+
-
diff --git a/src/modules/nostr-feed/components/ScheduledEventCard.vue b/src/modules/nostr-feed/components/ScheduledEventCard.vue
new file mode 100644
index 0000000..46c188e
--- /dev/null
+++ b/src/modules/nostr-feed/components/ScheduledEventCard.vue
@@ -0,0 +1,540 @@
+
+
+
+
+
+
+
+
+
+
+ {{ formattedTimeRange || formattedDate }}
+
+
+
+
+ {{ event.title }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ getDisplayName(completion.pubkey) }}
+
+
+
+
+ 🔄
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ formattedDate }}
+
+
+
+ {{ formattedTimeRange }}
+
+
+
+
+
+
+ {{ event.location }}
+
+
+
+
+
{{ event.description || event.content }}
+
+
+
+
+
+ ✓ Completed by {{ getDisplayName(completion.pubkey) }}
+ - {{ completion.notes }}
+
+
+ 🔄 In Progress by {{ getDisplayName(completion.pubkey) }}
+ - {{ completion.notes }}
+
+
+ 👋 Claimed by {{ getDisplayName(completion.pubkey) }}
+ - {{ completion.notes }}
+
+
+
+
+
+ Posted by {{ getDisplayName(event.pubkey) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/modules/nostr-feed/composables/useScheduledEvents.ts b/src/modules/nostr-feed/composables/useScheduledEvents.ts
new file mode 100644
index 0000000..580a26b
--- /dev/null
+++ b/src/modules/nostr-feed/composables/useScheduledEvents.ts
@@ -0,0 +1,261 @@
+import { computed } from 'vue'
+import { injectService, SERVICE_TOKENS } from '@/core/di-container'
+import type { ScheduledEventService, ScheduledEvent, EventCompletion, TaskStatus } from '../services/ScheduledEventService'
+import type { AuthService } from '@/modules/base/auth/auth-service'
+import { useToast } from '@/core/composables/useToast'
+
+/**
+ * Composable for managing scheduled events in the feed
+ */
+export function useScheduledEvents() {
+ const scheduledEventService = injectService
(SERVICE_TOKENS.SCHEDULED_EVENT_SERVICE)
+ const authService = injectService(SERVICE_TOKENS.AUTH_SERVICE)
+ const toast = useToast()
+
+ // Get current user's pubkey
+ const currentUserPubkey = computed(() => authService?.user.value?.pubkey)
+
+ /**
+ * Get all scheduled events
+ */
+ const getScheduledEvents = (): ScheduledEvent[] => {
+ if (!scheduledEventService) return []
+ return scheduledEventService.getScheduledEvents()
+ }
+
+ /**
+ * Get events for a specific date (YYYY-MM-DD)
+ */
+ const getEventsForDate = (date: string): ScheduledEvent[] => {
+ if (!scheduledEventService) return []
+ return scheduledEventService.getEventsForDate(date)
+ }
+
+ /**
+ * Get events for a specific date (filtered by current user participation)
+ * @param date - ISO date string (YYYY-MM-DD). Defaults to today.
+ */
+ const getEventsForSpecificDate = (date?: string): ScheduledEvent[] => {
+ if (!scheduledEventService) return []
+ return scheduledEventService.getEventsForSpecificDate(date, currentUserPubkey.value)
+ }
+
+ /**
+ * Get today's scheduled events (filtered by current user participation)
+ */
+ const getTodaysEvents = (): ScheduledEvent[] => {
+ if (!scheduledEventService) return []
+ return scheduledEventService.getTodaysEvents(currentUserPubkey.value)
+ }
+
+ /**
+ * Get completion status for an event
+ */
+ const getCompletion = (eventAddress: string): EventCompletion | undefined => {
+ if (!scheduledEventService) return undefined
+ return scheduledEventService.getCompletion(eventAddress)
+ }
+
+ /**
+ * Check if an event is completed
+ */
+ const isCompleted = (eventAddress: string): boolean => {
+ if (!scheduledEventService) return false
+ return scheduledEventService.isCompleted(eventAddress)
+ }
+
+ /**
+ * Get task status for an event
+ */
+ const getTaskStatus = (eventAddress: string, occurrence?: string): TaskStatus | null => {
+ if (!scheduledEventService) return null
+ return scheduledEventService.getTaskStatus(eventAddress, occurrence)
+ }
+
+ /**
+ * Claim a task
+ */
+ const claimTask = async (event: ScheduledEvent, notes: string = '', occurrence?: string): Promise => {
+ if (!scheduledEventService) {
+ toast.error('Scheduled event service not available')
+ return
+ }
+
+ try {
+ await scheduledEventService.claimTask(event, notes, occurrence)
+ toast.success('Task claimed!')
+ } catch (error) {
+ const message = error instanceof Error ? error.message : 'Failed to claim task'
+ if (message.includes('authenticated')) {
+ toast.error('Please sign in to claim tasks')
+ } else {
+ toast.error(message)
+ }
+ console.error('Failed to claim task:', error)
+ }
+ }
+
+ /**
+ * Start a task (mark as in-progress)
+ */
+ const startTask = async (event: ScheduledEvent, notes: string = '', occurrence?: string): Promise => {
+ if (!scheduledEventService) {
+ toast.error('Scheduled event service not available')
+ return
+ }
+
+ try {
+ await scheduledEventService.startTask(event, notes, occurrence)
+ toast.success('Task started!')
+ } catch (error) {
+ const message = error instanceof Error ? error.message : 'Failed to start task'
+ toast.error(message)
+ console.error('Failed to start task:', error)
+ }
+ }
+
+ /**
+ * Unclaim a task (remove task status)
+ */
+ const unclaimTask = async (event: ScheduledEvent, occurrence?: string): Promise => {
+ if (!scheduledEventService) {
+ toast.error('Scheduled event service not available')
+ return
+ }
+
+ try {
+ await scheduledEventService.unclaimTask(event, occurrence)
+ toast.success('Task unclaimed')
+ } catch (error) {
+ const message = error instanceof Error ? error.message : 'Failed to unclaim task'
+ toast.error(message)
+ console.error('Failed to unclaim task:', error)
+ }
+ }
+
+ /**
+ * Toggle completion status of an event (optionally for a specific occurrence)
+ * DEPRECATED: Use claimTask, startTask, completeEvent, or unclaimTask instead for more granular control
+ */
+ const toggleComplete = async (event: ScheduledEvent, occurrence?: string, notes: string = ''): Promise => {
+ console.log('🔧 useScheduledEvents: toggleComplete called for event:', event.title, 'occurrence:', occurrence)
+
+ if (!scheduledEventService) {
+ console.error('❌ useScheduledEvents: Scheduled event service not available')
+ toast.error('Scheduled event service not available')
+ return
+ }
+
+ try {
+ const eventAddress = `31922:${event.pubkey}:${event.dTag}`
+ const currentlyCompleted = scheduledEventService.isCompleted(eventAddress, occurrence)
+ console.log('📊 useScheduledEvents: Current completion status:', currentlyCompleted)
+
+ if (currentlyCompleted) {
+ console.log('⬇️ useScheduledEvents: Unclaiming task...')
+ await scheduledEventService.unclaimTask(event, occurrence)
+ toast.success('Task unclaimed')
+ } else {
+ console.log('⬆️ useScheduledEvents: Marking as complete...')
+ await scheduledEventService.completeEvent(event, notes, occurrence)
+ toast.success('Task completed!')
+ }
+ } catch (error) {
+ const message = error instanceof Error ? error.message : 'Failed to toggle completion'
+
+ if (message.includes('authenticated')) {
+ toast.error('Please sign in to complete tasks')
+ } else if (message.includes('Not connected')) {
+ toast.error('Not connected to relays')
+ } else {
+ toast.error(message)
+ }
+
+ console.error('❌ useScheduledEvents: Failed to toggle completion:', error)
+ }
+ }
+
+ /**
+ * Complete an event with optional notes
+ */
+ const completeEvent = async (event: ScheduledEvent, occurrence?: string, notes: string = ''): Promise => {
+ if (!scheduledEventService) {
+ toast.error('Scheduled event service not available')
+ return
+ }
+
+ try {
+ await scheduledEventService.completeEvent(event, notes, occurrence)
+ toast.success('Task completed!')
+ } catch (error) {
+ const message = error instanceof Error ? error.message : 'Failed to complete task'
+ toast.error(message)
+ console.error('Failed to complete task:', error)
+ }
+ }
+
+ /**
+ * Get loading state
+ */
+ const isLoading = computed(() => {
+ return scheduledEventService?.isLoading ?? false
+ })
+
+ /**
+ * Get all scheduled events (reactive)
+ */
+ const allScheduledEvents = computed(() => {
+ return scheduledEventService?.scheduledEvents ?? new Map()
+ })
+
+ /**
+ * Delete a task (only author can delete)
+ */
+ const deleteTask = async (event: ScheduledEvent): Promise => {
+ if (!scheduledEventService) {
+ toast.error('Scheduled event service not available')
+ return
+ }
+
+ try {
+ await scheduledEventService.deleteTask(event)
+ toast.success('Task deleted!')
+ } catch (error) {
+ const message = error instanceof Error ? error.message : 'Failed to delete task'
+ toast.error(message)
+ console.error('Failed to delete task:', error)
+ }
+ }
+
+ /**
+ * Get all completions (reactive) - returns array for better reactivity
+ */
+ const allCompletions = computed(() => {
+ if (!scheduledEventService?.completions) return []
+ return Array.from(scheduledEventService.completions.values())
+ })
+
+ return {
+ // Methods - Getters
+ getScheduledEvents,
+ getEventsForDate,
+ getEventsForSpecificDate,
+ getTodaysEvents,
+ getCompletion,
+ isCompleted,
+ getTaskStatus,
+
+ // Methods - Actions
+ claimTask,
+ startTask,
+ completeEvent,
+ unclaimTask,
+ deleteTask,
+ toggleComplete, // DEPRECATED: Use specific actions instead
+
+ // State
+ isLoading,
+ allScheduledEvents,
+ allCompletions
+ }
+}
diff --git a/src/modules/nostr-feed/config/content-filters.ts b/src/modules/nostr-feed/config/content-filters.ts
index 46808ee..903131d 100644
--- a/src/modules/nostr-feed/config/content-filters.ts
+++ b/src/modules/nostr-feed/config/content-filters.ts
@@ -88,6 +88,14 @@ export const CONTENT_FILTERS: Record = {
description: 'Rideshare requests, offers, and coordination',
tags: ['rideshare', 'carpool'], // NIP-12 tags
keywords: ['rideshare', 'ride share', 'carpool', '🚗', '🚶']
+ },
+
+ // Scheduled events (NIP-52)
+ scheduledEvents: {
+ id: 'scheduled-events',
+ label: 'Scheduled Events',
+ kinds: [31922], // NIP-52: Calendar Events
+ description: 'Calendar-based tasks and scheduled activities'
}
}
@@ -110,6 +118,11 @@ export const FILTER_PRESETS: Record = {
// Rideshare only
rideshare: [
CONTENT_FILTERS.rideshare
+ ],
+
+ // Scheduled events only
+ scheduledEvents: [
+ CONTENT_FILTERS.scheduledEvents
]
}
diff --git a/src/modules/nostr-feed/index.ts b/src/modules/nostr-feed/index.ts
index 9967476..c790302 100644
--- a/src/modules/nostr-feed/index.ts
+++ b/src/modules/nostr-feed/index.ts
@@ -1,11 +1,15 @@
import type { App } from 'vue'
+import { markRaw } from 'vue'
import type { ModulePlugin } from '@/core/types'
import { container, SERVICE_TOKENS } from '@/core/di-container'
import NostrFeed from './components/NostrFeed.vue'
+import NoteComposer from './components/NoteComposer.vue'
+import RideshareComposer from './components/RideshareComposer.vue'
import { useFeed } from './composables/useFeed'
import { FeedService } from './services/FeedService'
import { ProfileService } from './services/ProfileService'
import { ReactionService } from './services/ReactionService'
+import { ScheduledEventService } from './services/ScheduledEventService'
/**
* Nostr Feed Module Plugin
@@ -16,6 +20,28 @@ export const nostrFeedModule: ModulePlugin = {
version: '1.0.0',
dependencies: ['base'],
+ // Register quick actions for the FAB menu
+ quickActions: [
+ {
+ id: 'note',
+ label: 'Note',
+ icon: 'MessageSquare',
+ component: markRaw(NoteComposer),
+ category: 'compose',
+ order: 1,
+ requiresAuth: true
+ },
+ {
+ id: 'rideshare',
+ label: 'Rideshare',
+ icon: 'Car',
+ component: markRaw(RideshareComposer),
+ category: 'compose',
+ order: 2,
+ requiresAuth: true
+ }
+ ],
+
async install(app: App) {
console.log('nostr-feed module: Starting installation...')
@@ -23,10 +49,12 @@ export const nostrFeedModule: ModulePlugin = {
const feedService = new FeedService()
const profileService = new ProfileService()
const reactionService = new ReactionService()
+ const scheduledEventService = new ScheduledEventService()
container.provide(SERVICE_TOKENS.FEED_SERVICE, feedService)
container.provide(SERVICE_TOKENS.PROFILE_SERVICE, profileService)
container.provide(SERVICE_TOKENS.REACTION_SERVICE, reactionService)
+ container.provide(SERVICE_TOKENS.SCHEDULED_EVENT_SERVICE, scheduledEventService)
console.log('nostr-feed module: Services registered in DI container')
// Initialize services
@@ -43,6 +71,10 @@ export const nostrFeedModule: ModulePlugin = {
reactionService.initialize({
waitForDependencies: true,
maxRetries: 3
+ }),
+ scheduledEventService.initialize({
+ waitForDependencies: true,
+ maxRetries: 3
})
])
console.log('nostr-feed module: Services initialized')
diff --git a/src/modules/nostr-feed/services/FeedService.ts b/src/modules/nostr-feed/services/FeedService.ts
index 377186b..2141ed7 100644
--- a/src/modules/nostr-feed/services/FeedService.ts
+++ b/src/modules/nostr-feed/services/FeedService.ts
@@ -47,6 +47,7 @@ export class FeedService extends BaseService {
protected relayHub: any = null
protected visibilityService: any = null
protected reactionService: any = null
+ protected scheduledEventService: any = null
// Event ID tracking for deduplication
private seenEventIds = new Set()
@@ -72,10 +73,12 @@ export class FeedService extends BaseService {
this.relayHub = injectService(SERVICE_TOKENS.RELAY_HUB)
this.visibilityService = injectService(SERVICE_TOKENS.VISIBILITY_SERVICE)
this.reactionService = injectService(SERVICE_TOKENS.REACTION_SERVICE)
+ this.scheduledEventService = injectService(SERVICE_TOKENS.SCHEDULED_EVENT_SERVICE)
console.log('FeedService: RelayHub injected:', !!this.relayHub)
console.log('FeedService: VisibilityService injected:', !!this.visibilityService)
console.log('FeedService: ReactionService injected:', !!this.reactionService)
+ console.log('FeedService: ScheduledEventService injected:', !!this.scheduledEventService)
if (!this.relayHub) {
throw new Error('RelayHub service not available')
@@ -199,6 +202,12 @@ export class FeedService extends BaseService {
kinds: [5] // All deletion events (for both posts and reactions)
})
+ // Add scheduled events (kind 31922) and RSVPs (kind 31925)
+ filters.push({
+ kinds: [31922, 31925], // Calendar events and RSVPs
+ limit: 200
+ })
+
console.log(`Creating feed subscription for ${config.feedType} with filters:`, filters)
// Subscribe to all events (posts, reactions, deletions) with deduplication
@@ -257,6 +266,25 @@ export class FeedService extends BaseService {
return
}
+ // Route scheduled events (kind 31922) to ScheduledEventService
+ if (event.kind === 31922) {
+ if (this.scheduledEventService) {
+ this.scheduledEventService.handleScheduledEvent(event)
+ }
+ return
+ }
+
+ // Route RSVP/completion events (kind 31925) to ScheduledEventService
+ if (event.kind === 31925) {
+ console.log('🔀 FeedService: Routing kind 31925 (completion) to ScheduledEventService')
+ if (this.scheduledEventService) {
+ this.scheduledEventService.handleCompletionEvent(event)
+ } else {
+ console.warn('⚠️ FeedService: ScheduledEventService not available')
+ }
+ return
+ }
+
// Skip if event already seen (for posts only, kind 1)
if (this.seenEventIds.has(event.id)) {
return
@@ -355,6 +383,28 @@ export class FeedService extends BaseService {
return
}
+ // Route to ScheduledEventService for completion/RSVP deletions (kind 31925)
+ if (deletedKind === '31925') {
+ console.log('🔀 FeedService: Routing kind 5 (deletion of kind 31925) to ScheduledEventService')
+ if (this.scheduledEventService) {
+ this.scheduledEventService.handleDeletionEvent(event)
+ } else {
+ console.warn('⚠️ FeedService: ScheduledEventService not available')
+ }
+ return
+ }
+
+ // Route to ScheduledEventService for scheduled event deletions (kind 31922)
+ if (deletedKind === '31922') {
+ console.log('🔀 FeedService: Routing kind 5 (deletion of kind 31922) to ScheduledEventService')
+ if (this.scheduledEventService) {
+ this.scheduledEventService.handleTaskDeletion(event)
+ } else {
+ console.warn('⚠️ FeedService: ScheduledEventService not available')
+ }
+ return
+ }
+
// Handle post deletions (kind 1) in FeedService
if (deletedKind === '1' || !deletedKind) {
// Extract event IDs to delete from 'e' tags
diff --git a/src/modules/nostr-feed/services/ScheduledEventService.ts b/src/modules/nostr-feed/services/ScheduledEventService.ts
new file mode 100644
index 0000000..d2ef6b4
--- /dev/null
+++ b/src/modules/nostr-feed/services/ScheduledEventService.ts
@@ -0,0 +1,678 @@
+import { ref, reactive } from 'vue'
+import { BaseService } from '@/core/base/BaseService'
+import { injectService, SERVICE_TOKENS } from '@/core/di-container'
+import { finalizeEvent, type EventTemplate } from 'nostr-tools'
+import type { Event as NostrEvent } from 'nostr-tools'
+
+export interface RecurrencePattern {
+ frequency: 'daily' | 'weekly'
+ dayOfWeek?: string // For weekly: 'monday', 'tuesday', etc.
+ endDate?: string // ISO date string - when to stop recurring (optional)
+}
+
+export interface ScheduledEvent {
+ id: string
+ pubkey: string
+ created_at: number
+ dTag: string // Unique identifier from 'd' tag
+ title: string
+ start: string // ISO date string (YYYY-MM-DD or ISO datetime)
+ end?: string
+ description?: string
+ location?: string
+ status: string
+ eventType?: string // 'task' for completable events, 'announcement' for informational
+ participants?: Array<{ pubkey: string; type?: string }> // 'required', 'optional', 'organizer'
+ content: string
+ tags: string[][]
+ recurrence?: RecurrencePattern // Optional: for recurring events
+}
+
+export type TaskStatus = 'claimed' | 'in-progress' | 'completed' | 'blocked' | 'cancelled'
+
+export interface EventCompletion {
+ id: string
+ eventAddress: string // "31922:pubkey:d-tag"
+ occurrence?: string // ISO date string for the specific occurrence (YYYY-MM-DD)
+ pubkey: string // Who claimed/completed it
+ created_at: number
+ taskStatus: TaskStatus
+ completedAt?: number // Unix timestamp when completed
+ notes: string
+}
+
+export class ScheduledEventService extends BaseService {
+ protected readonly metadata = {
+ name: 'ScheduledEventService',
+ version: '1.0.0',
+ dependencies: []
+ }
+
+ protected relayHub: any = null
+ protected authService: any = null
+
+ // Scheduled events state - indexed by event address
+ private _scheduledEvents = reactive(new Map())
+ private _completions = reactive(new Map())
+ private _isLoading = ref(false)
+
+ protected async onInitialize(): Promise {
+ console.log('ScheduledEventService: Starting initialization...')
+
+ this.relayHub = injectService(SERVICE_TOKENS.RELAY_HUB)
+ this.authService = injectService(SERVICE_TOKENS.AUTH_SERVICE)
+
+ if (!this.relayHub) {
+ throw new Error('RelayHub service not available')
+ }
+
+ console.log('ScheduledEventService: Initialization complete')
+ }
+
+ /**
+ * Handle incoming scheduled event (kind 31922)
+ * Made public so FeedService can route kind 31922 events to this service
+ */
+ public handleScheduledEvent(event: NostrEvent): void {
+ try {
+ // Extract event data from tags
+ const dTag = event.tags.find(tag => tag[0] === 'd')?.[1]
+ if (!dTag) {
+ console.warn('Scheduled event missing d tag:', event.id)
+ return
+ }
+
+ const title = event.tags.find(tag => tag[0] === 'title')?.[1] || 'Untitled Event'
+ const start = event.tags.find(tag => tag[0] === 'start')?.[1]
+ const end = event.tags.find(tag => tag[0] === 'end')?.[1]
+ const description = event.tags.find(tag => tag[0] === 'description')?.[1]
+ const location = event.tags.find(tag => tag[0] === 'location')?.[1]
+ const status = event.tags.find(tag => tag[0] === 'status')?.[1] || 'pending'
+ const eventType = event.tags.find(tag => tag[0] === 'event-type')?.[1]
+
+ // Parse participant tags: ["p", "", "", ""]
+ const participantTags = event.tags.filter(tag => tag[0] === 'p')
+ const participants = participantTags.map(tag => ({
+ pubkey: tag[1],
+ type: tag[3] // 'required', 'optional', 'organizer'
+ }))
+
+ // Parse recurrence tags
+ const recurrenceFreq = event.tags.find(tag => tag[0] === 'recurrence')?.[1] as 'daily' | 'weekly' | undefined
+ const recurrenceDayOfWeek = event.tags.find(tag => tag[0] === 'recurrence-day')?.[1]
+ const recurrenceEndDate = event.tags.find(tag => tag[0] === 'recurrence-end')?.[1]
+
+ let recurrence: RecurrencePattern | undefined
+ if (recurrenceFreq === 'daily' || recurrenceFreq === 'weekly') {
+ recurrence = {
+ frequency: recurrenceFreq,
+ dayOfWeek: recurrenceDayOfWeek,
+ endDate: recurrenceEndDate
+ }
+ }
+
+ if (!start) {
+ console.warn('Scheduled event missing start date:', event.id)
+ return
+ }
+
+ // Create event address: "kind:pubkey:d-tag"
+ const eventAddress = `31922:${event.pubkey}:${dTag}`
+
+ const scheduledEvent: ScheduledEvent = {
+ id: event.id,
+ pubkey: event.pubkey,
+ created_at: event.created_at,
+ dTag,
+ title,
+ start,
+ end,
+ description,
+ location,
+ status,
+ eventType,
+ participants: participants.length > 0 ? participants : undefined,
+ content: event.content,
+ tags: event.tags,
+ recurrence
+ }
+
+ // Store or update the event (replaceable by d-tag)
+ this._scheduledEvents.set(eventAddress, scheduledEvent)
+
+ } catch (error) {
+ console.error('Failed to handle scheduled event:', error)
+ }
+ }
+
+ /**
+ * Handle RSVP/completion event (kind 31925)
+ * Made public so FeedService can route kind 31925 events to this service
+ */
+ public handleCompletionEvent(event: NostrEvent): void {
+ console.log('🔔 ScheduledEventService: Received completion event (kind 31925)', event.id)
+
+ try {
+ // Find the event being responded to
+ const aTag = event.tags.find(tag => tag[0] === 'a')?.[1]
+ if (!aTag) {
+ console.warn('Completion event missing a tag:', event.id)
+ return
+ }
+
+ // Parse task status (new approach)
+ const taskStatusTag = event.tags.find(tag => tag[0] === 'task-status')?.[1] as TaskStatus | undefined
+
+ // Backward compatibility: check old 'completed' tag if task-status not present
+ let taskStatus: TaskStatus
+ if (taskStatusTag) {
+ taskStatus = taskStatusTag
+ } else {
+ // Legacy support: convert old 'completed' tag to new taskStatus
+ const completed = event.tags.find(tag => tag[0] === 'completed')?.[1] === 'true'
+ taskStatus = completed ? 'completed' : 'claimed'
+ }
+
+ const completedAtTag = event.tags.find(tag => tag[0] === 'completed_at')?.[1]
+ const completedAt = completedAtTag ? parseInt(completedAtTag) : undefined
+ const occurrence = event.tags.find(tag => tag[0] === 'occurrence')?.[1] // ISO date string
+
+ console.log('📋 Completion details:', {
+ aTag,
+ occurrence,
+ taskStatus,
+ pubkey: event.pubkey,
+ eventId: event.id
+ })
+
+ const completion: EventCompletion = {
+ id: event.id,
+ eventAddress: aTag,
+ occurrence,
+ pubkey: event.pubkey,
+ created_at: event.created_at,
+ taskStatus,
+ completedAt,
+ notes: event.content
+ }
+
+ // Store completion (most recent one wins)
+ // For recurring events, include occurrence in the key: "eventAddress:occurrence"
+ // For non-recurring, just use eventAddress
+ const completionKey = occurrence ? `${aTag}:${occurrence}` : aTag
+ const existing = this._completions.get(completionKey)
+ if (!existing || event.created_at > existing.created_at) {
+ this._completions.set(completionKey, completion)
+ console.log('✅ Stored completion for:', completionKey, '- status:', taskStatus)
+ } else {
+ console.log('⏭️ Skipped older completion for:', completionKey)
+ }
+
+ } catch (error) {
+ console.error('Failed to handle completion event:', error)
+ }
+ }
+
+ /**
+ * Handle deletion event (kind 5) for completion events
+ * Made public so FeedService can route deletion events to this service
+ */
+ public handleDeletionEvent(event: NostrEvent): void {
+ console.log('🗑️ ScheduledEventService: Received deletion event (kind 5)', event.id)
+
+ try {
+ // Extract event IDs to delete from 'e' tags
+ const eventIdsToDelete = event.tags
+ ?.filter((tag: string[]) => tag[0] === 'e')
+ .map((tag: string[]) => tag[1]) || []
+
+ if (eventIdsToDelete.length === 0) {
+ console.warn('Deletion event missing e tags:', event.id)
+ return
+ }
+
+ console.log('🔍 Looking for completions to delete:', eventIdsToDelete)
+
+ // Find and remove completions that match the deleted event IDs
+ let deletedCount = 0
+ for (const [completionKey, completion] of this._completions.entries()) {
+ // Only delete if:
+ // 1. The completion event ID matches one being deleted
+ // 2. The deletion request comes from the same author (NIP-09 validation)
+ if (eventIdsToDelete.includes(completion.id) && completion.pubkey === event.pubkey) {
+ this._completions.delete(completionKey)
+ console.log('✅ Deleted completion:', completionKey, 'event ID:', completion.id)
+ deletedCount++
+ }
+ }
+
+ console.log(`🗑️ Deleted ${deletedCount} completion(s) from deletion event`)
+
+ } catch (error) {
+ console.error('Failed to handle deletion event:', error)
+ }
+ }
+
+ /**
+ * Handle deletion event (kind 5) for scheduled events (kind 31922)
+ * Made public so FeedService can route deletion events to this service
+ */
+ public handleTaskDeletion(event: NostrEvent): void {
+ console.log('🗑️ ScheduledEventService: Received task deletion event (kind 5)', event.id)
+
+ try {
+ // Extract event addresses to delete from 'a' tags
+ const eventAddressesToDelete = event.tags
+ ?.filter((tag: string[]) => tag[0] === 'a')
+ .map((tag: string[]) => tag[1]) || []
+
+ if (eventAddressesToDelete.length === 0) {
+ console.warn('Task deletion event missing a tags:', event.id)
+ return
+ }
+
+ console.log('🔍 Looking for tasks to delete:', eventAddressesToDelete)
+
+ // Find and remove tasks that match the deleted event addresses
+ let deletedCount = 0
+ for (const eventAddress of eventAddressesToDelete) {
+ const task = this._scheduledEvents.get(eventAddress)
+
+ // Only delete if:
+ // 1. The task exists
+ // 2. The deletion request comes from the task author (NIP-09 validation)
+ if (task && task.pubkey === event.pubkey) {
+ this._scheduledEvents.delete(eventAddress)
+ console.log('✅ Deleted task:', eventAddress)
+ deletedCount++
+ } else if (task) {
+ console.warn('⚠️ Deletion request not from task author:', eventAddress)
+ }
+ }
+
+ console.log(`🗑️ Deleted ${deletedCount} task(s) from deletion event`)
+
+ } catch (error) {
+ console.error('Failed to handle task deletion event:', error)
+ }
+ }
+
+ /**
+ * Get all scheduled events
+ */
+ getScheduledEvents(): ScheduledEvent[] {
+ return Array.from(this._scheduledEvents.values())
+ }
+
+ /**
+ * Get events scheduled for a specific date (YYYY-MM-DD)
+ */
+ getEventsForDate(date: string): ScheduledEvent[] {
+ return this.getScheduledEvents().filter(event => {
+ // Simple date matching (start date)
+ // For ISO datetime strings, extract just the date part
+ const eventDate = event.start.split('T')[0]
+ return eventDate === date
+ })
+ }
+
+ /**
+ * Check if a recurring event occurs on a specific date
+ */
+ private doesRecurringEventOccurOnDate(event: ScheduledEvent, targetDate: string): boolean {
+ if (!event.recurrence) return false
+
+ const target = new Date(targetDate)
+ const eventStart = new Date(event.start.split('T')[0]) // Get date part only
+
+ // Check if target date is before the event start date
+ if (target < eventStart) return false
+
+ // Check if target date is after the event end date (if specified)
+ if (event.recurrence.endDate) {
+ const endDate = new Date(event.recurrence.endDate)
+ if (target > endDate) return false
+ }
+
+ // Check frequency-specific rules
+ if (event.recurrence.frequency === 'daily') {
+ // Daily events occur every day within the range
+ return true
+ } else if (event.recurrence.frequency === 'weekly') {
+ // Weekly events occur on specific day of week
+ const targetDayOfWeek = target.toLocaleDateString('en-US', { weekday: 'long' }).toLowerCase()
+ const eventDayOfWeek = event.recurrence.dayOfWeek?.toLowerCase()
+ return targetDayOfWeek === eventDayOfWeek
+ }
+
+ return false
+ }
+
+ /**
+ * Get events for a specific date, optionally filtered by user participation
+ * @param date - ISO date string (YYYY-MM-DD). Defaults to today.
+ * @param userPubkey - Optional user pubkey to filter by participation
+ */
+ getEventsForSpecificDate(date?: string, userPubkey?: string): ScheduledEvent[] {
+ const targetDate = date || new Date().toISOString().split('T')[0]
+
+ // Get one-time events for the date (exclude recurring events to avoid duplicates)
+ const oneTimeEvents = this.getEventsForDate(targetDate).filter(event => !event.recurrence)
+
+ // Get all events and check for recurring events that occur on this date
+ const allEvents = this.getScheduledEvents()
+ const recurringEventsOnDate = allEvents.filter(event =>
+ event.recurrence && this.doesRecurringEventOccurOnDate(event, targetDate)
+ )
+
+ // Combine one-time and recurring events
+ let events = [...oneTimeEvents, ...recurringEventsOnDate]
+
+ // Filter events based on participation (if user pubkey provided)
+ if (userPubkey) {
+ events = events.filter(event => {
+ // If event has no participants, it's community-wide (show to everyone)
+ if (!event.participants || event.participants.length === 0) return true
+
+ // Otherwise, only show if user is a participant
+ return event.participants.some(p => p.pubkey === userPubkey)
+ })
+ }
+
+ // Sort by start time (ascending order)
+ events.sort((a, b) => {
+ // ISO datetime strings can be compared lexicographically
+ return a.start.localeCompare(b.start)
+ })
+
+ return events
+ }
+
+ /**
+ * Get events for today, optionally filtered by user participation
+ */
+ getTodaysEvents(userPubkey?: string): ScheduledEvent[] {
+ return this.getEventsForSpecificDate(undefined, userPubkey)
+ }
+
+ /**
+ * Get completion status for an event (optionally for a specific occurrence)
+ */
+ getCompletion(eventAddress: string, occurrence?: string): EventCompletion | undefined {
+ const completionKey = occurrence ? `${eventAddress}:${occurrence}` : eventAddress
+ return this._completions.get(completionKey)
+ }
+
+ /**
+ * Check if an event is completed (optionally for a specific occurrence)
+ */
+ isCompleted(eventAddress: string, occurrence?: string): boolean {
+ const completion = this.getCompletion(eventAddress, occurrence)
+ return completion?.taskStatus === 'completed'
+ }
+
+ /**
+ * Get task status for an event
+ */
+ getTaskStatus(eventAddress: string, occurrence?: string): TaskStatus | null {
+ const completion = this.getCompletion(eventAddress, occurrence)
+ return completion?.taskStatus || null
+ }
+
+ /**
+ * Claim a task (mark as claimed)
+ */
+ async claimTask(event: ScheduledEvent, notes: string = '', occurrence?: string): Promise {
+ await this.updateTaskStatus(event, 'claimed', notes, occurrence)
+ }
+
+ /**
+ * Start a task (mark as in-progress)
+ */
+ async startTask(event: ScheduledEvent, notes: string = '', occurrence?: string): Promise {
+ await this.updateTaskStatus(event, 'in-progress', notes, occurrence)
+ }
+
+ /**
+ * Mark an event as complete (optionally for a specific occurrence)
+ */
+ async completeEvent(event: ScheduledEvent, notes: string = '', occurrence?: string): Promise {
+ await this.updateTaskStatus(event, 'completed', notes, occurrence)
+ }
+
+ /**
+ * Internal method to update task status
+ */
+ private async updateTaskStatus(
+ event: ScheduledEvent,
+ taskStatus: TaskStatus,
+ notes: string = '',
+ occurrence?: string
+ ): Promise {
+ if (!this.authService?.isAuthenticated?.value) {
+ throw new Error('Must be authenticated to update task status')
+ }
+
+ if (!this.relayHub?.isConnected) {
+ throw new Error('Not connected to relays')
+ }
+
+ const userPrivkey = this.authService.user.value?.prvkey
+ if (!userPrivkey) {
+ throw new Error('User private key not available')
+ }
+
+ try {
+ this._isLoading.value = true
+
+ const eventAddress = `31922:${event.pubkey}:${event.dTag}`
+
+ // Create RSVP event with task-status tag
+ const tags: string[][] = [
+ ['a', eventAddress],
+ ['task-status', taskStatus]
+ ]
+
+ // Add completed_at timestamp if task is completed
+ if (taskStatus === 'completed') {
+ tags.push(['completed_at', Math.floor(Date.now() / 1000).toString()])
+ }
+
+ // Add occurrence tag if provided (for recurring events)
+ if (occurrence) {
+ tags.push(['occurrence', occurrence])
+ }
+
+ const eventTemplate: EventTemplate = {
+ kind: 31925, // Calendar Event RSVP
+ content: notes,
+ tags,
+ created_at: Math.floor(Date.now() / 1000)
+ }
+
+ // Sign the event
+ const privkeyBytes = this.hexToUint8Array(userPrivkey)
+ const signedEvent = finalizeEvent(eventTemplate, privkeyBytes)
+
+ // Publish the status update
+ console.log(`📤 Publishing task status update (${taskStatus}) for:`, eventAddress)
+ const result = await this.relayHub.publishEvent(signedEvent)
+ console.log('✅ Task status published to', result.success, '/', result.total, 'relays')
+
+ // Update local state (publishEvent throws if no relays accepted)
+ console.log('🔄 Updating local state (event published successfully)')
+ this.handleCompletionEvent(signedEvent)
+
+ } catch (error) {
+ console.error('Failed to update task status:', error)
+ throw error
+ } finally {
+ this._isLoading.value = false
+ }
+ }
+
+ /**
+ * Unclaim/reset a task (removes task status - makes it unclaimed)
+ * Note: In Nostr, we can't truly "delete" an event, but we can publish
+ * a deletion request (kind 5) to ask relays to remove our RSVP
+ */
+ async unclaimTask(event: ScheduledEvent, occurrence?: string): Promise {
+ if (!this.authService?.isAuthenticated?.value) {
+ throw new Error('Must be authenticated to unclaim tasks')
+ }
+
+ if (!this.relayHub?.isConnected) {
+ throw new Error('Not connected to relays')
+ }
+
+ const userPrivkey = this.authService.user.value?.prvkey
+ if (!userPrivkey) {
+ throw new Error('User private key not available')
+ }
+
+ try {
+ this._isLoading.value = true
+
+ const eventAddress = `31922:${event.pubkey}:${event.dTag}`
+ const completionKey = occurrence ? `${eventAddress}:${occurrence}` : eventAddress
+ const completion = this._completions.get(completionKey)
+
+ if (!completion) {
+ console.log('No completion to unclaim')
+ return
+ }
+
+ // Create deletion event (kind 5) for the RSVP
+ const deletionEvent: EventTemplate = {
+ kind: 5,
+ content: 'Task unclaimed',
+ tags: [
+ ['e', completion.id], // Reference to the RSVP event being deleted
+ ['k', '31925'] // Kind of event being deleted
+ ],
+ created_at: Math.floor(Date.now() / 1000)
+ }
+
+ // Sign the event
+ const privkeyBytes = this.hexToUint8Array(userPrivkey)
+ const signedEvent = finalizeEvent(deletionEvent, privkeyBytes)
+
+ // Publish the deletion request
+ console.log('📤 Publishing deletion request for task RSVP:', completion.id)
+ const result = await this.relayHub.publishEvent(signedEvent)
+ console.log('✅ Deletion request published to', result.success, '/', result.total, 'relays')
+
+ // Remove from local state (publishEvent throws if no relays accepted)
+ this._completions.delete(completionKey)
+ console.log('🗑️ Removed completion from local state:', completionKey)
+
+ } catch (error) {
+ console.error('Failed to unclaim task:', error)
+ throw error
+ } finally {
+ this._isLoading.value = false
+ }
+ }
+
+ /**
+ * Delete a scheduled event (kind 31922)
+ * Only the author can delete their own event
+ */
+ async deleteTask(event: ScheduledEvent): Promise {
+ if (!this.authService?.isAuthenticated?.value) {
+ throw new Error('Must be authenticated to delete tasks')
+ }
+
+ if (!this.relayHub?.isConnected) {
+ throw new Error('Not connected to relays')
+ }
+
+ const userPrivkey = this.authService.user.value?.prvkey
+ const userPubkey = this.authService.user.value?.pubkey
+
+ if (!userPrivkey || !userPubkey) {
+ throw new Error('User credentials not available')
+ }
+
+ // Only author can delete
+ if (userPubkey !== event.pubkey) {
+ throw new Error('Only the task author can delete this task')
+ }
+
+ try {
+ this._isLoading.value = true
+
+ const eventAddress = `31922:${event.pubkey}:${event.dTag}`
+
+ // Create deletion event (kind 5) for the scheduled event
+ const deletionEvent: EventTemplate = {
+ kind: 5,
+ content: 'Task deleted',
+ tags: [
+ ['a', eventAddress], // Reference to the parameterized replaceable event being deleted
+ ['k', '31922'] // Kind of event being deleted
+ ],
+ created_at: Math.floor(Date.now() / 1000)
+ }
+
+ // Sign the event
+ const privkeyBytes = this.hexToUint8Array(userPrivkey)
+ const signedEvent = finalizeEvent(deletionEvent, privkeyBytes)
+
+ // Publish the deletion request
+ console.log('📤 Publishing deletion request for task:', eventAddress)
+ const result = await this.relayHub.publishEvent(signedEvent)
+ console.log('✅ Task deletion request published to', result.success, '/', result.total, 'relays')
+
+ // Remove from local state (publishEvent throws if no relays accepted)
+ this._scheduledEvents.delete(eventAddress)
+ console.log('🗑️ Removed task from local state:', eventAddress)
+
+ } catch (error) {
+ console.error('Failed to delete task:', error)
+ throw error
+ } finally {
+ this._isLoading.value = false
+ }
+ }
+
+ /**
+ * Helper function to convert hex string to Uint8Array
+ */
+ private hexToUint8Array(hex: string): Uint8Array {
+ const bytes = new Uint8Array(hex.length / 2)
+ for (let i = 0; i < hex.length; i += 2) {
+ bytes[i / 2] = parseInt(hex.substr(i, 2), 16)
+ }
+ return bytes
+ }
+
+ /**
+ * Get all scheduled events
+ */
+ get scheduledEvents(): Map {
+ return this._scheduledEvents
+ }
+
+ /**
+ * Get all completions
+ */
+ get completions(): Map {
+ return this._completions
+ }
+
+ /**
+ * Check if currently loading
+ */
+ get isLoading(): boolean {
+ return this._isLoading.value
+ }
+
+ /**
+ * Cleanup
+ */
+ protected async onDestroy(): Promise {
+ this._scheduledEvents.clear()
+ this._completions.clear()
+ }
+}
diff --git a/src/pages/Home.vue b/src/pages/Home.vue
index 23d5c4d..9b78320 100644
--- a/src/pages/Home.vue
+++ b/src/pages/Home.vue
@@ -36,24 +36,20 @@