feat(settings): autosave on change, remove Save buttons (closes #128)

Each setting now saves via a debounced (600ms) individual API call when
its value changes. A hydration guard (_hydrated flag + nextTick) prevents
watchers from firing during the initial load() fetch, ensuring the first
API round-trip does not generate spurious write calls.

Removed: five explicit Save buttons across Equipment, Sensory, Units,
Shopping Region, and Recipe Search Layout sections.
Added: "Changes save automatically." subtitle + fixed bottom-right toast
  that appears for 2s after any successful save, with enter/leave
  transitions that respect prefers-reduced-motion via the theme.

The full save() and saveSensory() actions are kept as internal fallbacks.
This commit is contained in:
pyr0ball 2026-05-11 11:55:09 -07:00
parent 0ef57618bf
commit 30f5620fd5
2 changed files with 86 additions and 81 deletions

View file

@ -2,6 +2,7 @@
<div class="settings-view"> <div class="settings-view">
<div class="card"> <div class="card">
<h2 class="section-title text-xl mb-md">Settings</h2> <h2 class="section-title text-xl mb-md">Settings</h2>
<p class="text-xs text-muted mb-md">Changes save automatically.</p>
<!-- Cooking Equipment --> <!-- Cooking Equipment -->
<section> <section>
@ -50,18 +51,6 @@
</div> </div>
</div> </div>
<!-- Save button -->
<div class="flex-start gap-sm">
<button
class="btn btn-primary"
:disabled="settingsStore.loading"
@click="settingsStore.save()"
>
<span v-if="settingsStore.loading">Saving</span>
<span v-else-if="settingsStore.saved"> Saved!</span>
<span v-else>Save Settings</span>
</button>
</div>
</section> </section>
<!-- Sensory Preferences --> <!-- Sensory Preferences -->
@ -134,17 +123,6 @@
</p> </p>
</div> </div>
<div class="flex-start gap-sm mt-sm">
<button
class="btn btn-primary btn-sm"
:disabled="settingsStore.loading"
@click="settingsStore.saveSensory()"
>
<span v-if="settingsStore.loading">Saving</span>
<span v-else-if="settingsStore.saved">Saved!</span>
<span v-else>Save sensory preferences</span>
</button>
</div>
</section> </section>
<!-- Units --> <!-- Units -->
@ -169,17 +147,6 @@
Imperial (oz, cups, °F) Imperial (oz, cups, °F)
</button> </button>
</div> </div>
<div class="flex-start gap-sm">
<button
class="btn btn-primary btn-sm"
:disabled="settingsStore.loading"
@click="settingsStore.save()"
>
<span v-if="settingsStore.loading">Saving</span>
<span v-else-if="settingsStore.saved"> Saved!</span>
<span v-else>Save</span>
</button>
</div>
</section> </section>
<!-- Shopping Locale --> <!-- Shopping Locale -->
@ -220,17 +187,6 @@
<option value="br">Brazil (BRL R$)</option> <option value="br">Brazil (BRL R$)</option>
</optgroup> </optgroup>
</select> </select>
<div class="flex-start gap-sm mt-sm">
<button
class="btn btn-primary btn-sm"
:disabled="settingsStore.loading"
@click="settingsStore.save()"
>
<span v-if="settingsStore.loading">Saving</span>
<span v-else-if="settingsStore.saved"> Saved!</span>
<span v-else>Save</span>
</button>
</div>
</section> </section>
<!-- Time-First Layout --> <!-- Time-First Layout -->
@ -258,17 +214,6 @@
</span> </span>
</label> </label>
</div> </div>
<div class="flex-start gap-sm mt-sm">
<button
class="btn btn-primary btn-sm"
:disabled="settingsStore.loading"
@click="settingsStore.save()"
>
<span v-if="settingsStore.loading">Saving</span>
<span v-else-if="settingsStore.saved"> Saved!</span>
<span v-else>Save</span>
</button>
</div>
</section> </section>
<!-- Data Sharing (cloud only) --> <!-- Data Sharing (cloud only) -->
@ -393,6 +338,12 @@
</template> </template>
</div> </div>
</div> </div>
<Transition name="autosave-fade">
<div v-if="settingsStore.saved" class="autosave-toast" role="status" aria-live="polite">
Saved
</div>
</Transition>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@ -871,4 +822,32 @@ function getNoiseClass(_value: NoiseLevel, idx: number): string {
border-color: var(--color-border, #e0e0e0); border-color: var(--color-border, #e0e0e0);
color: var(--color-text-secondary, #888); color: var(--color-text-secondary, #888);
} }
/* ── Autosave toast ──────────────────────────────────────────────────────── */
.autosave-toast {
position: fixed;
bottom: 1.5rem;
right: 1.5rem;
background: var(--color-surface, #fff);
border: 1px solid var(--color-border, #e0e0e0);
border-radius: var(--radius-md, 0.5rem);
padding: 0.4rem 0.9rem;
font-size: var(--font-size-sm);
color: var(--color-success, #4a8c40);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
z-index: 500;
pointer-events: none;
}
.autosave-fade-enter-active,
.autosave-fade-leave-active {
transition: opacity 0.25s ease, transform 0.25s ease;
}
.autosave-fade-enter-from,
.autosave-fade-leave-to {
opacity: 0;
transform: translateY(0.5rem);
}
</style> </style>

View file

@ -1,11 +1,5 @@
/**
* Settings Store
*
* Manages user settings (cooking equipment, preferences) using Pinia.
*/
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref } from 'vue' import { ref, watch, nextTick } from 'vue'
import { settingsAPI } from '../services/api' import { settingsAPI } from '../services/api'
import type { UnitSystem } from '../utils/units' import type { UnitSystem } from '../utils/units'
import type { SensoryPreferences } from '../services/api' import type { SensoryPreferences } from '../services/api'
@ -13,8 +7,12 @@ import { DEFAULT_SENSORY_PREFERENCES } from '../services/api'
export type TimeFirstLayout = 'auto' | 'time_first' | 'normal' export type TimeFirstLayout = 'auto' | 'time_first' | 'normal'
function debounce(fn: () => void, ms: number): () => void {
let t: ReturnType<typeof setTimeout>
return () => { clearTimeout(t); t = setTimeout(fn, ms) }
}
export const useSettingsStore = defineStore('settings', () => { export const useSettingsStore = defineStore('settings', () => {
// State
const cookingEquipment = ref<string[]>([]) const cookingEquipment = ref<string[]>([])
const unitSystem = ref<UnitSystem>('metric') const unitSystem = ref<UnitSystem>('metric')
const shoppingLocale = ref<string>('us') const shoppingLocale = ref<string>('us')
@ -23,7 +21,40 @@ export const useSettingsStore = defineStore('settings', () => {
const loading = ref(false) const loading = ref(false)
const saved = ref(false) const saved = ref(false)
// Actions // Prevents autosave watchers from firing during initial load hydration.
// Set to true after nextTick() at the end of load() — by that point all
// watcher jobs queued by the hydration assignments have already flushed.
let _hydrated = false
function _flash() {
saved.value = true
setTimeout(() => { saved.value = false }, 2000)
}
async function _saveKey(key: string, value: string): Promise<void> {
if (!_hydrated) return
try {
await settingsAPI.setSetting(key, value)
_flash()
} catch (err: unknown) {
console.error('Autosave failed for key:', key, err)
}
}
const _autosave = {
equipment: debounce(() => _saveKey('cooking_equipment', JSON.stringify(cookingEquipment.value)), 600),
unit: debounce(() => _saveKey('unit_system', unitSystem.value), 600),
locale: debounce(() => _saveKey('shopping_locale', shoppingLocale.value), 600),
sensory: debounce(() => _saveKey('sensory_preferences', JSON.stringify(sensoryPreferences.value)), 600),
layout: debounce(() => _saveKey('time_first_layout', timeFirstLayout.value), 600),
}
watch(cookingEquipment, _autosave.equipment, { deep: true })
watch(unitSystem, _autosave.unit)
watch(shoppingLocale, _autosave.locale)
watch(sensoryPreferences, _autosave.sensory, { deep: true })
watch(timeFirstLayout, _autosave.layout)
async function load() { async function load() {
loading.value = true loading.value = true
try { try {
@ -58,8 +89,15 @@ export const useSettingsStore = defineStore('settings', () => {
} finally { } finally {
loading.value = false loading.value = false
} }
// Yield past the watcher flush triggered by hydration assignments above.
// After nextTick, any pending watcher jobs from this load() have already
// run (and been ignored by _hydrated guard), so user-driven changes from
// here forward will correctly trigger autosave.
await nextTick()
_hydrated = true
} }
// Kept for explicit full-save scenarios (e.g. fallback, tests).
async function save() { async function save() {
loading.value = true loading.value = true
try { try {
@ -70,10 +108,7 @@ export const useSettingsStore = defineStore('settings', () => {
settingsAPI.setSetting('sensory_preferences', JSON.stringify(sensoryPreferences.value)), settingsAPI.setSetting('sensory_preferences', JSON.stringify(sensoryPreferences.value)),
settingsAPI.setSetting('time_first_layout', timeFirstLayout.value), settingsAPI.setSetting('time_first_layout', timeFirstLayout.value),
]) ])
saved.value = true _flash()
setTimeout(() => {
saved.value = false
}, 2000)
} catch (err: unknown) { } catch (err: unknown) {
console.error('Failed to save settings:', err) console.error('Failed to save settings:', err)
} finally { } finally {
@ -81,24 +116,17 @@ export const useSettingsStore = defineStore('settings', () => {
} }
} }
// Kept for backward compat; autosave handles sensory changes now.
async function saveSensory() { async function saveSensory() {
loading.value = true
try { try {
await settingsAPI.setSetting( await settingsAPI.setSetting('sensory_preferences', JSON.stringify(sensoryPreferences.value))
'sensory_preferences', _flash()
JSON.stringify(sensoryPreferences.value),
)
saved.value = true
setTimeout(() => { saved.value = false }, 2000)
} catch (err: unknown) { } catch (err: unknown) {
console.error('Failed to save sensory preferences:', err) console.error('Failed to save sensory preferences:', err)
} finally {
loading.value = false
} }
} }
return { return {
// State
cookingEquipment, cookingEquipment,
unitSystem, unitSystem,
shoppingLocale, shoppingLocale,
@ -106,8 +134,6 @@ export const useSettingsStore = defineStore('settings', () => {
timeFirstLayout, timeFirstLayout,
loading, loading,
saved, saved,
// Actions
load, load,
save, save,
saveSensory, saveSensory,