fix in rooms

This commit is contained in:
Mateusz Gruszczyński
2026-09-12 13:38:38 +02:00
parent 4ce74f23de
commit f37b19de53
7 changed files with 428 additions and 102 deletions
+15
View File
@@ -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')
+42 -35
View File
@@ -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")
+74
View File
@@ -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;
}
}
+27 -10
View File
@@ -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) {
+160 -37
View File
@@ -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();
}
+85 -20
View File
@@ -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();
});
+25
View File
@@ -38,7 +38,11 @@
aria-pressed="false" aria-label="Przełącz listę tymczasową">
<span class="create-list-temp-toggle__label">Tymczasowa</span>
</button>
<button type="button" class="btn btn-outline-secondary create-list-date-toggle" id="listDateToggle"
data-bs-toggle="modal" data-bs-target="#listDateModal" title="Przypisz listę do wybranego dnia"
aria-label="Wybierz datę listy">📅 Data</button>
<input type="hidden" name="temporary" id="temporaryHidden" value="0">
<input type="hidden" name="list_date" id="listDateHidden" value="">
</div>
<button type="submit" class="btn btn-success w-100"> Utwórz nową listę</button>
</form>
@@ -46,6 +50,27 @@
</div>
</section>
<div class="modal fade" id="listDateModal" tabindex="-1" aria-labelledby="listDateModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content bg-dark text-white">
<div class="modal-header">
<h5 class="modal-title" id="listDateModalLabel">📅 Data listy</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Zamknij"></button>
</div>
<div class="modal-body">
<label for="listDatePicker" class="form-label">Przypisz listę do dnia</label>
<input type="date" id="listDatePicker" class="form-control bg-dark text-white border-secondary">
<div class="form-text text-secondary mt-2">Opcjonalne. Bez wyboru lista dostanie bieżącą datę.</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" id="clearListDateBtn">Wyczyść</button>
<button type="button" class="btn btn-outline-light" data-bs-dismiss="modal">Anuluj</button>
<button type="button" class="btn btn-success" id="applyListDateBtn">Ustaw datę</button>
</div>
</div>
</div>
</div>
{% endif %}
{% set month_names = ["styczeń","luty","marzec","kwiecień","maj","czerwiec","lipiec","sierpień","wrzesień","październik","listopad","grudzień"] %}