Cache unsaved profile editions for 24h

This commit is contained in:
MartinBraquet
2026-02-14 01:00:20 +01:00
parent 7fd509b7e4
commit d2929a94ce
5 changed files with 129 additions and 12 deletions

View File

@@ -25,7 +25,7 @@ CREATE TABLE IF NOT EXISTS profiles (
gender TEXT NOT NULL,
geodb_city_id TEXT,
has_kids INTEGER,
height_in_inches INTEGER,
height_in_inches float4,
id BIGINT GENERATED ALWAYS AS IDENTITY NOT NULL,
image_descriptions jsonb,
is_smoker BOOLEAN,

View File

@@ -14,8 +14,9 @@ import {MIN_BIO_LENGTH} from "common/constants";
import {ShowMore} from 'web/components/widgets/show-more'
import {NewTabLink} from 'web/components/widgets/new-tab-link'
import {useT} from 'web/lib/locale'
import {richTextToString} from 'common/util/parse'
export function BioTips() {
export function BioTips({onClick}: { onClick?: () => void }) {
const t = useT();
const tips = t('profile.bio.tips_list', `
- Your core values, interests, and activities
@@ -35,7 +36,8 @@ export function BioTips() {
<p>{t('profile.bio.tips_intro', "Write a clear and engaging bio to help others understand who you are and the connections you seek. Include:")}</p>
<ReactMarkdown>{tips}</ReactMarkdown>
<NewTabLink
href="/tips-bio">{t('profile.bio.tips_link', 'Read full tips for writing a high-quality bio')}</NewTabLink>
href="/tips-bio"
onClick={onClick}>{t('profile.bio.tips_link', 'Read full tips for writing a high-quality bio')}</NewTabLink>
</ShowMore>
)
}
@@ -71,6 +73,7 @@ export function EditableBio(props: {
defaultValue={profile.bio}
onEditor={(e) => {
setEditor(e);
if (e) setTextLength(e.getText().length)
e?.on('update', () => {
setTextLength(e.getText().length);
});
@@ -101,8 +104,23 @@ export function EditableBio(props: {
export function SignupBio(props: {
onChange: (e: Editor) => void
profile?: ProfileWithoutUser | undefined
onClickTips?: () => void
onEditor?: (editor: any) => void
}) {
const {onChange, profile} = props
const {onChange, profile, onClickTips, onEditor} = props
const [editor, setEditor] = useState<any>(null)
// Keep the editor content in sync with profile.bio when it becomes available
useEffect(() => {
if (!editor) return
const profileText = profile?.bio ? richTextToString(profile.bio as any) : ''
const currentText = editor?.getText?.() ?? ''
// Only update if the underlying text differs to avoid clobbering user input unnecessarily
if (profileText !== currentText) {
editor.commands.setContent(profile?.bio ?? '')
}
}, [profile?.bio, editor])
return (
<Col className="relative w-full">
<BaseBio
@@ -111,6 +129,11 @@ export function SignupBio(props: {
if (!editor) return
onChange(editor)
}}
onClickTips={onClickTips}
onEditor={(e) => {
setEditor(e)
onEditor?.(e)
}}
/>
</Col>
)
@@ -120,9 +143,10 @@ interface BaseBioProps {
defaultValue?: any
onBlur?: (editor: any) => void
onEditor?: (editor: any) => void
onClickTips?: () => void
}
export function BaseBio({defaultValue, onBlur, onEditor}: BaseBioProps) {
export function BaseBio({defaultValue, onBlur, onEditor, onClickTips}: BaseBioProps) {
const t = useT();
const editor = useTextEditor({
// extensions: [StarterKit],
@@ -147,7 +171,7 @@ export function BaseBio({defaultValue, onBlur, onEditor}: BaseBioProps) {
}
</p>
}
<BioTips/>
<BioTips onClick={onClickTips}/>
<TextEditor
editor={editor}
onBlur={() => onBlur?.(editor)}

View File

@@ -1,4 +1,4 @@
import {Fragment, useEffect, useRef, useState} from 'react'
import {Fragment, useCallback, useEffect, useRef, useState} from 'react'
import {Title} from 'web/components/widgets/title'
import {Col} from 'web/components/layout/col'
import clsx from 'clsx'
@@ -48,6 +48,7 @@ import {sleep} from "common/util/time"
import {SignupBio} from "web/components/bio/editable-bio";
import {Editor} from "@tiptap/core";
import {Slider} from "web/components/widgets/slider";
import {safeLocalStorage} from "web/lib/util/local";
export const OptionalProfileUserForm = (props: {
@@ -76,6 +77,18 @@ export const OptionalProfileUserForm = (props: {
: undefined
)
// Keep local feet/inches inputs in sync when profile.height_in_inches updates
// This covers cases like hydration from localStorage where setProfile is called externally
const updateHeight = (h: any) => {
if (h == null || Number.isNaN(h as any)) {
setHeightFeet(undefined)
setHeightInches(undefined)
return
}
setHeightFeet(Math.floor(h / 12))
setHeightInches(Math.round(h % 12))
}
const [newLinks, setNewLinks] = useState<Record<string, string | null>>(
user.link
)
@@ -87,6 +100,80 @@ export const OptionalProfileUserForm = (props: {
const [workChoices, setWorkChoices] = useState({})
const {locale} = useLocale()
const KEY = `draft-profile-${user.id}`
const clearProfileDraft = (userId: string) => {
try {
safeLocalStorage?.removeItem(`draft-profile-${userId}`)
safeLocalStorage?.removeItem(`draft-profile-${userId}-timestamp`)
} catch (error) {
console.warn('Failed to clear profile from store:', error)
}
}
// Debounced save function
const debouncedSaveProfile = useCallback(
(() => {
let timeoutId: NodeJS.Timeout
return (profileToSave: ProfileWithoutUser) => {
clearTimeout(timeoutId)
timeoutId = setTimeout(() => {
try {
safeLocalStorage?.setItem(KEY, JSON.stringify({
profile: profileToSave,
timestamp: Date.now().toString(),
}))
} catch (error) {
console.warn('Failed to save profile to store:', error)
}
}, 500) // 500ms debounce delay
}
})(),
[KEY]
)
useEffect(() => {
console.log({profile})
if (profile && Object.keys(profile).length > 0) {
debouncedSaveProfile(profile)
}
}, [profile, user.id, debouncedSaveProfile])
useEffect(() => {
try {
const savedProfileString = safeLocalStorage?.getItem(KEY)
if (savedProfileString) {
const data = JSON.parse(savedProfileString)
if (data) {
const {profile: savedProfile, timestamp} = data
// Check if saved data is older than 24 hours
if (timestamp) {
const savedTime = parseInt(timestamp, 10)
const now = Date.now()
const twentyFourHoursInMs = 24 * 60 * 60 * 1000
if (now - savedTime > twentyFourHoursInMs) {
console.log('Skipping profile update: saved data is older than 24 hours')
return
}
}
// Update all profile fields
Object.entries(savedProfile).forEach(([key, value]) => {
const typedKey = key as keyof ProfileWithoutUser
if (value !== profile[typedKey]) {
console.log(key, value)
setProfile(typedKey, value)
if (typedKey === 'height_in_inches') updateHeight(value)
}
})
}
}
} catch (error) {
console.warn('Failed to load profile from store:', error)
}
}, []) // Only run once on mount
useEffect(() => {
fetchChoices('interests', locale).then(setInterestChoices)
fetchChoices('causes', locale).then(setCauseChoices)
@@ -120,6 +207,8 @@ export const OptionalProfileUserForm = (props: {
}
try {
await Promise.all(promises)
// Clear profile draft from Zustand store after successful submission
clearProfileDraft(user.id)
} catch (error) {
console.error(error)
toast.error(
@@ -308,6 +397,7 @@ export const OptionalProfileUserForm = (props: {
}}
className={'w-16'}
value={typeof heightFeet === 'number' ? Math.floor(heightFeet) : ''}
min={0}
/>
</Col>
<Col>
@@ -325,6 +415,7 @@ export const OptionalProfileUserForm = (props: {
}}
className={'w-16'}
value={typeof heightInches === 'number' ? Math.floor(heightInches) : ''}
min={0}
/>
</Col>
<div className="self-end mb-2 text-ink-700 mx-2">
@@ -348,9 +439,10 @@ export const OptionalProfileUserForm = (props: {
}
}}
className={'w-20'}
value={heightFeet !== undefined
? Math.round((heightFeet * 12 + (heightInches ?? 0)) * 2.54)
value={heightFeet !== undefined && profile['height_in_inches']
? Math.round(profile['height_in_inches'] * 2.54)
: ''}
min={0}
/>
</Col>
</Row>

View File

@@ -456,7 +456,7 @@ export const formatProfileValue = (key: string, value: any) => {
case 'has_pets':
return value ? 'Yes' : 'No'
case 'height_in_inches':
return `${Math.floor(value / 12)}' ${value % 12}"`
return `${Math.floor(value / 12)}' ${Math.round(value % 12)}"`
case 'pref_age_max':
case 'pref_age_min':
return null // handle this in a special case

View File

@@ -5,13 +5,14 @@ interface NewTabLinkProps {
href: string
children: React.ReactNode
className?: string
onClick?: () => void
}
export function NewTabLink({href, children, className}: NewTabLinkProps) {
export function NewTabLink({href, children, className, onClick}: NewTabLinkProps) {
// New tabs don't work on native apps
const isNative = isNativeMobile()
return (
<Link href={href} target={isNative ? undefined : "_blank"} className={className}>
<Link href={href} onClick={onClick} target={isNative ? undefined : "_blank"} className={className}>
{children}
</Link>
)