From e27e7a679e4bb759b9b83fe03a9222e1ca5710ce Mon Sep 17 00:00:00 2001 From: Robin Dittmar Date: Sat, 22 Aug 2026 22:08:09 +0200 Subject: [PATCH 1/3] feat: lists are now sorted by modified_at desc --- src/components/ListCard.vue | 4 ---- src/router/index.ts | 3 --- src/stores/lists.ts | 4 ---- src/views/ListDetailView.vue | 14 ++++++++++++-- 4 files changed, 12 insertions(+), 13 deletions(-) diff --git a/src/components/ListCard.vue b/src/components/ListCard.vue index 05f7703..8c343b8 100644 --- a/src/components/ListCard.vue +++ b/src/components/ListCard.vue @@ -14,10 +14,6 @@ const listsStore = useListsStore() const isMenuOpen = ref(false) const menuContainerRef = ref(null) -// The server now reports total_items/completed_items directly on the list, -// so the overview can show progress without having to load every item of -// every list. Fall back to counting locally cached items for lists that -// haven't synced to the server yet (e.g. just created while offline). const items = computed(() => listsStore.itemsForList(props.list.id)) const totalCount = computed(() => props.list.total_items ?? items.value.length) const completedCount = computed( diff --git a/src/router/index.ts b/src/router/index.ts index 8ffdb8e..3d02a73 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -26,9 +26,6 @@ const router = createRouter({ { path: '/about', name: 'about', - // route level code-splitting - // this generates a separate chunk (About.[hash].js) for this route - // which is lazy-loaded when the route is visited. component: () => import('../views/AboutView.vue'), }, ], diff --git a/src/stores/lists.ts b/src/stores/lists.ts index 30d09a8..776fb1c 100644 --- a/src/stores/lists.ts +++ b/src/stores/lists.ts @@ -50,10 +50,6 @@ export const useListsStore = defineStore('lists', () => { if (!isLoaded.value) { await refresh() } - // The server is the source of truth: even though we already have a - // local snapshot to render instantly (including while offline), always - // kick off a background sync/pull so views reflect the latest server - // state on every visit, not just on the first load of the session. void sync() } diff --git a/src/views/ListDetailView.vue b/src/views/ListDetailView.vue index 3c8838d..b7dbe95 100644 --- a/src/views/ListDetailView.vue +++ b/src/views/ListDetailView.vue @@ -2,6 +2,7 @@ import { ref, computed, onMounted, onUnmounted } from 'vue' import { useRouter } from 'vue-router' import { useListsStore } from '@/stores/lists' +import type { LocalListItem } from '@/database/db' import ListItemRow from '@/components/ListItemRow.vue' import DeleteListModal from '@/components/DeleteListModal.vue' @@ -43,8 +44,17 @@ onUnmounted(() => { const list = computed(() => listsStore.lists.find((entry) => entry.id === props.id)) const items = computed(() => listsStore.itemsForList(props.id)) -const pendingItems = computed(() => items.value.filter((item) => !item.is_completed)) -const completedItems = computed(() => items.value.filter((item) => item.is_completed)) + +function byModifiedDesc(a: LocalListItem, b: LocalListItem) { + return (b.modified_at ?? '').localeCompare(a.modified_at ?? '') +} + +const pendingItems = computed(() => + items.value.filter((item) => !item.is_completed).sort(byModifiedDesc), +) +const completedItems = computed(() => + items.value.filter((item) => item.is_completed).sort(byModifiedDesc), +) function toggleMenu(event: Event) { event.preventDefault() -- 2.54.0 From c6e45cd067dec3ec65b1aaee347c15f59ab5c20c Mon Sep 17 00:00:00 2001 From: Robin Dittmar Date: Sat, 22 Aug 2026 22:58:20 +0200 Subject: [PATCH 2/3] fix: debouncing sync and other improvements --- public/favicon.ico | Bin 4286 -> 4606 bytes src/api/auth.ts | 12 +- src/api/client.ts | 12 +- src/api/http.ts | 10 ++ src/api/lists.ts | 14 +- src/components/AppHeader.vue | 6 + src/components/DeleteListModal.vue | 20 +-- src/components/ListCard.vue | 53 +++---- src/components/ListItemRow.vue | 34 ++-- src/components/ShareListModal.vue | 18 +-- src/composables/useClickOutside.ts | 37 +++++ src/composables/useEscapeKey.ts | 30 ++++ src/database/db.ts | 1 - src/stores/__tests__/lists.spec.ts | 31 +++- src/stores/counter.ts | 12 -- src/stores/lists.ts | 239 ++++++++++++++++++++--------- src/views/ListDetailView.vue | 28 ++-- 17 files changed, 350 insertions(+), 207 deletions(-) create mode 100644 src/api/http.ts create mode 100644 src/composables/useClickOutside.ts create mode 100644 src/composables/useEscapeKey.ts delete mode 100644 src/stores/counter.ts diff --git a/public/favicon.ico b/public/favicon.ico index df36fcfb72584e00488330b560ebcf34a41c64c2..da8b5c6e7d57415f57e4d31027a0330f28230162 100644 GIT binary patch literal 4606 zcmc(jcU06@7RSl%Im--Kh$t4Y#fUwKHEOKHf(B4P!J#Tr6;v>G5EK(sjADr~Dpp)K z#*H0OR8RvV2+~BV8Z@zs7y=lE&)(mRy86fdvH$GOIiK_1n{vMQ-FM%;_csOx9~k__ zpi?IU89NyqGBGf)G%%3$Uu6A(LEjGy46^0D?#g?@C_(w&e|iB13Qir3{x-Fvdi>N5 z>IC7gb96x}JJ z>(*6`R}Uk>7_XlH##`pzy@Z}kWK>Y*%I7i{l+mk)9@j2L;sZk_wo-hd#!l=J8pJP~ z#TSOtKT=H)ss&s+t59s1)=@?9AQRTvnz4GcDJzDz))A?UpS33P9ioP{A@Vg*!`c!( z%#@8|+OXZ$g70lR@ZF@2gbXy*pNU=@d;R!Q?6K8+q&lGKqC!EjL3|-|HS6rniLvcP zR8&0c<1Z1r^AhXc?(EzPhj(;)@*gP#I>6_j=|e-m^@eX^}Y~&t%s!w4@*Nqg=cqTX7p~xENc_u zrgvfevT)9q)o}9can}F1g3}igNxf4`T5dV%nFXZ1A*+BCu`&5h1Lw+L@k2@>Km7AJ zySD8h+O99(diKD*hbhxM7UMY9MSnkD-%-zQqN~uo4W8Ei_*mN#AZHin-jxmE;iOd5 zl6WzhpHfe-C20f6rIlpl7IF1z=3CNbo|c|T!oEYq#3r!mU>3U*%lSw817g2iz&7um zcw4nV)6GQA!C03&lw6_2f@cp!TaD-V#kd4N#M9vvbB4ENvzH|sBf?3mtS9S!9qHvy zNxpT5AB&!o_Mm}lzc%pmy#}sSzTr>y{E)&)(Kid6Jpky-wjpR;a| zS@DE3m0Hs7z96IGnb`DcM-jGIL?ye9NHO*wi=&4P4Ms6oY|k6GH*;<7EH9@Tfd%cUK!5S${G?coMhv% zC=yc>$@;C9tfJdw=H7hEwHr4{D|

