Compare commits
18 commits
main
...
castle-web
| Author | SHA1 | Date | |
|---|---|---|---|
| 1a38c92db1 | |||
| 2620c07a23 | |||
| 76b930469d | |||
| 0e42318036 | |||
| a27a8232f2 | |||
| 706ceea84b | |||
| 8381d43268 | |||
| 098bff8acc | |||
| 62c38185e8 | |||
| 9aa8c28bef | |||
| abaf7f2f5b | |||
| 4bf1da7331 | |||
| 661b700092 | |||
| 46418ef6fd | |||
| 033113829f | |||
| 4050b33d0e | |||
| 9b05bcc238 | |||
| b6d8a78cd8 |
9 changed files with 1135 additions and 34 deletions
35
package-lock.json
generated
35
package-lock.json
generated
|
|
@ -141,6 +141,7 @@
|
||||||
"integrity": "sha512-l+lkXCHS6tQEc5oUpK28xBOZ6+HwaH7YwoYQbLFiYb4nS2/l1tKnZEtEWkD0GuiYdvArf9qBS0XlQGXzPMsNqQ==",
|
"integrity": "sha512-l+lkXCHS6tQEc5oUpK28xBOZ6+HwaH7YwoYQbLFiYb4nS2/l1tKnZEtEWkD0GuiYdvArf9qBS0XlQGXzPMsNqQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ampproject/remapping": "^2.2.0",
|
"@ampproject/remapping": "^2.2.0",
|
||||||
"@babel/code-frame": "^7.26.2",
|
"@babel/code-frame": "^7.26.2",
|
||||||
|
|
@ -2646,6 +2647,7 @@
|
||||||
"integrity": "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==",
|
"integrity": "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"chalk": "^4.1.1",
|
"chalk": "^4.1.1",
|
||||||
"fs-extra": "^9.0.1",
|
"fs-extra": "^9.0.1",
|
||||||
|
|
@ -5688,6 +5690,7 @@
|
||||||
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-deep-equal": "^3.1.3",
|
"fast-deep-equal": "^3.1.3",
|
||||||
"fast-uri": "^3.0.1",
|
"fast-uri": "^3.0.1",
|
||||||
|
|
@ -5850,14 +5853,6 @@
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/async-validator": {
|
|
||||||
"version": "4.2.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz",
|
|
||||||
"integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"peer": true
|
|
||||||
},
|
|
||||||
"node_modules/at-least-node": {
|
"node_modules/at-least-node": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz",
|
||||||
|
|
@ -6058,6 +6053,7 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"caniuse-lite": "^1.0.30001688",
|
"caniuse-lite": "^1.0.30001688",
|
||||||
"electron-to-chromium": "^1.5.73",
|
"electron-to-chromium": "^1.5.73",
|
||||||
|
|
@ -7604,17 +7600,6 @@
|
||||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/encoding": {
|
|
||||||
"version": "0.1.13",
|
|
||||||
"resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
|
|
||||||
"integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
|
||||||
"iconv-lite": "^0.6.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/end-of-stream": {
|
"node_modules/end-of-stream": {
|
||||||
"version": "1.4.4",
|
"version": "1.4.4",
|
||||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz",
|
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz",
|
||||||
|
|
@ -8368,6 +8353,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.0.0.tgz",
|
||||||
"integrity": "sha512-14F4hBIxqKvD4Zz/XjDc3y94mNZN6pRv3U13Udo0lNLCWRBUsrMv2xwcF/y/Z5sV6+FQW+/ow68cHpm4sunt8Q==",
|
"integrity": "sha512-14F4hBIxqKvD4Zz/XjDc3y94mNZN6pRv3U13Udo0lNLCWRBUsrMv2xwcF/y/Z5sV6+FQW+/ow68cHpm4sunt8Q==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
|
|
@ -11718,6 +11704,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||||
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dijkstrajs": "^1.0.1",
|
"dijkstrajs": "^1.0.1",
|
||||||
"pngjs": "^5.0.0",
|
"pngjs": "^5.0.0",
|
||||||
|
|
@ -12361,6 +12348,7 @@
|
||||||
"integrity": "sha512-4iya7Jb76fVpQyLoiVpzUrsjQ12r3dM7fIVz+4NwoYvZOShknRmiv+iu9CClZml5ZLGb0XMcYLutK6w9tgxHDw==",
|
"integrity": "sha512-4iya7Jb76fVpQyLoiVpzUrsjQ12r3dM7fIVz+4NwoYvZOShknRmiv+iu9CClZml5ZLGb0XMcYLutK6w9tgxHDw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/estree": "1.0.8"
|
"@types/estree": "1.0.8"
|
||||||
},
|
},
|
||||||
|
|
@ -13370,7 +13358,8 @@
|
||||||
"version": "4.0.12",
|
"version": "4.0.12",
|
||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.0.12.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.0.12.tgz",
|
||||||
"integrity": "sha512-bT0hJo91FtncsAMSsMzUkoo/iEU0Xs5xgFgVC9XmdM9bw5MhZuQFjPNl6wxAE0SiQF/YTZJa+PndGWYSDtuxAg==",
|
"integrity": "sha512-bT0hJo91FtncsAMSsMzUkoo/iEU0Xs5xgFgVC9XmdM9bw5MhZuQFjPNl6wxAE0SiQF/YTZJa+PndGWYSDtuxAg==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/tailwindcss-animate": {
|
"node_modules/tailwindcss-animate": {
|
||||||
"version": "1.0.7",
|
"version": "1.0.7",
|
||||||
|
|
@ -13505,6 +13494,7 @@
|
||||||
"integrity": "sha512-GWANVlPM/ZfYzuPHjq0nxT+EbOEDDN3Jwhwdg1D8TU8oSkktp8w64Uq4auuGLxFSoNTRDncTq2hQHX1Ld9KHkA==",
|
"integrity": "sha512-GWANVlPM/ZfYzuPHjq0nxT+EbOEDDN3Jwhwdg1D8TU8oSkktp8w64Uq4auuGLxFSoNTRDncTq2hQHX1Ld9KHkA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/source-map": "^0.3.3",
|
"@jridgewell/source-map": "^0.3.3",
|
||||||
"acorn": "^8.8.2",
|
"acorn": "^8.8.2",
|
||||||
|
|
@ -13734,6 +13724,7 @@
|
||||||
"integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==",
|
"integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
|
|
@ -13984,6 +13975,7 @@
|
||||||
"integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==",
|
"integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"fdir": "^6.4.4",
|
"fdir": "^6.4.4",
|
||||||
|
|
@ -14208,6 +14200,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.13.tgz",
|
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.13.tgz",
|
||||||
"integrity": "sha512-wmeiSMxkZCSc+PM2w2VRsOYAZC8GdipNFRTsLSfodVqI9mbejKeXEGr8SckuLnrQPGe3oJN5c3K0vpoU9q/wCQ==",
|
"integrity": "sha512-wmeiSMxkZCSc+PM2w2VRsOYAZC8GdipNFRTsLSfodVqI9mbejKeXEGr8SckuLnrQPGe3oJN5c3K0vpoU9q/wCQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/compiler-dom": "3.5.13",
|
"@vue/compiler-dom": "3.5.13",
|
||||||
"@vue/compiler-sfc": "3.5.13",
|
"@vue/compiler-sfc": "3.5.13",
|
||||||
|
|
@ -14660,6 +14653,7 @@
|
||||||
"integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==",
|
"integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"rollup": "dist/bin/rollup"
|
"rollup": "dist/bin/rollup"
|
||||||
},
|
},
|
||||||
|
|
@ -14917,6 +14911,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -136,6 +136,7 @@ export const SERVICE_TOKENS = {
|
||||||
FEED_SERVICE: Symbol('feedService'),
|
FEED_SERVICE: Symbol('feedService'),
|
||||||
PROFILE_SERVICE: Symbol('profileService'),
|
PROFILE_SERVICE: Symbol('profileService'),
|
||||||
REACTION_SERVICE: Symbol('reactionService'),
|
REACTION_SERVICE: Symbol('reactionService'),
|
||||||
|
SCHEDULED_EVENT_SERVICE: Symbol('scheduledEventService'),
|
||||||
|
|
||||||
// Nostr metadata services
|
// Nostr metadata services
|
||||||
NOSTR_METADATA_SERVICE: Symbol('nostrMetadataService'),
|
NOSTR_METADATA_SERVICE: Symbol('nostrMetadataService'),
|
||||||
|
|
|
||||||
|
|
@ -9,13 +9,16 @@ import {
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog'
|
} 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 { useFeed } from '../composables/useFeed'
|
||||||
import { useProfiles } from '../composables/useProfiles'
|
import { useProfiles } from '../composables/useProfiles'
|
||||||
import { useReactions } from '../composables/useReactions'
|
import { useReactions } from '../composables/useReactions'
|
||||||
|
import { useScheduledEvents } from '../composables/useScheduledEvents'
|
||||||
import ThreadedPost from './ThreadedPost.vue'
|
import ThreadedPost from './ThreadedPost.vue'
|
||||||
|
import ScheduledEventCard from './ScheduledEventCard.vue'
|
||||||
import appConfig from '@/app.config'
|
import appConfig from '@/app.config'
|
||||||
import type { ContentFilter, FeedPost } from '../services/FeedService'
|
import type { ContentFilter, FeedPost } from '../services/FeedService'
|
||||||
|
import type { ScheduledEvent } from '../services/ScheduledEventService'
|
||||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||||
import type { AuthService } from '@/modules/base/auth/auth-service'
|
import type { AuthService } from '@/modules/base/auth/auth-service'
|
||||||
import type { RelayHub } from '@/modules/base/nostr/relay-hub'
|
import type { RelayHub } from '@/modules/base/nostr/relay-hub'
|
||||||
|
|
@ -95,6 +98,68 @@ const { getDisplayName, fetchProfiles } = useProfiles()
|
||||||
// Use reactions service for likes/hearts
|
// Use reactions service for likes/hearts
|
||||||
const { getEventReactions, subscribeToReactions, toggleLike } = useReactions()
|
const { getEventReactions, subscribeToReactions, toggleLike } = useReactions()
|
||||||
|
|
||||||
|
// Use scheduled events service
|
||||||
|
const { getEventsForSpecificDate, getCompletion, toggleComplete, allCompletions } = useScheduledEvents()
|
||||||
|
|
||||||
|
// Selected date for viewing events (defaults to today)
|
||||||
|
const selectedDate = ref(new Date().toISOString().split('T')[0])
|
||||||
|
|
||||||
|
// Get scheduled events 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 Events"
|
||||||
|
} else if (selectedDate.value === yesterdayStr) {
|
||||||
|
return "Yesterday's Events"
|
||||||
|
} else if (selectedDate.value === tomorrowStr) {
|
||||||
|
return "Tomorrow's Events"
|
||||||
|
} else {
|
||||||
|
// Format as "Events 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 `Events for ${formatted}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// Watch for new posts and fetch their profiles and reactions
|
// Watch for new posts and fetch their profiles and reactions
|
||||||
watch(notes, async (newNotes) => {
|
watch(notes, async (newNotes) => {
|
||||||
if (newNotes.length > 0) {
|
if (newNotes.length > 0) {
|
||||||
|
|
@ -109,6 +174,38 @@ watch(notes, async (newNotes) => {
|
||||||
}
|
}
|
||||||
}, { immediate: true })
|
}, { 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<string>()
|
||||||
|
|
||||||
|
// 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
|
// Check if we have admin pubkeys configured
|
||||||
const hasAdminPubkeys = computed(() => adminPubkeys.length > 0)
|
const hasAdminPubkeys = computed(() => adminPubkeys.length > 0)
|
||||||
|
|
||||||
|
|
@ -158,6 +255,17 @@ async function onToggleLike(note: FeedPost) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle scheduled event completion toggle
|
||||||
|
async function onToggleComplete(event: ScheduledEvent, occurrence?: string) {
|
||||||
|
console.log('🎯 NostrFeed: onToggleComplete called for event:', event.title, 'occurrence:', occurrence)
|
||||||
|
try {
|
||||||
|
await toggleComplete(event, occurrence)
|
||||||
|
console.log('✅ NostrFeed: toggleComplete succeeded')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ NostrFeed: Failed to toggle event completion:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Handle collapse toggle with cascading behavior
|
// Handle collapse toggle with cascading behavior
|
||||||
function onToggleCollapse(postId: string) {
|
function onToggleCollapse(postId: string) {
|
||||||
const newCollapsed = new Set(collapsedPosts.value)
|
const newCollapsed = new Set(collapsedPosts.value)
|
||||||
|
|
@ -356,20 +464,70 @@ function cancelDelete() {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- No Posts -->
|
|
||||||
<div v-else-if="threadedPosts.length === 0" class="text-center py-8 px-4">
|
|
||||||
<div class="flex items-center justify-center gap-2 text-muted-foreground mb-4">
|
|
||||||
<Megaphone class="h-5 w-5" />
|
|
||||||
<span>No posts yet</span>
|
|
||||||
</div>
|
|
||||||
<p class="text-sm text-muted-foreground">
|
|
||||||
Check back later for community updates.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Posts List - Natural flow without internal scrolling -->
|
<!-- Posts List - Natural flow without internal scrolling -->
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<div class="md:space-y-4 md:py-4">
|
<!-- Scheduled Events Section with Date Navigation -->
|
||||||
|
<div class="my-2 md:my-4">
|
||||||
|
<div class="flex items-center justify-between px-4 md:px-0 mb-3">
|
||||||
|
<!-- Left Arrow -->
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-8 w-8"
|
||||||
|
@click="goToPreviousDay"
|
||||||
|
>
|
||||||
|
<ChevronLeft class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<!-- Date Header with Today Button -->
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<h3 class="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
|
||||||
|
📅 {{ dateDisplayText }}
|
||||||
|
</h3>
|
||||||
|
<Button
|
||||||
|
v-if="!isToday"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
class="h-6 text-xs"
|
||||||
|
@click="goToToday"
|
||||||
|
>
|
||||||
|
Today
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right Arrow -->
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-8 w-8"
|
||||||
|
@click="goToNextDay"
|
||||||
|
>
|
||||||
|
<ChevronRight class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Events List or Empty State -->
|
||||||
|
<div v-if="scheduledEventsForDate.length > 0" class="md:space-y-3">
|
||||||
|
<ScheduledEventCard
|
||||||
|
v-for="event in scheduledEventsForDate"
|
||||||
|
:key="`${event.pubkey}:${event.dTag}`"
|
||||||
|
:event="event"
|
||||||
|
:get-display-name="getDisplayName"
|
||||||
|
:get-completion="getCompletion"
|
||||||
|
:admin-pubkeys="adminPubkeys"
|
||||||
|
@toggle-complete="onToggleComplete"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-center py-3 text-muted-foreground text-sm px-4">
|
||||||
|
{{ isToday ? 'no tasks today' : 'no tasks for this day' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Posts Section -->
|
||||||
|
<div v-if="threadedPosts.length > 0" class="md:space-y-4 md:py-4">
|
||||||
|
<h3 v-if="scheduledEventsForDate.length > 0" class="text-sm font-semibold text-muted-foreground uppercase tracking-wide px-4 md:px-0 mb-3 mt-6">
|
||||||
|
💬 Posts
|
||||||
|
</h3>
|
||||||
<ThreadedPost
|
<ThreadedPost
|
||||||
v-for="post in threadedPosts"
|
v-for="post in threadedPosts"
|
||||||
:key="post.id"
|
:key="post.id"
|
||||||
|
|
@ -390,8 +548,19 @@ function cancelDelete() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- No Posts Message (show whenever there are no posts, regardless of events) -->
|
||||||
|
<div v-else class="text-center py-8 px-4">
|
||||||
|
<div class="flex items-center justify-center gap-2 text-muted-foreground mb-4">
|
||||||
|
<Megaphone class="h-5 w-5" />
|
||||||
|
<span>No posts yet</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
Check back later for community updates.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- End of feed message -->
|
<!-- End of feed message -->
|
||||||
<div class="text-center py-6 text-md text-muted-foreground">
|
<div v-if="threadedPosts.length > 0" class="text-center py-6 text-md text-muted-foreground">
|
||||||
<p>🐢</p>
|
<p>🐢</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
246
src/modules/nostr-feed/components/ScheduledEventCard.vue
Normal file
246
src/modules/nostr-feed/components/ScheduledEventCard.vue
Normal file
|
|
@ -0,0 +1,246 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import {
|
||||||
|
Collapsible,
|
||||||
|
CollapsibleContent,
|
||||||
|
CollapsibleTrigger,
|
||||||
|
} from '@/components/ui/collapsible'
|
||||||
|
import { Calendar, MapPin, Clock, CheckCircle } from 'lucide-vue-next'
|
||||||
|
import type { ScheduledEvent, EventCompletion } from '../services/ScheduledEventService'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
event: ScheduledEvent
|
||||||
|
getDisplayName: (pubkey: string) => string
|
||||||
|
getCompletion: (eventAddress: string, occurrence?: string) => EventCompletion | undefined
|
||||||
|
adminPubkeys?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: 'toggle-complete', event: ScheduledEvent, occurrence?: string): void
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
adminPubkeys: () => []
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<Emits>()
|
||||||
|
|
||||||
|
// Confirmation dialog state
|
||||||
|
const showConfirmDialog = ref(false)
|
||||||
|
|
||||||
|
// Event address for tracking completion
|
||||||
|
const eventAddress = computed(() => `31922:${props.event.pubkey}:${props.event.dTag}`)
|
||||||
|
|
||||||
|
// Check if this is a recurring event
|
||||||
|
const isRecurring = computed(() => !!props.event.recurrence)
|
||||||
|
|
||||||
|
// For recurring events, occurrence is today's date. For non-recurring, it's undefined.
|
||||||
|
const occurrence = computed(() => {
|
||||||
|
if (!isRecurring.value) return undefined
|
||||||
|
return new Date().toISOString().split('T')[0] // YYYY-MM-DD
|
||||||
|
})
|
||||||
|
|
||||||
|
// Check if this is an admin event
|
||||||
|
const isAdminEvent = computed(() => props.adminPubkeys.includes(props.event.pubkey))
|
||||||
|
|
||||||
|
// Check if event is completed - call function with occurrence for recurring events
|
||||||
|
const isCompleted = computed(() => props.getCompletion(eventAddress.value, occurrence.value)?.completed || false)
|
||||||
|
|
||||||
|
// Check if event is completable (task type)
|
||||||
|
const isCompletable = computed(() => props.event.eventType === 'task')
|
||||||
|
|
||||||
|
// Format the date/time
|
||||||
|
const formattedDate = computed(() => {
|
||||||
|
try {
|
||||||
|
const date = new Date(props.event.start)
|
||||||
|
|
||||||
|
// Check if it's a datetime or just date
|
||||||
|
if (props.event.start.includes('T')) {
|
||||||
|
// Full datetime - show date and time
|
||||||
|
return date.toLocaleString('en-US', {
|
||||||
|
weekday: 'short',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// Just date
|
||||||
|
return date.toLocaleDateString('en-US', {
|
||||||
|
weekday: 'short',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return props.event.start
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Format the time range if end time exists
|
||||||
|
const formattedTimeRange = computed(() => {
|
||||||
|
if (!props.event.end || !props.event.start.includes('T')) return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const start = new Date(props.event.start)
|
||||||
|
const end = new Date(props.event.end)
|
||||||
|
|
||||||
|
const startTime = start.toLocaleTimeString('en-US', {
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
const endTime = end.toLocaleTimeString('en-US', {
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
|
||||||
|
return `${startTime} - ${endTime}`
|
||||||
|
} catch (error) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handle mark complete button click - show confirmation dialog
|
||||||
|
function handleMarkComplete() {
|
||||||
|
console.log('🔘 Mark Complete button clicked for event:', props.event.title)
|
||||||
|
showConfirmDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm and execute mark complete
|
||||||
|
function confirmMarkComplete() {
|
||||||
|
console.log('✅ Confirmed mark complete for event:', props.event.title, 'occurrence:', occurrence.value)
|
||||||
|
emit('toggle-complete', props.event, occurrence.value)
|
||||||
|
showConfirmDialog.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel mark complete
|
||||||
|
function cancelMarkComplete() {
|
||||||
|
showConfirmDialog.value = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Collapsible class="border-b md:border md:rounded-lg bg-card transition-all"
|
||||||
|
:class="{ 'opacity-60': isCompletable && isCompleted }">
|
||||||
|
<!-- Collapsed View (Trigger) -->
|
||||||
|
<CollapsibleTrigger as-child>
|
||||||
|
<div class="flex items-center gap-3 p-3 md:p-4 cursor-pointer hover:bg-accent/50 transition-colors">
|
||||||
|
<!-- Time -->
|
||||||
|
<div class="flex items-center gap-1.5 text-sm text-muted-foreground shrink-0">
|
||||||
|
<Clock class="h-3.5 w-3.5" />
|
||||||
|
<span class="font-medium">{{ formattedTimeRange || formattedDate }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Title -->
|
||||||
|
<h3 class="font-semibold text-sm md:text-base flex-1 truncate"
|
||||||
|
:class="{ 'line-through': isCompletable && isCompleted }">
|
||||||
|
{{ event.title }}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<!-- Badges and Actions -->
|
||||||
|
<div class="flex items-center gap-2 shrink-0">
|
||||||
|
<!-- Mark Complete Button (for uncompleted tasks) -->
|
||||||
|
<Button
|
||||||
|
v-if="isCompletable && !isCompleted"
|
||||||
|
@click.stop="handleMarkComplete"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="h-7 w-7 p-0"
|
||||||
|
>
|
||||||
|
<CheckCircle class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<!-- Completed Badge with completer name -->
|
||||||
|
<Badge v-if="isCompletable && isCompleted && getCompletion(eventAddress, occurrence)" variant="secondary" class="text-xs">
|
||||||
|
✓ {{ getDisplayName(getCompletion(eventAddress, occurrence)!.pubkey) }}
|
||||||
|
</Badge>
|
||||||
|
|
||||||
|
<!-- Recurring Badge -->
|
||||||
|
<Badge v-if="isRecurring" variant="outline" class="text-xs">
|
||||||
|
🔄
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
|
||||||
|
<!-- Expanded View (Content) -->
|
||||||
|
<CollapsibleContent class="p-4 md:p-6 pt-0">
|
||||||
|
<!-- Event Details -->
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<!-- Date/Time -->
|
||||||
|
<div class="flex items-center gap-4 text-sm text-muted-foreground mb-2 flex-wrap">
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<Calendar class="h-4 w-4" />
|
||||||
|
<span>{{ formattedDate }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="formattedTimeRange" class="flex items-center gap-1.5">
|
||||||
|
<Clock class="h-4 w-4" />
|
||||||
|
<span>{{ formattedTimeRange }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Location -->
|
||||||
|
<div v-if="event.location" class="flex items-center gap-1.5 text-sm text-muted-foreground mb-3">
|
||||||
|
<MapPin class="h-4 w-4" />
|
||||||
|
<span>{{ event.location }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Description/Content -->
|
||||||
|
<div v-if="event.description || event.content" class="text-sm mb-3">
|
||||||
|
<p class="whitespace-pre-wrap break-words">{{ event.description || event.content }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Completion info (only for completable events) -->
|
||||||
|
<div v-if="isCompletable && isCompleted && getCompletion(eventAddress, occurrence)" class="text-xs text-muted-foreground mb-3">
|
||||||
|
✓ Completed by {{ getDisplayName(getCompletion(eventAddress, occurrence)!.pubkey) }}
|
||||||
|
<span v-if="getCompletion(eventAddress, occurrence)!.notes"> - {{ getCompletion(eventAddress, occurrence)!.notes }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Author (if not admin) -->
|
||||||
|
<div v-if="!isAdminEvent" class="text-xs text-muted-foreground mb-3">
|
||||||
|
Posted by {{ getDisplayName(event.pubkey) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mark Complete Button (only for completable task events) -->
|
||||||
|
<div v-if="isCompletable && !isCompleted" class="mt-3">
|
||||||
|
<Button
|
||||||
|
@click.stop="handleMarkComplete"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
class="gap-2"
|
||||||
|
>
|
||||||
|
<CheckCircle class="h-4 w-4" />
|
||||||
|
Mark Complete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
|
||||||
|
</Collapsible>
|
||||||
|
|
||||||
|
<!-- Confirmation Dialog -->
|
||||||
|
<Dialog :open="showConfirmDialog" @update:open="(val: boolean) => showConfirmDialog = val">
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Mark Event as Complete?</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
This will mark "{{ event.title }}" as completed by you. Other users will be able to see that you completed this event.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" @click="cancelMarkComplete">Cancel</Button>
|
||||||
|
<Button @click="confirmMarkComplete">Mark Complete</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
165
src/modules/nostr-feed/composables/useScheduledEvents.ts
Normal file
165
src/modules/nostr-feed/composables/useScheduledEvents.ts
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||||
|
import type { ScheduledEventService, ScheduledEvent, EventCompletion } 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<ScheduledEventService>(SERVICE_TOKENS.SCHEDULED_EVENT_SERVICE)
|
||||||
|
const authService = injectService<AuthService>(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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle completion status of an event (optionally for a specific occurrence)
|
||||||
|
*/
|
||||||
|
const toggleComplete = async (event: ScheduledEvent, occurrence?: string, notes: string = ''): Promise<void> => {
|
||||||
|
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: Marking as incomplete...')
|
||||||
|
await scheduledEventService.uncompleteEvent(event, occurrence)
|
||||||
|
toast.success('Event marked as incomplete')
|
||||||
|
} else {
|
||||||
|
console.log('⬆️ useScheduledEvents: Marking as complete...')
|
||||||
|
await scheduledEventService.completeEvent(event, notes, occurrence)
|
||||||
|
toast.success('Event 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 events')
|
||||||
|
} 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, notes: string = ''): Promise<void> => {
|
||||||
|
if (!scheduledEventService) {
|
||||||
|
toast.error('Scheduled event service not available')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await scheduledEventService.completeEvent(event, notes)
|
||||||
|
toast.success('Event completed!')
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to complete event'
|
||||||
|
toast.error(message)
|
||||||
|
console.error('Failed to complete event:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get loading state
|
||||||
|
*/
|
||||||
|
const isLoading = computed(() => {
|
||||||
|
return scheduledEventService?.isLoading ?? false
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all scheduled events (reactive)
|
||||||
|
*/
|
||||||
|
const allScheduledEvents = computed(() => {
|
||||||
|
return scheduledEventService?.scheduledEvents ?? new Map()
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all completions (reactive) - returns array for better reactivity
|
||||||
|
*/
|
||||||
|
const allCompletions = computed(() => {
|
||||||
|
if (!scheduledEventService?.completions) return []
|
||||||
|
return Array.from(scheduledEventService.completions.values())
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Methods
|
||||||
|
getScheduledEvents,
|
||||||
|
getEventsForDate,
|
||||||
|
getEventsForSpecificDate,
|
||||||
|
getTodaysEvents,
|
||||||
|
getCompletion,
|
||||||
|
isCompleted,
|
||||||
|
toggleComplete,
|
||||||
|
completeEvent,
|
||||||
|
|
||||||
|
// State
|
||||||
|
isLoading,
|
||||||
|
allScheduledEvents,
|
||||||
|
allCompletions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -88,6 +88,14 @@ export const CONTENT_FILTERS: Record<string, ContentFilter> = {
|
||||||
description: 'Rideshare requests, offers, and coordination',
|
description: 'Rideshare requests, offers, and coordination',
|
||||||
tags: ['rideshare', 'carpool'], // NIP-12 tags
|
tags: ['rideshare', 'carpool'], // NIP-12 tags
|
||||||
keywords: ['rideshare', 'ride share', 'carpool', '🚗', '🚶']
|
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<string, ContentFilter[]> = {
|
||||||
// Rideshare only
|
// Rideshare only
|
||||||
rideshare: [
|
rideshare: [
|
||||||
CONTENT_FILTERS.rideshare
|
CONTENT_FILTERS.rideshare
|
||||||
|
],
|
||||||
|
|
||||||
|
// Scheduled events only
|
||||||
|
scheduledEvents: [
|
||||||
|
CONTENT_FILTERS.scheduledEvents
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { useFeed } from './composables/useFeed'
|
||||||
import { FeedService } from './services/FeedService'
|
import { FeedService } from './services/FeedService'
|
||||||
import { ProfileService } from './services/ProfileService'
|
import { ProfileService } from './services/ProfileService'
|
||||||
import { ReactionService } from './services/ReactionService'
|
import { ReactionService } from './services/ReactionService'
|
||||||
|
import { ScheduledEventService } from './services/ScheduledEventService'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Nostr Feed Module Plugin
|
* Nostr Feed Module Plugin
|
||||||
|
|
@ -23,10 +24,12 @@ export const nostrFeedModule: ModulePlugin = {
|
||||||
const feedService = new FeedService()
|
const feedService = new FeedService()
|
||||||
const profileService = new ProfileService()
|
const profileService = new ProfileService()
|
||||||
const reactionService = new ReactionService()
|
const reactionService = new ReactionService()
|
||||||
|
const scheduledEventService = new ScheduledEventService()
|
||||||
|
|
||||||
container.provide(SERVICE_TOKENS.FEED_SERVICE, feedService)
|
container.provide(SERVICE_TOKENS.FEED_SERVICE, feedService)
|
||||||
container.provide(SERVICE_TOKENS.PROFILE_SERVICE, profileService)
|
container.provide(SERVICE_TOKENS.PROFILE_SERVICE, profileService)
|
||||||
container.provide(SERVICE_TOKENS.REACTION_SERVICE, reactionService)
|
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')
|
console.log('nostr-feed module: Services registered in DI container')
|
||||||
|
|
||||||
// Initialize services
|
// Initialize services
|
||||||
|
|
@ -43,6 +46,10 @@ export const nostrFeedModule: ModulePlugin = {
|
||||||
reactionService.initialize({
|
reactionService.initialize({
|
||||||
waitForDependencies: true,
|
waitForDependencies: true,
|
||||||
maxRetries: 3
|
maxRetries: 3
|
||||||
|
}),
|
||||||
|
scheduledEventService.initialize({
|
||||||
|
waitForDependencies: true,
|
||||||
|
maxRetries: 3
|
||||||
})
|
})
|
||||||
])
|
])
|
||||||
console.log('nostr-feed module: Services initialized')
|
console.log('nostr-feed module: Services initialized')
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@ export class FeedService extends BaseService {
|
||||||
protected relayHub: any = null
|
protected relayHub: any = null
|
||||||
protected visibilityService: any = null
|
protected visibilityService: any = null
|
||||||
protected reactionService: any = null
|
protected reactionService: any = null
|
||||||
|
protected scheduledEventService: any = null
|
||||||
|
|
||||||
// Event ID tracking for deduplication
|
// Event ID tracking for deduplication
|
||||||
private seenEventIds = new Set<string>()
|
private seenEventIds = new Set<string>()
|
||||||
|
|
@ -72,10 +73,12 @@ export class FeedService extends BaseService {
|
||||||
this.relayHub = injectService(SERVICE_TOKENS.RELAY_HUB)
|
this.relayHub = injectService(SERVICE_TOKENS.RELAY_HUB)
|
||||||
this.visibilityService = injectService(SERVICE_TOKENS.VISIBILITY_SERVICE)
|
this.visibilityService = injectService(SERVICE_TOKENS.VISIBILITY_SERVICE)
|
||||||
this.reactionService = injectService(SERVICE_TOKENS.REACTION_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: RelayHub injected:', !!this.relayHub)
|
||||||
console.log('FeedService: VisibilityService injected:', !!this.visibilityService)
|
console.log('FeedService: VisibilityService injected:', !!this.visibilityService)
|
||||||
console.log('FeedService: ReactionService injected:', !!this.reactionService)
|
console.log('FeedService: ReactionService injected:', !!this.reactionService)
|
||||||
|
console.log('FeedService: ScheduledEventService injected:', !!this.scheduledEventService)
|
||||||
|
|
||||||
if (!this.relayHub) {
|
if (!this.relayHub) {
|
||||||
throw new Error('RelayHub service not available')
|
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)
|
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)
|
console.log(`Creating feed subscription for ${config.feedType} with filters:`, filters)
|
||||||
|
|
||||||
// Subscribe to all events (posts, reactions, deletions) with deduplication
|
// Subscribe to all events (posts, reactions, deletions) with deduplication
|
||||||
|
|
@ -257,6 +266,25 @@ export class FeedService extends BaseService {
|
||||||
return
|
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)
|
// Skip if event already seen (for posts only, kind 1)
|
||||||
if (this.seenEventIds.has(event.id)) {
|
if (this.seenEventIds.has(event.id)) {
|
||||||
return
|
return
|
||||||
|
|
|
||||||
477
src/modules/nostr-feed/services/ScheduledEventService.ts
Normal file
477
src/modules/nostr-feed/services/ScheduledEventService.ts
Normal file
|
|
@ -0,0 +1,477 @@
|
||||||
|
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 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 completed it
|
||||||
|
created_at: number
|
||||||
|
completed: boolean
|
||||||
|
completedAt?: number
|
||||||
|
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<string, ScheduledEvent>())
|
||||||
|
private _completions = reactive(new Map<string, EventCompletion>())
|
||||||
|
private _isLoading = ref(false)
|
||||||
|
|
||||||
|
protected async onInitialize(): Promise<void> {
|
||||||
|
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", "<pubkey>", "<relay-hint>", "<participation-type>"]
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
const completed = event.tags.find(tag => tag[0] === 'completed')?.[1] === 'true'
|
||||||
|
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,
|
||||||
|
completed,
|
||||||
|
pubkey: event.pubkey,
|
||||||
|
eventId: event.id
|
||||||
|
})
|
||||||
|
|
||||||
|
const completion: EventCompletion = {
|
||||||
|
id: event.id,
|
||||||
|
eventAddress: aTag,
|
||||||
|
occurrence,
|
||||||
|
pubkey: event.pubkey,
|
||||||
|
created_at: event.created_at,
|
||||||
|
completed,
|
||||||
|
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, '- completed:', completed)
|
||||||
|
} else {
|
||||||
|
console.log('⏭️ Skipped older completion for:', completionKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to handle completion 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?.completed || false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark an event as complete (optionally for a specific occurrence)
|
||||||
|
*/
|
||||||
|
async completeEvent(event: ScheduledEvent, notes: string = '', occurrence?: string): Promise<void> {
|
||||||
|
if (!this.authService?.isAuthenticated?.value) {
|
||||||
|
throw new Error('Must be authenticated to complete events')
|
||||||
|
}
|
||||||
|
|
||||||
|
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/completion event (NIP-52)
|
||||||
|
const tags: string[][] = [
|
||||||
|
['a', eventAddress],
|
||||||
|
['status', 'accepted'],
|
||||||
|
['completed', 'true'],
|
||||||
|
['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 completion
|
||||||
|
console.log('📤 Publishing completion event (kind 31925) for:', eventAddress)
|
||||||
|
const result = await this.relayHub.publishEvent(signedEvent)
|
||||||
|
console.log('✅ Completion event published to', result.success, '/', result.total, 'relays')
|
||||||
|
|
||||||
|
// Optimistically update local state
|
||||||
|
console.log('🔄 Optimistically updating local state')
|
||||||
|
this.handleCompletionEvent(signedEvent)
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to complete event:', error)
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
this._isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uncomplete an event (publish new RSVP with completed=false)
|
||||||
|
*/
|
||||||
|
async uncompleteEvent(event: ScheduledEvent, occurrence?: string): Promise<void> {
|
||||||
|
if (!this.authService?.isAuthenticated?.value) {
|
||||||
|
throw new Error('Must be authenticated to uncomplete events')
|
||||||
|
}
|
||||||
|
|
||||||
|
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 completed=false
|
||||||
|
const tags: string[][] = [
|
||||||
|
['a', eventAddress],
|
||||||
|
['status', 'tentative'],
|
||||||
|
['completed', 'false']
|
||||||
|
]
|
||||||
|
|
||||||
|
// Add occurrence tag if provided (for recurring events)
|
||||||
|
if (occurrence) {
|
||||||
|
tags.push(['occurrence', occurrence])
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventTemplate: EventTemplate = {
|
||||||
|
kind: 31925,
|
||||||
|
content: '',
|
||||||
|
tags,
|
||||||
|
created_at: Math.floor(Date.now() / 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sign the event
|
||||||
|
const privkeyBytes = this.hexToUint8Array(userPrivkey)
|
||||||
|
const signedEvent = finalizeEvent(eventTemplate, privkeyBytes)
|
||||||
|
|
||||||
|
// Publish the uncomplete
|
||||||
|
await this.relayHub.publishEvent(signedEvent)
|
||||||
|
|
||||||
|
// Optimistically update local state
|
||||||
|
this.handleCompletionEvent(signedEvent)
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to uncomplete event:', 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<string, ScheduledEvent> {
|
||||||
|
return this._scheduledEvents
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all completions
|
||||||
|
*/
|
||||||
|
get completions(): Map<string, EventCompletion> {
|
||||||
|
return this._completions
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if currently loading
|
||||||
|
*/
|
||||||
|
get isLoading(): boolean {
|
||||||
|
return this._isLoading.value
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleanup
|
||||||
|
*/
|
||||||
|
protected async onDestroy(): Promise<void> {
|
||||||
|
this._scheduledEvents.clear()
|
||||||
|
this._completions.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue