[PULSE-58] Data retention settings in Site Settings #33
11
CHANGELOG.md
11
CHANGELOG.md
@@ -6,6 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.9.0-alpha] - 2026-02-21
|
||||
|
||||
### Added
|
||||
|
||||
- **Data retention settings (PULSE-58).** Site owners can choose how long raw event data is kept (1 month to 3 years depending on plan). Events older than the retention period are automatically deleted every 24 hours. Aggregated daily stats are preserved so historical charts remain intact.
|
||||
- **Data Retention section in Site Settings.** Under Data & Privacy, a dropdown lets you set retention; options are capped by your plan (free: up to 6 months, solo: 1 year, team: 2 years, business: 3 years).
|
||||
- **Privacy snippet includes retention.** The generated privacy policy text now mentions when raw data is automatically deleted.
|
||||
|
||||
## [0.8.0-alpha] - 2026-02-20
|
||||
|
||||
### Added
|
||||
@@ -104,7 +112,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
|
||||
---
|
||||
|
||||
[Unreleased]: https://github.com/ciphera-net/pulse/compare/v0.8.0-alpha...HEAD
|
||||
[Unreleased]: https://github.com/ciphera-net/pulse/compare/v0.9.0-alpha...HEAD
|
||||
[0.9.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.8.0-alpha...v0.9.0-alpha
|
||||
[0.8.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.7.0-alpha...v0.8.0-alpha
|
||||
[0.7.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.6.0-alpha...v0.7.0-alpha
|
||||
[0.6.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.5.1-alpha...v0.6.0-alpha
|
||||
|
||||
@@ -13,6 +13,8 @@ import { PasswordInput } from '@ciphera-net/ui'
|
||||
import { Select, Modal, Button } from '@ciphera-net/ui'
|
||||
import { APP_URL } from '@/lib/api/client'
|
||||
import { generatePrivacySnippet } from '@/lib/utils/privacySnippet'
|
||||
import { getSubscription, type SubscriptionDetails } from '@/lib/api/billing'
|
||||
import { getRetentionOptionsForPlan, formatRetentionMonths } from '@/lib/plans'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { useAuth } from '@/lib/auth/context'
|
||||
import {
|
||||
@@ -68,8 +70,12 @@ export default function SiteSettingsPage() {
|
||||
// Performance insights setting
|
||||
enable_performance_insights: false,
|
||||
// Bot and noise filtering
|
||||
filter_bots: true
|
||||
filter_bots: true,
|
||||
// Data retention (6 = free-tier max; safe default)
|
||||
data_retention_months: 6
|
||||
})
|
||||
const [subscription, setSubscription] = useState<SubscriptionDetails | null>(null)
|
||||
const [subscriptionLoadFailed, setSubscriptionLoadFailed] = useState(false)
|
||||
const [linkCopied, setLinkCopied] = useState(false)
|
||||
const [snippetCopied, setSnippetCopied] = useState(false)
|
||||
const [showVerificationModal, setShowVerificationModal] = useState(false)
|
||||
@@ -83,6 +89,7 @@ export default function SiteSettingsPage() {
|
||||
|
||||
useEffect(() => {
|
||||
loadSite()
|
||||
loadSubscription()
|
||||
}, [siteId])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -91,6 +98,30 @@ export default function SiteSettingsPage() {
|
||||
}
|
||||
}, [activeTab, siteId])
|
||||
|
||||
const loadSubscription = async () => {
|
||||
try {
|
||||
setSubscriptionLoadFailed(false)
|
||||
const sub = await getSubscription()
|
||||
setSubscription(sub)
|
||||
} catch (e) {
|
||||
setSubscriptionLoadFailed(true)
|
||||
toast.error(getAuthErrorMessage(e as Error) || 'Could not load plan limits. Showing default options.')
|
||||
}
|
||||
}
|
||||
|
||||
// * Snap data_retention_months to nearest valid option when subscription loads
|
||||
useEffect(() => {
|
||||
if (!subscription) return
|
||||
const opts = getRetentionOptionsForPlan(subscription.plan_id)
|
||||
const values = opts.map(o => o.value)
|
||||
const maxVal = Math.max(...values)
|
||||
setFormData(prev => {
|
||||
if (values.includes(prev.data_retention_months)) return prev
|
||||
const bestFit = values.filter(v => v <= prev.data_retention_months).pop() ?? maxVal
|
||||
return { ...prev, data_retention_months: Math.min(bestFit, maxVal) }
|
||||
})
|
||||
}, [subscription])
|
||||
|
||||
const loadSite = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
@@ -111,7 +142,9 @@ export default function SiteSettingsPage() {
|
||||
// Performance insights setting (default to false)
|
||||
enable_performance_insights: data.enable_performance_insights ?? false,
|
||||
// Bot and noise filtering (default to true)
|
||||
filter_bots: data.filter_bots ?? true
|
||||
filter_bots: data.filter_bots ?? true,
|
||||
// Data retention (default 6 = free-tier max; avoids flash-then-clamp for existing sites)
|
||||
data_retention_months: data.data_retention_months ?? 6
|
||||
})
|
||||
if (data.has_password) {
|
||||
setIsPasswordEnabled(true)
|
||||
@@ -226,7 +259,9 @@ export default function SiteSettingsPage() {
|
||||
// Performance insights setting
|
||||
enable_performance_insights: formData.enable_performance_insights,
|
||||
// Bot and noise filtering
|
||||
filter_bots: formData.filter_bots
|
||||
filter_bots: formData.filter_bots,
|
||||
// Data retention
|
||||
data_retention_months: formData.data_retention_months
|
||||
})
|
||||
toast.success('Site updated successfully')
|
||||
loadSite()
|
||||
@@ -821,6 +856,58 @@ export default function SiteSettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Data Retention */}
|
||||
<div className="space-y-4 pt-6 border-t border-neutral-100 dark:border-neutral-800">
|
||||
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">Data Retention</h3>
|
||||
{subscriptionLoadFailed && (
|
||||
<div className="p-3 rounded-xl border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20 flex items-center justify-between gap-3">
|
||||
<p className="text-sm text-amber-800 dark:text-amber-200">
|
||||
Plan limits could not be loaded. Options shown may be limited.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadSubscription}
|
||||
className="shrink-0 text-sm font-medium text-amber-800 dark:text-amber-200 hover:underline"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-4 bg-neutral-50 dark:bg-neutral-900/50 rounded-2xl border border-neutral-100 dark:border-neutral-800">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="font-medium text-neutral-900 dark:text-white">Keep raw event data for</h4>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-0.5">
|
||||
Events older than this are automatically deleted. Aggregated daily stats are kept permanently.
|
||||
</p>
|
||||
</div>
|
||||
<Select
|
||||
value={String(formData.data_retention_months)}
|
||||
onChange={(v) => setFormData({ ...formData, data_retention_months: Number(v) })}
|
||||
options={getRetentionOptionsForPlan(subscription?.plan_id).map(opt => ({
|
||||
value: String(opt.value),
|
||||
label: opt.label,
|
||||
}))}
|
||||
variant="input"
|
||||
align="right"
|
||||
className="min-w-[160px]"
|
||||
/>
|
||||
</div>
|
||||
{subscription?.plan_id && subscription.plan_id !== 'free' && (
|
||||
<p className="text-xs text-neutral-400 dark:text-neutral-500 mt-3">
|
||||
Your {subscription.plan_id} plan supports up to {formatRetentionMonths(
|
||||
getRetentionOptionsForPlan(subscription.plan_id).at(-1)?.value ?? 6
|
||||
)} of data retention.
|
||||
</p>
|
||||
)}
|
||||
{(!subscription?.plan_id || subscription.plan_id === 'free') && (
|
||||
<p className="text-xs text-neutral-400 dark:text-neutral-500 mt-3">
|
||||
Free plan supports up to 6 months. <a href="/pricing" className="text-brand-orange hover:underline">Upgrade</a> for longer retention.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Excluded Paths */}
|
||||
<div className="space-y-4 pt-6 border-t border-neutral-100 dark:border-neutral-800">
|
||||
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">Path Filtering</h3>
|
||||
|
||||
@@ -21,6 +21,8 @@ export interface Site {
|
||||
enable_performance_insights?: boolean
|
||||
// Bot and noise filtering
|
||||
filter_bots?: boolean
|
||||
// Data retention (months); 0 = keep forever
|
||||
data_retention_months?: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
@@ -47,6 +49,8 @@ export interface UpdateSiteRequest {
|
||||
enable_performance_insights?: boolean
|
||||
// Bot and noise filtering
|
||||
filter_bots?: boolean
|
||||
// Data retention (months); 0 = keep forever
|
||||
data_retention_months?: number
|
||||
}
|
||||
|
||||
export async function listSites(): Promise<Site[]> {
|
||||
|
||||
39
lib/plans.ts
39
lib/plans.ts
@@ -40,3 +40,42 @@ export function getLimitForTierIndex(index: number): number {
|
||||
if (index < 0 || index >= TRAFFIC_TIERS.length) return 100000
|
||||
return TRAFFIC_TIERS[index].value
|
||||
}
|
||||
|
||||
/** Maximum data retention (months) allowed per plan. */
|
||||
export function getMaxRetentionMonthsForPlan(planId: string | null | undefined): number {
|
||||
switch (planId) {
|
||||
case 'business': return 36
|
||||
case 'team': return 24
|
||||
case 'solo': return 12
|
||||
default: return 6
|
||||
}
|
||||
}
|
||||
|
||||
/** Selectable retention options (months) for the given plan. */
|
||||
export function getRetentionOptionsForPlan(planId: string | null | undefined): { label: string; value: number }[] {
|
||||
const base = [
|
||||
{ label: '1 month', value: 1 },
|
||||
{ label: '3 months', value: 3 },
|
||||
{ label: '6 months', value: 6 },
|
||||
]
|
||||
const solo = [...base, { label: '1 year', value: 12 }]
|
||||
const team = [...solo, { label: '2 years', value: 24 }]
|
||||
const business = [...team, { label: '3 years', value: 36 }]
|
||||
|
||||
switch (planId) {
|
||||
case 'business': return business
|
||||
case 'team': return team
|
||||
case 'solo': return solo
|
||||
default: return base
|
||||
}
|
||||
}
|
||||
|
||||
/** Human-readable label for a retention value in months. */
|
||||
export function formatRetentionMonths(months: number): string {
|
||||
if (months === 0) return 'Forever'
|
||||
if (months === 1) return '1 month'
|
||||
if (months < 12) return `${months} months`
|
||||
const years = months / 12
|
||||
if (Number.isInteger(years)) return years === 1 ? '1 year' : `${years} years`
|
||||
return `${months} months`
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Site } from '@/lib/api/sites'
|
||||
import { formatRetentionMonths } from '@/lib/plans'
|
||||
|
||||
const DOCS_URL =
|
||||
(typeof process !== 'undefined' && process.env?.NEXT_PUBLIC_APP_URL)
|
||||
@@ -22,6 +23,7 @@ export function generatePrivacySnippet(site: Site): string {
|
||||
const screen = site.collect_screen_resolution ?? true
|
||||
|
|
||||
const perf = site.enable_performance_insights ?? false
|
||||
const filterBots = site.filter_bots ?? true
|
||||
const retentionMonths = site.data_retention_months ?? 6
|
||||
|
||||
const parts: string[] = []
|
||||
if (paths) parts.push('which pages are viewed')
|
||||
@@ -44,6 +46,9 @@ export function generatePrivacySnippet(site: Site): string {
|
||||
if (filterBots) {
|
||||
p2 += 'Known bots and referrer spam are excluded from our analytics. '
|
||||
}
|
||||
if (retentionMonths > 0) {
|
||||
p2 += `Raw event data is automatically deleted after ${formatRetentionMonths(retentionMonths)}. `
|
||||
}
|
||||
p2 += `Data is processed in a privacy-preserving way and is not used to identify individuals. For more information, see Pulse's documentation: ${DOCS_URL}`
|
||||
|
||||
return `${p1}\n\n${p2}`
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pulse-frontend",
|
||||
"version": "0.8.0-alpha",
|
||||
"version": "0.9.0-alpha",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
Reference in New Issue
Block a user
Mismatched default retention value
The fallback here is
?? 12(12 months), but the settings page (page.tsx:146) defaults to?? 6(6 months). For existing sites that haven't saved adata_retention_monthsvalue yet, the privacy snippet will claim "Raw event data is automatically deleted after 1 year" while the settings page displays and saves "6 months."These should use the same default to avoid misleading the user's privacy policy text.
Prompt To Fix With AI
Issue: The privacy snippet defaulted to 12 months while the settings page defaults to 6, so unsaved sites could show "1 year" in the snippet but "6 months" in the UI.
Fix: Use
?? 6to match the settings page default.Why: Keeps defaults aligned so the generated privacy text matches what the user sees in settings.