From f37b19de53fcf87bffe8feea3272e41235f89899 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Sat, 12 Sep 2026 13:38:38 +0200 Subject: [PATCH] fix in rooms --- shopping_app/routes_main.py | 15 ++ shopping_app/sockets.py | 77 ++++----- shopping_app/static/css/split/pages.css | 74 +++++++++ shopping_app/static/js/live.js | 37 +++-- shopping_app/static/js/sockets.js | 197 +++++++++++++++++++----- shopping_app/static/js/toggle_button.js | 105 ++++++++++--- shopping_app/templates/main.html | 25 +++ 7 files changed, 428 insertions(+), 102 deletions(-) diff --git a/shopping_app/routes_main.py b/shopping_app/routes_main.py index 3218de1..027631f 100644 --- a/shopping_app/routes_main.py +++ b/shopping_app/routes_main.py @@ -546,8 +546,21 @@ def logout(): def create_list(): title = request.form.get("title") is_temporary = request.form.get("temporary") == "1" + list_date_raw = (request.form.get("list_date") or "").strip() token = generate_share_token(8) + created_at_override = None + if list_date_raw: + try: + # created_at jest w tym modelu DateTime bez strefy. Środek dnia + # zapobiega przypadkowemu przesunięciu daty przy konwersjach DB/UI. + created_at_override = datetime.strptime(list_date_raw, "%Y-%m-%d").replace( + hour=12, minute=0, second=0, microsecond=0 + ) + except ValueError: + flash("Nieprawidłowa data listy.", "danger") + return redirect(url_for("main_page")) + expires_at = ( datetime.now(timezone.utc) + timedelta(days=7) if is_temporary else None ) @@ -559,6 +572,8 @@ def create_list(): share_token=token, expires_at=expires_at, ) + if created_at_override is not None: + new_list.created_at = created_at_override db.session.add(new_list) db.session.commit() log_list_activity(new_list.id, 'list_created', actor=current_user, actor_name=current_user.username, details='Utworzono listę ręcznie') diff --git a/shopping_app/sockets.py b/shopping_app/sockets.py index fba40ff..5dfb77e 100644 --- a/shopping_app/sockets.py +++ b/shopping_app/sockets.py @@ -354,50 +354,56 @@ def handle_add_item(data): def handle_check_item(data): item = db.session.get(Item, data["item_id"]) - if item: - item.purchased = True - item.purchased_at = datetime.now(UTC) - item.not_purchased = False - item.not_purchased_reason = None - log_list_activity(item.list_id, 'item_checked', item_name=item.name, actor=current_user if current_user.is_authenticated else None, actor_name=current_user.username if current_user.is_authenticated else 'Gość') - db.session.commit() + if not item: + return {"ok": False, "error": "item_not_found"} - purchased_count, total_count, percent = get_progress(item.list_id) + item.purchased = True + item.purchased_at = datetime.now(UTC) + item.not_purchased = False + item.not_purchased_reason = None + log_list_activity(item.list_id, 'item_checked', item_name=item.name, actor=current_user if current_user.is_authenticated else None, actor_name=current_user.username if current_user.is_authenticated else 'Gość') + db.session.commit() - emit("item_checked", {"item_id": item.id}, to=str(item.list_id)) - emit( - "progress_updated", - { - "purchased_count": purchased_count, - "total_count": total_count, - "percent": percent, - }, - to=str(item.list_id), - ) + purchased_count, total_count, percent = get_progress(item.list_id) + + emit("item_checked", {"item_id": item.id}, to=str(item.list_id)) + emit( + "progress_updated", + { + "purchased_count": purchased_count, + "total_count": total_count, + "percent": percent, + }, + to=str(item.list_id), + ) + return {"ok": True, "item_id": item.id, "purchased": True} @socketio.on("uncheck_item") def handle_uncheck_item(data): item = db.session.get(Item, data["item_id"]) - if item: - item.purchased = False - item.purchased_at = None - log_list_activity(item.list_id, 'item_unchecked', item_name=item.name, actor=current_user if current_user.is_authenticated else None, actor_name=current_user.username if current_user.is_authenticated else 'Gość') - db.session.commit() + if not item: + return {"ok": False, "error": "item_not_found"} - purchased_count, total_count, percent = get_progress(item.list_id) + item.purchased = False + item.purchased_at = None + log_list_activity(item.list_id, 'item_unchecked', item_name=item.name, actor=current_user if current_user.is_authenticated else None, actor_name=current_user.username if current_user.is_authenticated else 'Gość') + db.session.commit() - emit("item_unchecked", {"item_id": item.id}, to=str(item.list_id)) - emit( - "progress_updated", - { - "purchased_count": purchased_count, - "total_count": total_count, - "percent": percent, - }, - to=str(item.list_id), - ) + purchased_count, total_count, percent = get_progress(item.list_id) + + emit("item_unchecked", {"item_id": item.id}, to=str(item.list_id)) + emit( + "progress_updated", + { + "purchased_count": purchased_count, + "total_count": total_count, + "percent": percent, + }, + to=str(item.list_id), + ) + return {"ok": True, "item_id": item.id, "purchased": False} @socketio.on("request_full_list") @@ -406,7 +412,7 @@ def handle_request_full_list(data): shopping_list = db.session.get(ShoppingList, list_id) if not shopping_list: - return + return {"ok": False, "error": "list_not_found"} owner_id = shopping_list.owner_id @@ -438,6 +444,7 @@ def handle_request_full_list(data): ) emit("full_list", {"items": items_data}, to=request.sid) + return {"ok": True, "list_id": list_id} @socketio.on("update_note") diff --git a/shopping_app/static/css/split/pages.css b/shopping_app/static/css/split/pages.css index 6c45559..ff9b072 100644 --- a/shopping_app/static/css/split/pages.css +++ b/shopping_app/static/css/split/pages.css @@ -729,3 +729,77 @@ input[type="checkbox"].form-check-input, letter-spacing: 0; } } + +/* Tworzenie listy: osobny, kompaktowy wybór daty obok "Tymczasowa". */ +.endpoint-main_page .create-list-input-group > .create-list-temp-toggle, +.endpoint-main_page .create-list-input-group > #tempToggle { + min-width: 8.75rem; + border-top-right-radius: 0 !important; + border-bottom-right-radius: 0 !important; +} + +.endpoint-main_page .create-list-input-group > .create-list-date-toggle, +.endpoint-main_page .create-list-input-group > #listDateToggle { + flex: 0 0 auto !important; + width: auto !important; + min-width: 5.75rem; + margin-left: -1px; + padding-left: .8rem; + padding-right: .8rem; + font-weight: 600; + white-space: nowrap; + border: 0 !important; + border-left: 1px solid rgba(255, 255, 255, 0.08) !important; + border-radius: 0 14px 14px 0 !important; + background: rgba(255, 255, 255, 0.04) !important; + color: var(--app-text) !important; + box-shadow: none !important; + transition: background-color .18s ease, border-color .18s ease, color .18s ease, box-shadow .18s ease; +} + +.endpoint-main_page .create-list-input-group > .create-list-date-toggle:hover, +.endpoint-main_page .create-list-input-group > .create-list-date-toggle:focus, +.endpoint-main_page .create-list-input-group > #listDateToggle:hover, +.endpoint-main_page .create-list-input-group > #listDateToggle:focus { + background: rgba(255, 255, 255, 0.07) !important; +} + +.endpoint-main_page .create-list-input-group > .create-list-date-toggle.is-active, +.endpoint-main_page .create-list-input-group > #listDateToggle.is-active { + background: rgba(13, 110, 253, 0.2) !important; + color: #b9d4ff !important; +} + +@media (max-width: 767.98px) { + .endpoint-main_page .create-list-input-group > .create-list-temp-toggle, + .endpoint-main_page .create-list-input-group > #tempToggle { + min-width: 7.25rem; + padding-left: .65rem; + padding-right: .65rem; + font-size: .86rem; + } + + .endpoint-main_page .create-list-input-group > .create-list-date-toggle, + .endpoint-main_page .create-list-input-group > #listDateToggle { + min-width: 5rem; + padding-left: .55rem; + padding-right: .55rem; + font-size: .86rem; + } +} + +@media (max-width: 575.98px) { + .endpoint-main_page .create-list-input-group > .create-list-temp-toggle, + .endpoint-main_page .create-list-input-group > #tempToggle { + min-width: 6.7rem; + font-size: .8rem; + } + + .endpoint-main_page .create-list-input-group > .create-list-date-toggle, + .endpoint-main_page .create-list-input-group > #listDateToggle { + min-width: 4.7rem; + padding-left: .45rem; + padding-right: .45rem; + font-size: .8rem; + } +} diff --git a/shopping_app/static/js/live.js b/shopping_app/static/js/live.js index 6c91f10..536d777 100644 --- a/shopping_app/static/js/live.js +++ b/shopping_app/static/js/live.js @@ -21,6 +21,12 @@ function toggleEmptyPlaceholder() { } function setupList(listId, username) { + // Ustaw dane pokoju przed pierwszym emit — dzięki temu reconnect, który trafi + // dokładnie w moment inicjalizacji widoku, nadal wie do jakiego pokoju wrócić. + window.LIST_ID = listId; + window.usernameForReconnect = username; + window.CURRENT_LIST_USERNAME = username; + socket.emit('join_list', { room: listId, username: username }); const newItemInput = document.getElementById('newItem'); @@ -81,11 +87,8 @@ function setupList(listId, username) { if (li) { const id = parseInt(li.id.replace('item-', ''), 10); - if (e.target.checked) { - socket.emit('check_item', { item_id: id }); - } else { - socket.emit('uncheck_item', { item_id: id }); - } + const intendedChecked = e.target.checked; + const eventName = intendedChecked ? 'check_item' : 'uncheck_item'; e.target.disabled = true; li.classList.add('opacity-50', 'is-pending'); @@ -98,6 +101,25 @@ function setupList(listId, username) { spinner.setAttribute('aria-hidden', 'true'); li.appendChild(spinner); } + + // ACK od serwera zabezpiecza przypadek, w którym po wznowieniu karty + // transport wygląda na aktywny, ale faktycznie już nie działa. Nie czekamy wtedy + // bez końca z zablokowanym checkboxem. + socket.timeout(7000).emit(eventName, { item_id: id }, (err, response) => { + if (!err && response && response.ok) { + updateItemState(id, intendedChecked); + return; + } + + if (!err && response && response.ok === false) { + socket.emit('request_full_list', { list_id: window.LIST_ID }); + return; + } + + if (typeof window.recoverListSocket === 'function') { + window.recoverListSocket({ force: true }); + } + }); } } }); @@ -262,11 +284,6 @@ function setupList(listId, username) { applyHidePurchased(); }); - // --- WAŻNE: zapisz dane do reconnect --- - window.LIST_ID = listId; - window.usernameForReconnect = username; - window.CURRENT_LIST_USERNAME = username; - } function unmarkNotPurchased(itemId) { diff --git a/shopping_app/static/js/sockets.js b/shopping_app/static/js/sockets.js index 4ef5862..a7f77d3 100644 --- a/shopping_app/static/js/sockets.js +++ b/shopping_app/static/js/sockets.js @@ -1,53 +1,177 @@ let didReceiveFirstFullList = false; -// --- Automatyczny reconnect po powrocie do karty/przywróceniu internetu --- -function reconnectIfNeeded() { - if (!socket.connected) { - socket.connect(); - } +// Przeglądarki mobilne i desktopowe mogą zamrozić kartę lub zerwać transport +// bez natychmiastowej zmiany socket.connected. Po wznowieniu odświeżamy transport, +// ponownie dołączamy do pokoju i pobieramy aktualny stan listy. +const WAKE_RECONNECT_THRESHOLD_MS = 2500; +const WAKE_RECONNECT_DEBOUNCE_MS = 200; +const WAKE_WATCHDOG_INTERVAL_MS = 10000; +const WAKE_WATCHDOG_GAP_MS = 25000; +let pageHiddenAt = null; +let recoveryTimer = null; +let recoveryForceRequested = false; +let recoveryInProgress = false; +let firstConnect = true; +let wasReconnected = false; +let lastWatchdogTick = Date.now(); + +function hasActiveListRoom() { + return Boolean(window.LIST_ID && window.usernameForReconnect); } -document.addEventListener("visibilitychange", function () { - if (!document.hidden) { - reconnectIfNeeded(); - } -}); - -window.addEventListener("focus", function () { - reconnectIfNeeded(); -}); - -window.addEventListener("online", function () { - reconnectIfNeeded(); -}); - -// --- Blokowanie checkboxów na czas reconnect --- function disableCheckboxes(disable) { document.querySelectorAll('#items input[type="checkbox"]').forEach(cb => { - cb.disabled = disable; + if (disable) { + // Zapamiętaj stan biznesowy tylko przy pierwszym nałożeniu blokady transportu. + // Dzięki temu checkboxy wyłączone np. przez archiwizację nie zostaną + // przypadkiem odblokowane po ponownej synchronizacji. + if (cb.dataset.socketDisabledBefore === undefined) { + cb.dataset.socketDisabledBefore = cb.disabled ? '1' : '0'; + } + cb.disabled = true; + return; + } + + if (cb.dataset.socketDisabledBefore !== undefined) { + cb.disabled = cb.dataset.socketDisabledBefore === '1'; + delete cb.dataset.socketDisabledBefore; + } }); } -// --- Toasty przy rozłączeniu i połączeniu --- -let firstConnect = true; -let wasReconnected = false; // flaga do kontrolowania toasta +function rejoinCurrentList() { + if (!socket.connected || !hasActiveListRoom()) return; + socket.emit('join_list', { + room: window.LIST_ID, + username: window.usernameForReconnect + }); +} + +function requestCurrentList({ verify = false } = {}) { + if (!socket.connected || !window.LIST_ID) return; + + if (!verify) { + socket.emit('request_full_list', { list_id: window.LIST_ID }); + return; + } + + socket.timeout(4000).emit('request_full_list', { list_id: window.LIST_ID }, (err, response) => { + if (!err && response && response.ok === true) return; + recoverListSocket({ force: true }); + }); +} + +function recoverListSocket({ force = false } = {}) { + recoveryForceRequested = recoveryForceRequested || force; + clearTimeout(recoveryTimer); + recoveryTimer = setTimeout(() => { + const shouldForce = recoveryForceRequested; + recoveryForceRequested = false; + + if (document.hidden) return; + + if (!socket.connected) { + if (window.LIST_ID) disableCheckboxes(true); + socket.connect(); + return; + } + + if (shouldForce && hasActiveListRoom()) { + if (recoveryInProgress) return; + recoveryInProgress = true; + disableCheckboxes(true); + + // disconnect/connect czyści potencjalnie "martwy" transport po uśpieniu karty/procesu. + socket.disconnect(); + setTimeout(() => socket.connect(), 0); + return; + } + + if (hasActiveListRoom()) { + rejoinCurrentList(); + requestCurrentList({ verify: true }); + } + }, WAKE_RECONNECT_DEBOUNCE_MS); +} + +// Udostępnione dla akcji w live.js (np. timeout checkboxa). +window.recoverListSocket = recoverListSocket; + +// Powrót po zablokowaniu telefonu / zmianie aplikacji. +document.addEventListener('visibilitychange', () => { + if (document.hidden) { + pageHiddenAt = Date.now(); + return; + } + + const hiddenFor = pageHiddenAt === null ? 0 : Date.now() - pageHiddenAt; + pageHiddenAt = null; + recoverListSocket({ force: hiddenFor >= WAKE_RECONNECT_THRESHOLD_MS }); +}); + +// BFCache może przywrócić stronę ze starym transportem Socket.IO. +window.addEventListener('pageshow', event => { + recoverListSocket({ force: Boolean(event.persisted) }); +}); + +window.addEventListener('pagehide', () => { + pageHiddenAt = Date.now(); +}); + +window.addEventListener('focus', () => { + recoverListSocket({ force: false }); +}); + +window.addEventListener('online', () => { + recoverListSocket({ force: hasActiveListRoom() }); +}); + +// Dodatkowe lifecycle events obsługiwane przez część przeglądarek mobilnych. +document.addEventListener('freeze', () => { + pageHiddenAt = Date.now(); +}); + +document.addEventListener('resume', () => { + recoverListSocket({ force: hasActiveListRoom() }); +}); + +// Watchdog łapie także wznowienia, przy których przeglądarka nie wyśle poprawnie +// visibilitychange/pageshow (np. po agresywnym uśpieniu procesu lub karty). +setInterval(() => { + const now = Date.now(); + const gap = now - lastWatchdogTick; + lastWatchdogTick = now; + + if (!document.hidden && gap >= WAKE_WATCHDOG_GAP_MS) { + recoverListSocket({ force: hasActiveListRoom() }); + } +}, WAKE_WATCHDOG_INTERVAL_MS); socket.on('connect', function () { - if (!firstConnect) { - //showToast('Połączono z serwerem!', 'info'); - disableCheckboxes(true); - wasReconnected = true; + const isReconnect = !firstConnect; + recoveryInProgress = false; - if (window.LIST_ID && window.usernameForReconnect) { - socket.emit('join_list', { room: window.LIST_ID, username: window.usernameForReconnect }); + if (hasActiveListRoom()) { + if (isReconnect) { + disableCheckboxes(true); + wasReconnected = true; } + rejoinCurrentList(); + + // Nie polegamy wyłącznie na joined_confirmation — po wake-up lista ma się + // zsynchronizować nawet jeśli pojedyncze zdarzenie zaginie. + setTimeout(requestCurrentList, 250); } + firstConnect = false; }); -socket.on('disconnect', function (reason) { - //showToast('Utracono połączenie z serwerem...', 'warning'); - disableCheckboxes(true); +socket.on('disconnect', function () { + if (window.LIST_ID) disableCheckboxes(true); +}); + +socket.on('connect_error', function () { + if (window.LIST_ID) disableCheckboxes(true); }); socket.off('joined_confirmation'); @@ -56,10 +180,7 @@ socket.on('joined_confirmation', function (data) { showToast(`Lista: ${data.list_title} – ponownie dołączono.`, 'info'); wasReconnected = false; } - if (window.LIST_ID) { - socket.emit('request_full_list', { list_id: window.LIST_ID }); - } - + requestCurrentList(); }); socket.on('user_joined', function (data) { @@ -126,6 +247,8 @@ socket.on('full_list', function (data) { window.currentItems = data.items; updateListSmoothly(data.items); + disableCheckboxes(false); + recoveryInProgress = false; if (typeof window.syncSortModeUI === 'function') { window.syncSortModeUI(); } diff --git a/shopping_app/static/js/toggle_button.js b/shopping_app/static/js/toggle_button.js index 4c68a28..9f5b6a2 100644 --- a/shopping_app/static/js/toggle_button.js +++ b/shopping_app/static/js/toggle_button.js @@ -1,30 +1,95 @@ document.addEventListener("DOMContentLoaded", function () { const toggleBtn = document.getElementById("tempToggle"); const hiddenInput = document.getElementById("temporaryHidden"); - if (!toggleBtn || !hiddenInput) return; - if (typeof bootstrap !== "undefined") { - new bootstrap.Tooltip(toggleBtn); - } + if (toggleBtn && hiddenInput) { + if (typeof bootstrap !== "undefined") { + new bootstrap.Tooltip(toggleBtn); + } - function updateToggle(isActive) { - toggleBtn.classList.toggle("is-active", isActive); - toggleBtn.textContent = isActive ? "Tymczasowa ✔" : "Tymczasowa"; - toggleBtn.setAttribute("aria-pressed", isActive ? "true" : "false"); - toggleBtn.setAttribute("title", isActive - ? "Lista tymczasowa będzie ważna przez 7 dni" - : "Po zaznaczeniu lista będzie ważna tylko 7 dni"); - } + function updateToggle(isActive) { + toggleBtn.classList.toggle("is-active", isActive); + toggleBtn.textContent = isActive ? "Tymczasowa ✔" : "Tymczasowa"; + toggleBtn.setAttribute("aria-pressed", isActive ? "true" : "false"); + toggleBtn.setAttribute("title", isActive + ? "Lista tymczasowa będzie ważna przez 7 dni" + : "Po zaznaczeniu lista będzie ważna tylko 7 dni"); + } - let active = toggleBtn.getAttribute("data-active") === "1"; - hiddenInput.value = active ? "1" : "0"; - updateToggle(active); - - toggleBtn.addEventListener("click", function (event) { - event.preventDefault(); - active = !active; - toggleBtn.setAttribute("data-active", active ? "1" : "0"); + let active = toggleBtn.getAttribute("data-active") === "1"; hiddenInput.value = active ? "1" : "0"; updateToggle(active); + + toggleBtn.addEventListener("click", function (event) { + event.preventDefault(); + active = !active; + toggleBtn.setAttribute("data-active", active ? "1" : "0"); + hiddenInput.value = active ? "1" : "0"; + updateToggle(active); + }); + } + + const dateToggle = document.getElementById("listDateToggle"); + const dateHidden = document.getElementById("listDateHidden"); + const datePicker = document.getElementById("listDatePicker"); + const dateModalEl = document.getElementById("listDateModal"); + const applyDateBtn = document.getElementById("applyListDateBtn"); + const clearDateBtn = document.getElementById("clearListDateBtn"); + + if (!dateToggle || !dateHidden || !datePicker || !dateModalEl || !applyDateBtn || !clearDateBtn) { + return; + } + + function localIsoDate(date = new Date()) { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; + } + + function displayDate(value) { + const parts = String(value || "").split("-"); + if (parts.length !== 3) return value; + return `${parts[2]}.${parts[1]}.${parts[0]}`; + } + + function updateDateButton() { + const value = dateHidden.value; + const hasDate = Boolean(value); + dateToggle.classList.toggle("is-active", hasDate); + dateToggle.textContent = hasDate ? "📅 Data ✓" : "📅 Data"; + dateToggle.setAttribute("aria-pressed", hasDate ? "true" : "false"); + dateToggle.setAttribute( + "aria-label", + hasDate ? `Data listy: ${displayDate(value)}` : "Wybierz datę listy" + ); + dateToggle.setAttribute( + "title", + hasDate ? `Przypisana data: ${displayDate(value)}` : "Przypisz listę do wybranego dnia" + ); + } + + function closeDateModal() { + if (typeof bootstrap === "undefined") return; + bootstrap.Modal.getOrCreateInstance(dateModalEl).hide(); + } + + dateModalEl.addEventListener("show.bs.modal", function () { + datePicker.value = dateHidden.value || localIsoDate(); }); + + applyDateBtn.addEventListener("click", function () { + dateHidden.value = datePicker.value || ""; + updateDateButton(); + closeDateModal(); + }); + + clearDateBtn.addEventListener("click", function () { + dateHidden.value = ""; + datePicker.value = ""; + updateDateButton(); + closeDateModal(); + }); + + updateDateButton(); }); diff --git a/shopping_app/templates/main.html b/shopping_app/templates/main.html index 4dec705..6f80900 100644 --- a/shopping_app/templates/main.html +++ b/shopping_app/templates/main.html @@ -38,7 +38,11 @@ aria-pressed="false" aria-label="Przełącz listę tymczasową"> Tymczasowa + + @@ -46,6 +50,27 @@ + + {% endif %} {% set month_names = ["styczeń","luty","marzec","kwiecień","maj","czerwiec","lipiec","sierpień","wrzesień","październik","listopad","grudzień"] %}