Add streaks, scheduled releases, and notification settings

- Couple streaks with milestone notifications
- Scheduled pack releases with admin UI
- Notification preferences screen
- Refactored profile settings screens
- Partner activity notifications
This commit is contained in:
jjbrunton committed 2026-01-17 16:38:47 +00:00
1 parent 12f312421b
commit 494ed17fe2
82 files changed
+4189 -725

No files matched your search

@@ -45,6 +45,8 @@ export interface PackFormData {
is_public: boolean;
is_explicit: boolean;
category_id: string;
scheduled_release_at?: string | null; // Optional for backwards compatibility
skip_notification?: boolean; // If true, sets release_notified=true to skip notifications
}
export interface PackFormDialogProps {
@@ -200,10 +202,63 @@ export function PackFormDialog({
</div>
<Switch
checked={formData.is_public}
onCheckedChange={(checked) => setField('is_public', checked)}
onCheckedChange={(checked) => {
setField('is_public', checked);
// Clear scheduled release if making public immediately
if (checked) {
setField('scheduled_release_at', null);
}
}}
/>
</div>
{/* Scheduled Release - only show when not public */}
{!formData.is_public && (
<div className="space-y-4 p-3 rounded-md bg-muted/50 border border-dashed">
<div className="space-y-2">
<Label htmlFor="scheduled_release_at">Scheduled Release</Label>
<p className="text-sm text-muted-foreground">
Automatically make this pack public at a specific time. Users will be notified when it becomes available.
</p>
<Input
id="scheduled_release_at"
type="datetime-local"
value={formData.scheduled_release_at ? new Date(formData.scheduled_release_at).toISOString().slice(0, 16) : ''}
onChange={(e) => {
const value = e.target.value;
setField('scheduled_release_at', value ? new Date(value).toISOString() : null);
}}
className="w-full"
/>
{formData.scheduled_release_at && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setField('scheduled_release_at', null)}
className="mt-1"
>
Clear scheduled release
</Button>
)}
</div>
{/* Skip Notification Toggle */}
<div className="flex items-center justify-between pt-2 border-t">
<div className="space-y-0.5">
<Label>Skip release notification</Label>
<p className="text-sm text-muted-foreground">
Release without notifying users
</p>
</div>
<Switch
checked={formData.skip_notification ?? false}
onCheckedChange={(checked) => setField('skip_notification', checked)}
/>
</div>
</div>
)}
{/* Explicit Toggle */}
<div className="flex items-center justify-between">
<div className="space-y-0.5">
+40 -1
View File
@@ -8,7 +8,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { PaginationControls } from '@/components/ui/pagination';
import { Plus, MessageCircle, Pencil, Trash2, Sparkles, Crown, Eye, EyeOff, Flame, Heart, Tags, ChevronUp, ChevronDown } from 'lucide-react';
import { Plus, MessageCircle, Pencil, Trash2, Sparkles, Crown, Eye, EyeOff, Flame, Heart, Tags, ChevronUp, ChevronDown, Clock, BellOff } from 'lucide-react';
import { toast } from 'sonner';
import { AiGeneratorDialog } from '@/components/ai/AiGeneratorDialog';
import { ExtractTopicsDialog } from '@/components/content/ExtractTopicsDialog';
@@ -41,6 +41,8 @@ interface QuestionPack {
sort_order: number | null;
category_id: string | null;
created_at: string | null;
scheduled_release_at?: string | null; // Optional for backwards compatibility
release_notified?: boolean; // True if notifications have been sent or should be skipped
question_count?: number;
topics?: Topic[];
}
@@ -76,6 +78,8 @@ export function PacksPage() {
is_public: false,
is_explicit: false,
category_id: categoryId ?? '',
scheduled_release_at: null,
skip_notification: false,
},
(pack) => ({
name: pack.name,
@@ -85,6 +89,8 @@ export function PacksPage() {
is_public: pack.is_public,
is_explicit: pack.is_explicit,
category_id: pack.category_id || '',
scheduled_release_at: pack.scheduled_release_at,
skip_notification: false, // Always start unchecked when editing
})
);
@@ -223,6 +229,13 @@ export function PacksPage() {
let packId: string;
if (form.editingItem) {
// Determine release_notified value
// If skip_notification is checked, set release_notified=true to prevent notifications
// Otherwise, reset to false when scheduling a new release
const releaseNotifiedValue = form.formData.skip_notification
? true
: (form.formData.scheduled_release_at ? false : undefined);
const { error } = await auditedSupabase.update('question_packs', form.editingItem.id, {
name: form.formData.name,
description: form.formData.description || null,
@@ -231,6 +244,12 @@ export function PacksPage() {
is_public: form.formData.is_public,
is_explicit: form.formData.is_explicit,
category_id: form.formData.category_id || null,
// Only include scheduled_release_at if the field exists in form data (backwards compatible)
...('scheduled_release_at' in form.formData && {
scheduled_release_at: form.formData.scheduled_release_at,
// Set release_notified based on skip_notification toggle
...(releaseNotifiedValue !== undefined && { release_notified: releaseNotifiedValue }),
}),
});
if (error) throw error;
packId = form.editingItem.id;
@@ -246,6 +265,12 @@ export function PacksPage() {
is_explicit: form.formData.is_explicit,
category_id: form.formData.category_id || null,
sort_order: totalCount,
// Only include scheduled_release_at if the field exists in form data (backwards compatible)
...('scheduled_release_at' in form.formData && {
scheduled_release_at: form.formData.scheduled_release_at,
// If skip_notification is checked, set release_notified=true
...(form.formData.skip_notification && { release_notified: true }),
}),
})
.select('id')
.single();
@@ -305,6 +330,8 @@ export function PacksPage() {
is_public: false,
is_explicit: false,
category_id: categoryId ?? '',
scheduled_release_at: null,
skip_notification: false,
});
setAiDialogOpen(false);
setSelectedTopicIds(new Set());
@@ -497,6 +524,18 @@ export function PacksPage() {
<Eye className="h-3 w-3 mr-1" />
Published
</Badge>
) : pack.scheduled_release_at ? (
<>
<Badge variant="outline" className="border-blue-500 text-blue-400" title={`Releases: ${new Date(pack.scheduled_release_at).toLocaleString()}`}>
<Clock className="h-3 w-3 mr-1" />
Scheduled
</Badge>
{pack.release_notified && (
<Badge variant="outline" className="border-gray-500 text-gray-400" title="Notifications skipped">
<BellOff className="h-3 w-3" />
</Badge>
)}
</>
) : (
<Badge variant="secondary">
<EyeOff className="h-3 w-3 mr-1" />
+74 -1
View File
@@ -38,7 +38,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Crown, Users, MessageCircle, ChevronRight, ThumbsUp, ThumbsDown, Minus, Gift, Image, Video as VideoIcon, Target, User, Eye, EyeOff, CheckCircle, Package, Heart, Sparkles } from 'lucide-react';
import { Crown, Users, MessageCircle, ChevronRight, ThumbsUp, ThumbsDown, Minus, Gift, Image, Video as VideoIcon, Target, User, Eye, EyeOff, CheckCircle, Package, Heart, Sparkles, Flame, Trophy, Calendar } from 'lucide-react';
import { format } from 'date-fns';
import { IconPreview } from '@/components/ui/icon-picker';
@@ -142,6 +142,17 @@ interface FeatureInterest {
created_at: string;
}
interface CoupleStreak {
id: string;
couple_id: string;
current_streak: number;
longest_streak: number;
last_active_date: string | null;
last_milestone_celebrated: number | null;
created_at: string;
updated_at: string;
}
const answerIcons = {
yes: <ThumbsUp className="h-4 w-4 text-green-500" />,
no: <ThumbsDown className="h-4 w-4 text-red-500" />,
@@ -277,6 +288,7 @@ export function UserDetailPage() {
const [mediaMessages, setMediaMessages] = useState<any[]>([]);
const [enabledPacks, setEnabledPacks] = useState<EnabledPack[]>([]);
const [featureInterests, setFeatureInterests] = useState<FeatureInterest[]>([]);
const [streak, setStreak] = useState<CoupleStreak | null>(null);
const [responsesPage, setResponsesPage] = useState(1);
const [responsesPageSize, setResponsesPageSize] = useState(10);
const [responsesTotal, setResponsesTotal] = useState(0);
@@ -357,8 +369,17 @@ export function UserDetailPage() {
if (profileData?.couple_id) {
const { data: partnerData } = await supabase.from('profiles').select('id, name').eq('couple_id', profileData.couple_id).neq('id', userId).single();
setPartner(partnerData);
// Fetch streak data
const { data: streakData } = await supabase
.from('couple_streaks')
.select('*')
.eq('couple_id', profileData.couple_id)
.maybeSingle();
setStreak(streakData);
} else {
setPartner(null);
setStreak(null);
}
const responsesFrom = (responsesPage - 1) * responsesPageSize;
@@ -534,6 +555,58 @@ export function UserDetailPage() {
<div className="flex items-start gap-3"><div className={`p-2 rounded-lg ${profile.show_explicit_content ? 'bg-amber-100 dark:bg-amber-900' : 'bg-gray-100 dark:bg-gray-800'}`}>{profile.show_explicit_content ? <Eye className="h-4 w-4 text-amber-600 dark:text-amber-400" /> : <EyeOff className="h-4 w-4 text-gray-500 dark:text-gray-400" />}</div><div><p className="text-sm font-medium text-muted-foreground">Explicit Content</p><p className="font-medium">{profile.show_explicit_content === null ? 'Not set' : profile.show_explicit_content ? 'Enabled' : 'Disabled'}</p></div></div>
<div className="flex items-start gap-3"><div className={`p-2 rounded-lg ${profile.onboarding_completed ? 'bg-green-100 dark:bg-green-900' : 'bg-red-100 dark:bg-red-900'}`}><CheckCircle className={`h-4 w-4 ${profile.onboarding_completed ? 'text-green-600 dark:text-green-400' : 'text-red-500 dark:text-red-400'}`} /></div><div><p className="text-sm font-medium text-muted-foreground">Onboarding</p><p className="font-medium">{profile.onboarding_completed ? 'Completed' : 'Not completed'}</p></div></div>
</div></CardContent></Card>
{/* Streak Info */}
{profile.couple_id && streak && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-lg flex items-center gap-2">
<Flame className="h-5 w-5 text-orange-500" />
Couple Streak
</CardTitle>
<CardDescription>Activity streak for this couple</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<div className="flex items-start gap-3">
<div className="p-2 rounded-lg bg-orange-100 dark:bg-orange-900">
<Flame className="h-4 w-4 text-orange-600 dark:text-orange-400" />
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">Current Streak</p>
<p className="font-medium text-xl">{streak.current_streak} day{streak.current_streak !== 1 ? 's' : ''}</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="p-2 rounded-lg bg-yellow-100 dark:bg-yellow-900">
<Trophy className="h-4 w-4 text-yellow-600 dark:text-yellow-400" />
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">Longest Streak</p>
<p className="font-medium text-xl">{streak.longest_streak} day{streak.longest_streak !== 1 ? 's' : ''}</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="p-2 rounded-lg bg-blue-100 dark:bg-blue-900">
<Calendar className="h-4 w-4 text-blue-600 dark:text-blue-400" />
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">Last Active</p>
<p className="font-medium">{streak.last_active_date ? format(new Date(streak.last_active_date), 'MMM d, yyyy') : 'Never'}</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="p-2 rounded-lg bg-purple-100 dark:bg-purple-900">
<Sparkles className="h-4 w-4 text-purple-600 dark:text-purple-400" />
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">Last Milestone</p>
<p className="font-medium">{streak.last_milestone_celebrated ? `${streak.last_milestone_celebrated} days` : 'None yet'}</p>
</div>
</div>
</div>
</CardContent>
</Card>
)}
{/* Feature Opt-Ins */}
{featureInterests.length > 0 && (
<Card>