Skip to content

Commit 3d3ad33

Browse files
committed
feat: drag-and-drop subscription prioritization
1 parent a907150 commit 3d3ad33

16 files changed

Lines changed: 704 additions & 88 deletions
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
-- Personal subscription priority order (drag-and-drop reordering)
2+
ALTER TABLE public.user_preferences
3+
ADD COLUMN IF NOT EXISTS subscription_priority_order uuid[] NOT NULL DEFAULT '{}';
4+
5+
COMMENT ON COLUMN public.user_preferences.subscription_priority_order IS
6+
'Ordered subscription IDs reflecting the user''s personal priority ranking';

backend/src/schemas/user-preferences.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ export const userPreferencesUpdateSchema = z.object({
3737
.optional(),
3838
privacy_mode_enabled: z.boolean().optional(),
3939
encryption_key: z.string().optional(),
40+
subscription_priority_order: z
41+
.array(z.string().uuid('Each subscription ID must be a valid UUID'))
42+
.max(500, 'Maximum 500 subscriptions in priority order')
43+
.optional(),
4044
});
4145

4246
// ─── Quiet Hours Schema ─────────────────────────────────────────────────────

backend/src/services/user-preference-service.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export class UserPreferenceService {
2929
calendar_export_reminders: true,
3030
preferred_gift_card_provider: 'atomic_wallet',
3131
privacy_mode_enabled: false,
32+
subscription_priority_order: [],
3233
};
3334

3435
/**

backend/src/types/reminder.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,8 @@ export interface UserPreferences {
134134
preferred_gift_card_provider: string;
135135
privacy_mode_enabled: boolean;
136136
encryption_key?: string;
137+
/** Subscription IDs in personal priority order (highest first). */
138+
subscription_priority_order: string[];
137139
/** ISO-8601 UTC string. */
138140
updated_at: string;
139141
}

client/components/__tests__/subscription-card.test.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,14 @@ import { describe, test, it, expect, vi, beforeEach } from 'vitest'
44
import { SubscriptionCard } from '../pages/subscriptions'
55
import { mockSubscription, mockCancellationGuide } from '@/lib/test-utils'
66