_8r#mPDJzd9c)7Dn69}?h|LO?k2E1F?YNLb0&5p zVCo<|`m~f>XDm5E#jHL?_*pk)_FxktG~+m)u#@oV{rEn_ifyaI$$eZ;@zXl;?mgv7 z#V>N^RpdNvAm_23>_=J@vL0&5l(@KjyPCL@*YHoOVf1D#qnEwFd&U*!+D;>QbX&rv zjA2!*eigY2V$Y3Au}Q}Zoh>OPPgr|8n_0)Ju~~bMvEh&Ka7`g_L_fl3OclRI zqOmkZsc*hkMg;V4O7Kt_tzS-A2x8lppvhSUB&HmH6y;SV}3{?-_IC=|KL`5^)W^B zaT8sQ^|dnA>&yPb#7Z;I#Ba6Iu z$j!+jHz$)DLT=7=vVW=KWL7ER=WnuMe-at-Gx%ZYLcDFF@#^)CeqYfa(1fsYtytwQ zdv~;8nR6%hhJQx<`Ur}i)r$}6D1H2#%zJmpcyNpSs%na!H|V(etbsxWiHQOsU&wp% zC%I1=$@;a94Qpb!es&v2;t%2Me~*dNit(}@{@(rr2RG-tZ>DkXSPYw1e#$bZj_ivZ z!QKtBe~G=z542R&K=I2)@?ZQ;(aTrds&3R#^a2!a3i(fkXHf85Mj`t_J=-OQw}#Ec ze_c7=>#ER9iO0KlE4{w-8h(9^2ps$&v0Gg@ni9)*-_2mjl=kdjHJZH}BPe}QPsJaN zl+`qHF29k37qqh9MoL8gTG@TBmfjP6E%~zF1Idk?&S|7jv=tk29@VoeCW5^S1~J>tFM|a#?ogf`fB{GjR>)~WO>*~mbi3d(HG|IUv0y_xClzB>SXVYlnX`A zwIpX%a3VRC{6{ajoL|D_yb|){J{&)HiKGmP%g6FP#UE9W`{)(%62toz4km28IkShG zNquQ0Ilrm&8b+coci&2{A2_fH!IGClZCbL>wk`X=9;?$YRrG&v)FU>e6_oJP$wW$D z{z++#mQwkw_(cOpPMqPA*ix*V%L}~?`Hx?*TViyG*x%Y~Z*iiVSmc8qi zuz&e5W)C&RXVgSIhuPyMx#vx<=P&v?2DHO((KiGJE+XVJQxakw*!R^$k}jMly`Y%= zM=s*NEQQHYc}!WEkCQNURX#4!1!z_mqKV8$6Q0kkrI*-q_##*GOF5l?! zM0yJg{06m_`={@D1Nx~S1}fUG39wcZG^n|*9<1~pL~O`#Hq0N%O5btBif`j?{y{{+ zD?GEcEIfakFOw^no~gwx;}w(7)Zo0ggmCwPfWZmH_(} z(r2pV3=E|P7(em1Rv#4`5I^XUH8I{pq!xA1`5|Oj3+9gLF14X0OMP4jUl_{TJ%?D4 z{gCa4H?umBfro=0ax zSJg4mn;O_pT`LgKPjAEQPg}@b@{-&MUwa=sgLe}$vJ3M^wvZaplEohOY{@8L#V;qd%iQhnTW?IR)uba;5<4Xx$WGC9vZ(ADHxml`Q zeft_U5$yz_QSpP$j(5((f7n2L?Y!hnnn{1uOzLEFU7lDIxtzVJnZ%sUWWnNaUHzOh zq&Y!@n-Mg`f<0R|vE{2MX7*_&&u1#UKQUp(h%SkSzl0&72 z$nTjYxzy(qrJt03wvS4BJGDGd2)(4o?WL*`{Zn%G8uuPXx_vA9DFz0Aja2?3_#eL% X%@omo|9^`Axf%cUuEl5n*Vn%Q6d(kC literal 4286 zcmds*O-Phc6o&64GDVCEQHxsW(p4>LW*W<827=Unuo8sGpRux(DN@jWP-e29Wl%wj zY84_aq9}^Am9-cWTD5GGEo#+5Fi2wX_P*bo+xO!)p*7B;iKlbFd(U~_d(U?#hLj56 zPhFkj-|A6~Qk#@g^#D^U0XT1cu=c-vu1+SElX9NR;kzAUV(q0|dl0|%h|dI$%VICy zJnu2^L*Te9JrJMGh%-P79CL0}dq92RGU6gI{v2~|)p}sG5x0U*z<8U;Ij*hB9z?ei z@g6Xq-pDoPl=MANPiR7%172VA%r)kevtV-_5H*QJKFmd;8yA$98zCxBZYXTNZ#QFk2(TX0;Y2dt&WitL#$96|gJY=3xX zpCoi|YNzgO3R`f@IiEeSmKrPSf#h#Qd<$%Ej^RIeeYfsxhPMOG`S`Pz8q``=511zm zAm)MX5AV^5xIWPyEu7u>qYs?pn$I4nL9J!=K=SGlKLXpE<5x+2cDTXq?brj?n6sp= zphe9;_JHf40^9~}9i08r{XM$7HB!`{Ys~TK0kx<}ZQng`UPvH*11|q7&l9?@FQz;8 zx!=3<4seY*%=OlbCbcae?5^V_}*K>Uo6ZWV8mTyE^B=DKy7-sdLYkR5Z?paTgK-zyIkKjIcpyO z{+uIt&YSa_$QnN_@t~L014dyK(fOOo+W*MIxbA6Ndgr=Y!f#Tokqv}n<7-9qfHkc3 z=>a|HWqcX8fzQCT=dqVbogRq!-S>H%yA{1w#2Pn;=e>JiEj7Hl;zdt-2f+j2%DeVD zsW0Ab)ZK@0cIW%W7z}H{&~yGhn~D;aiP4=;m-HCo`BEI+Kd6 z={Xwx{TKxD#iCLfl2vQGDitKtN>z|-AdCN|$jTFDg0m3O`WLD4_s#$S diff --git a/src/api/auth.ts b/src/api/auth.ts index 8f6d030..d95d73b 100644 --- a/src/api/auth.ts +++ b/src/api/auth.ts @@ -1,15 +1,7 @@ import type { LoginPayload, RefreshPayload, TokenPair } from '@/types/auth' +import { API_BASE_URL, extractErrorMessage } from '@/api/http' -export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? '/api/v1' - -async function extractErrorMessage(response: Response, fallback: string): Promise { - try { - const errorData = await response.json() - return errorData.message || errorData.error || fallback - } catch { - return response.statusText || fallback - } -} +export { API_BASE_URL } async function postJson( path: string, diff --git a/src/api/client.ts b/src/api/client.ts index d7ed06a..26c2204 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -1,6 +1,8 @@ import { useAuthStore } from '@/stores/auth' -import { API_BASE_URL } from '@/api/auth' import router from '@/router' +import { API_BASE_URL, extractErrorMessage } from '@/api/http' + +export { API_BASE_URL, extractErrorMessage } let refreshPromise: Promise | null = null @@ -94,6 +96,10 @@ export const apiClient = { method: 'PUT', body: body ? JSON.stringify(body) : undefined, }), - delete: (endpoint: string, options?: FetchOptions) => - fetchWithAuth(endpoint, { ...options, method: 'DELETE' }), + delete: (endpoint: string, body?: unknown, options?: FetchOptions) => + fetchWithAuth(endpoint, { + ...options, + method: 'DELETE', + body: body ? JSON.stringify(body) : undefined, + }), } diff --git a/src/api/http.ts b/src/api/http.ts new file mode 100644 index 0000000..bdf7a0d --- /dev/null +++ b/src/api/http.ts @@ -0,0 +1,10 @@ +export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? '/api/v1' + +export async function extractErrorMessage(response: Response, fallback: string): Promise { + try { + const errorData = await response.json() + return errorData.message || errorData.error || fallback + } catch { + return response.statusText || fallback + } +} diff --git a/src/api/lists.ts b/src/api/lists.ts index 740f82c..e673a85 100644 --- a/src/api/lists.ts +++ b/src/api/lists.ts @@ -1,4 +1,5 @@ import { apiClient } from '@/api/client' +import { extractErrorMessage } from '@/api/http' import type { List, ListItem, @@ -10,15 +11,6 @@ import type { RemoveUserFromListPayload, } from '@/types/list' -async function extractErrorMessage(response: Response, fallback: string): Promise { - try { - const errorData = await response.json() - return errorData.message || errorData.error || fallback - } catch { - return response.statusText || fallback - } -} - export async function getListsApi(): Promise { const response = await apiClient.get('/lists') if (!response.ok) { @@ -76,9 +68,7 @@ export async function addUserToListApi(payload: AddUserToListPayload): Promise { - const response = await apiClient.delete('/lists/user', { - body: JSON.stringify(payload), - }) + const response = await apiClient.delete('/lists/user', payload) if (!response.ok) { throw new Error(await extractErrorMessage(response, 'Failed to remove user from list')) } diff --git a/src/components/AppHeader.vue b/src/components/AppHeader.vue index b2a4bfd..6bc720b 100644 --- a/src/components/AppHeader.vue +++ b/src/components/AppHeader.vue @@ -29,6 +29,7 @@ async function handleLogout() { {{ listsStore.pendingCount }} pending + ⚠ sync error @@ -85,6 +86,11 @@ async function handleLogout() { background-color: var(--c-text-soft); } +.sync-error { + color: var(--c-danger); + cursor: help; +} + .logout { background: none; border: 1px solid var(--c-border); diff --git a/src/components/DeleteListModal.vue b/src/components/DeleteListModal.vue index 1be0dac..c32d652 100644 --- a/src/components/DeleteListModal.vue +++ b/src/components/DeleteListModal.vue @@ -1,29 +1,17 @@