7+
vi.mock('@/components/providers/user-settings-provider', () => ({
8+
useUserSettings: () => ({
9+
settings: { currency: 'USD', timezone: 'UTC', locale: 'en-US', privacyModeEnabled: false },
10+
updateSettings: vi.fn(),
11+
isLoading: false,
12+
}),
13+
}))
14+
715
describe('SubscriptionCard', () => {
816
const defaultProps = {
917
subscription: mockSubscription({

client/components/modals/keyboard-help-modal.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,14 @@ const SHORTCUT_GROUPS: { heading: string; shortcuts: ShortcutRow[] }[] = [
1818
{ keys: ["?"], description: "Show keyboard shortcuts" },
1919
],
2020
},
21+
{
22+
heading: "Subscription priority",
23+
shortcuts: [
24+
{ keys: ["Drag"], description: "Reorder cards by personal priority" },
25+
{ keys: ["Space"], description: "Pick up / drop focused card (keyboard drag)" },
26+
{ keys: ["↑", "↓"], description: "Move card while dragging with keyboard" },
27+
],
28+
},
2129
{
2230
heading: "Filters",
2331
shortcuts: [

client/components/pages/subscriptions.tsx

Lines changed: 119 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"use client"
22

33
import { useState, useEffect, useRef } from "react"
4-
import { Edit2, Trash2, Mail, Clock, Copy, Lock, Users, Calendar, Check, Download, FileText, Upload, PauseCircle, PlayCircle, AlertTriangle, ShieldAlert, AlertCircle } from "lucide-react"
4+
import { Edit2, Trash2, Mail, Clock, Copy, Lock, LockOpen, Users, Calendar, Check, Download, FileText, Upload, PauseCircle, PlayCircle, ShieldAlert, AlertCircle } from "lucide-react"
55
import { exportAllCSV, exportActiveCSV, exportDateRangeCSV } from "@/lib/csv-export"
66
import { downloadSubscriptionPDF } from "@/lib/pdf-report"
77
import CSVImportModal from "@/components/modals/csv-import-modal"
@@ -19,6 +19,19 @@ import { useUserSettings } from "@/components/providers/user-settings-provider"
1919
import { formatCurrency } from "@/lib/currency-utils"
2020
import { formatDate, getDaysDifference } from "@/lib/timezone-utils"
2121
import { fetchCalendarToken as getCalendarToken, downloadCalendarExport, getCalendarFeedUrl, updateCalendarPreferences } from "@/lib/api/calendar"
22+
import { SortableSubscriptionList } from "@/components/subscriptions/sortable-subscription-list"
23+
import {
24+
PriorityBadge,
25+
SortableDragHandle,
26+
SortableItemShell,
27+
} from "@/components/subscriptions/subscription-priority-ui"
28+
import { useSubscriptionPriorityOrder } from "@/hooks/use-subscription-priority-order"
29+
import {
30+
getGlobalPriorityRank,
31+
sortByPriorityOrder,
32+
type PriorityRank,
33+
} from "@/lib/subscription-priority-order"
34+
import type { CSSProperties, ReactNode } from "react"
2235

2336
interface SubscriptionsPageProps {
2437
subscriptions?: any[]
@@ -66,7 +79,7 @@ export default function SubscriptionsPage({
6679
const [isSearching, setIsSearching] = useState(false)
6780
const [advancedFilters, setAdvancedFilters] = useState<FilterState>(EMPTY_FILTERS)
6881
const [filterEmail, setFilterEmail] = useState("all")
69-
const [sortBy, setSortBy] = useState("name")
82+
const [sortBy, setSortBy] = useState("priority")
7083
const [showKeyboardHelp, setShowKeyboardHelp] = useState(false)
7184
const [showDuplicatesOnly, setShowDuplicatesOnly] = useState(false)
7285
const [showUnusedOnly, setShowUnusedOnly] = useState(false)
@@ -103,7 +116,7 @@ export default function SubscriptionsPage({
103116
setShowExportMenu(false)
104117
setExportingPDF(true)
105118
try {
106-
await downloadSubscriptionPDF(filtered)
119+
await downloadSubscriptionPDF(displayed)
107120
} finally {
108121
setExportingPDF(false)
109122
}
@@ -173,6 +186,14 @@ export default function SubscriptionsPage({
173186
setTimeout(() => setCopied(false), 2000)
174187
}
175188

189+
const subscriptionIds = (subscriptions || []).map((sub: any) => sub.id)
190+
const {
191+
priorityOrder,
192+
isLoading: priorityOrderLoading,
193+
isSaving: priorityOrderSaving,
194+
reorder: reorderPriority,
195+
} = useSubscriptionPriorityOrder({ subscriptionIds })
196+
176197
const filtered = (subscriptions || []).filter((sub: any) => {
177198
const matchesSearch = sub.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase())
178199
const matchesCategory =
@@ -197,20 +218,23 @@ export default function SubscriptionsPage({
197218
return matchesSearch && matchesCategory && matchesStatus && matchesEmail && matchesPrice
198219
})
199220

200-
if (sortBy === "price-high") {
201-
filtered.sort((a, b) => b.price - a.price)
221+
let displayed = filtered
222+
if (sortBy === "priority") {
223+
displayed = sortByPriorityOrder(filtered, priorityOrder)
224+
} else if (sortBy === "price-high") {
225+
displayed = [...filtered].sort((a, b) => b.price - a.price)
202226
} else if (sortBy === "price-low") {
203-
filtered.sort((a, b) => a.price - b.price)
227+
displayed = [...filtered].sort((a, b) => a.price - b.price)
204228
} else if (sortBy === "renewal") {
205-
filtered.sort((a, b) => a.renewsIn - b.renewsIn)
229+
displayed = [...filtered].sort((a, b) => a.renewsIn - b.renewsIn)
206230
} else {
207-
filtered.sort((a, b) => a.name.localeCompare(b.name))
231+
displayed = [...filtered].sort((a, b) => a.name.localeCompare(b.name))
208232
}
209233

210-
const totalCost = filtered.reduce((sum: number, sub: any) => sum + sub.price, 0)
234+
const totalCost = displayed.reduce((sum: number, sub: any) => sum + sub.price, 0)
211235

212236
const hasNoSubscriptions = !subscriptions || subscriptions.length === 0
213-
const hasNoResults = filtered.length === 0 && subscriptions && subscriptions.length > 0
237+
const hasNoResults = displayed.length === 0 && subscriptions && subscriptions.length > 0
214238

215239
// Active trials sorted by urgency (soonest expiry first)
216240
const activeTrials = (subscriptions || [])
@@ -232,7 +256,40 @@ export default function SubscriptionsPage({
232256
)
233257
}
234258

235-
const shouldVirtualize = filtered.length > 100
259+
const shouldVirtualize = displayed.length > 100 && sortBy !== "priority"
260+
const isPrioritySort = sortBy === "priority"
261+
const filteredIds = displayed.map((sub: any) => sub.id)
262+
263+
const renderSubscriptionCard = (sub: any, options?: { priorityRank?: PriorityRank; dragHandle?: ReactNode; shellStyle?: CSSProperties; shellRef?: (node: HTMLElement | null) => void; isDragging?: boolean }) => (
264+
<ErrorBoundary
265+
fallback={<BrokenCardPlaceholder name={sub?.name} darkMode={darkMode} />}
266+
>
267+
<div
268+
ref={options?.shellRef}
269+
style={options?.shellStyle}
270+
className={`transition-shadow duration-200 ${options?.isDragging ? "shadow-xl ring-2 ring-[#FFD166]/40 rounded-xl" : ""}`}
271+
>
272+
<SubscriptionCard
273+
subscription={sub}
274+
onDelete={onDelete}
275+
onManage={onManage}
276+
selectedSubscriptions={selectedSubscriptions}
277+
onToggleSelect={onToggleSelect}
278+
darkMode={darkMode}
279+
isDuplicate={duplicates.some((dup: any) => dup.subscriptions.some((s: any) => s.id === sub.id))}
280+
unusedInfo={unusedSubscriptions.find((unused: any) => unused.id === sub.id)}
281+
onCancel={(s) => setSelectedSubForCancel(s)}
282+
guide={guides.find((g) => g.service_name.toLowerCase() === sub.name.toLowerCase())}
283+
onPause={onPause}
284+
onResume={onResume}
285+
onCancelTrial={onCancelTrial}
286+
onConvertTrial={onConvertTrial}
287+
priorityRank={options?.priorityRank}
288+
dragHandle={options?.dragHandle}
289+
/>
290+
</div>
291+
</ErrorBoundary>
292+
)
236293

237294
return (
238295
<div>
@@ -344,7 +401,7 @@ export default function SubscriptionsPage({
344401
CSV
345402
</p>
346403
<button
347-
onClick={() => { exportAllCSV(filtered); setShowExportMenu(false) }}
404+
onClick={() => { exportAllCSV(displayed); setShowExportMenu(false) }}
348405
className={`w-full flex items-center gap-2 px-3 py-2 text-sm text-left transition-colors ${
349406
darkMode ? "text-gray-300 hover:bg-[#374151]" : "text-gray-700 hover:bg-gray-50"
350407
}`}
@@ -473,6 +530,7 @@ export default function SubscriptionsPage({
473530
: "bg-white border-gray-300 text-gray-900 focus:ring-black"
474531
}`}
475532
>
533+
<option value="priority">Personal Priority</option>
476534
<option value="name">Sort by Name</option>
477535
<option value="price-high">Price: High to Low</option>
478536
<option value="price-low">Price: Low to High</option>
@@ -490,11 +548,20 @@ export default function SubscriptionsPage({
490548
/>
491549
</div>
492550

551+
{isPrioritySort && !hasNoResults && (
552+
<p className={`mb-3 text-sm ${darkMode ? "text-gray-400" : "text-gray-600"}`}>
553+
Drag cards to set your personal priority. Top 3 subscriptions are highlighted.
554+
{priorityOrderSaving ? " Saving order…" : null}
555+
</p>
556+
)}
557+
493558
{/* Live region for search result count */}
494559
<div role="status" aria-live="polite" aria-atomic="true" className="sr-only">
495560
{!isSearching && debouncedSearchTerm
496-
? `Showing ${filtered.length} of ${subscriptions.length} subscriptions`
497-
: ""}
561+
? `Showing ${displayed.length} of ${subscriptions.length} subscriptions`
562+
: isPrioritySort && priorityOrderSaving
563+
? "Saving subscription priority order"
564+
: ""}
498565
</div>
499566

500567
{/* Active Trials Section */}
@@ -555,58 +622,42 @@ export default function SubscriptionsPage({
555622
<>
556623
{shouldVirtualize ? (
557624
<VirtualizedList
558-
items={filtered}
625+
items={displayed}
559626
itemHeight={80}
560627
containerHeight={600}
561-
renderItem={(sub: any, index: number) => (
562-
<ErrorBoundary
563-
fallback={<BrokenCardPlaceholder name={sub?.name} darkMode={darkMode} />}
564-
>
565-
<SubscriptionCard
566-
key={sub.id}
567-
subscription={sub}
568-
onDelete={onDelete}
569-
onManage={onManage}
570-
selectedSubscriptions={selectedSubscriptions}
571-
onToggleSelect={onToggleSelect}
572-
darkMode={darkMode}
573-
isDuplicate={duplicates.some((dup: any) => dup.subscriptions.some((s: any) => s.id === sub.id))}
574-
unusedInfo={unusedSubscriptions.find((unused: any) => unused.id === sub.id)}
575-
onCancel={(s) => setSelectedSubForCancel(s)}
576-
guide={guides.find((g) => g.service_name.toLowerCase() === sub.name.toLowerCase())}
577-
onPause={onPause}
578-
onResume={onResume}
579-
onCancelTrial={onCancelTrial}
580-
onConvertTrial={onConvertTrial}
581-
/>
582-
</ErrorBoundary>
628+
renderItem={(sub: any) => renderSubscriptionCard(sub)}
629+
/>
630+
) : isPrioritySort && !priorityOrderLoading ? (
631+
<SortableSubscriptionList
632+
items={displayed}
633+
darkMode={darkMode}
634+
onReorder={(fromIndex, toIndex) =>
635+
reorderPriority(fromIndex, toIndex, filteredIds)
636+
}
637+
renderItem={(sub: any) => (
638+
<SortableItemShell id={sub.id}>
639+
{({ setNodeRef, style, isDragging, attributes, listeners }) =>
640+
renderSubscriptionCard(sub, {
641+
priorityRank: getGlobalPriorityRank(sub.id, priorityOrder),
642+
dragHandle: (
643+
<SortableDragHandle
644+
attributes={attributes}
645+
listeners={listeners}
646+
darkMode={darkMode}
647+
label={`Drag to reorder ${sub.name}`}
648+
/>
649+
),
650+
shellRef: setNodeRef,
651+
shellStyle: style,
652+
isDragging,
653+
})
654+
}
655+
</SortableItemShell>
583656
)}
584657
/>
585658
) : (
586659
<div className="space-y-3">
587-
{filtered.map((sub: any) => (
588-
<ErrorBoundary
589-
key={sub.id}
590-
fallback={<BrokenCardPlaceholder name={sub?.name} darkMode={darkMode} />}
591-
>
592-
<SubscriptionCard
593-
subscription={sub}
594-
onDelete={onDelete}
595-
onManage={onManage}
596-
selectedSubscriptions={selectedSubscriptions}
597-
onToggleSelect={onToggleSelect}
598-
darkMode={darkMode}
599-
isDuplicate={duplicates.some((dup: any) => dup.subscriptions.some((s: any) => s.id === sub.id))}
600-
unusedInfo={unusedSubscriptions.find((unused: any) => unused.id === sub.id)}
601-
onCancel={(s) => setSelectedSubForCancel(s)}
602-
guide={guides.find((g) => g.service_name.toLowerCase() === sub.name.toLowerCase())}
603-
onPause={onPause}
604-
onResume={onResume}
605-
onCancelTrial={onCancelTrial}
606-
onConvertTrial={onConvertTrial}
607-
/>
608-
</ErrorBoundary>
609-
))}
660+
{displayed.map((sub: any) => renderSubscriptionCard(sub))}
610661
</div>
611662
)}
612663
</>
@@ -739,6 +790,8 @@ interface SubscriptionCardProps {
739790
onResume?: (subscription: any) => void
740791
onCancelTrial?: (id: string) => void
741792
onConvertTrial?: (id: string) => void
793+
priorityRank?: PriorityRank
794+
dragHandle?: ReactNode
742795
}
743796

744797
export function SubscriptionCard({
@@ -756,7 +809,11 @@ export function SubscriptionCard({
756809
onResume,
757810
onCancelTrial,
758811
onConvertTrial,
812+
priorityRank,
813+
dragHandle,
759814
}: SubscriptionCardProps) {
815+
const { settings } = useUserSettings()
816+
const currency = settings.currency
760817
const isPaused = sub.status === "paused"
761818

762819
const statusLabel =
@@ -789,6 +846,7 @@ export function SubscriptionCard({
789846
aria-label={`${sub.name}, ${sub.category}, $${sub.price}/month, ${statusLabel}${isDuplicate ? ", duplicate" : ""}${unusedInfo ? ", unused" : ""}`}
790847
>
791848
<div className="flex items-center gap-4 flex-1">
849+
{dragHandle}
792850
{selectedSubscriptions && onToggleSelect && (
793851
<input
794852
type="checkbox"
@@ -808,6 +866,7 @@ export function SubscriptionCard({
808866
<div className="flex-1">
809867
<div className="flex items-center gap-2">
810868
<h4 className={`font-semibold ${darkMode ? "text-white" : "text-gray-900"}`}>{sub.name}</h4>
869+
{priorityRank && <PriorityBadge rank={priorityRank} darkMode={darkMode} />}
811870
<div
812871
className="flex items-center gap-1 text-xs text-gray-500"
813872
title={

0 commit comments

Comments
 (0)