Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 32 additions & 14 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,16 @@
All notable changes to CheckCheck are recorded here. The format is loosely based
on [Keep a Changelog](https://keepachangelog.com/); dates are ISO-8601.

## [2.0.0] — unreleased
## [1.0.0] - 2026-09-19

The **offline / local-first** release. CheckCheck now works while disconnected:
edits are applied locally, queued, and synced back when the connection returns,
and the app is installable as a PWA.
The first stable release. This entry collects everything since the project
started, including the `0.x` pre-releases, which had no changelog entries of
their own. The headline is **offline / local-first**: CheckCheck works while
disconnected, applies edits locally, queues them, and syncs them back when the
connection returns, and it installs as a PWA.

Older planning documents under `docs/` call this milestone "2.0". That was an
internal working name; the released version is 1.0.0.

### Added

Expand All @@ -29,11 +34,11 @@ and the app is installable as a PWA.
bounce you to `/login`.
- **Soft delete (tombstones)** for checklists, items, and labels so deletions
propagate correctly through the delta feed.
- **Bulk item actions** in a card's ⋮ menu — **Untick all items** and **Delete
ticked items** — each a single offline-safe operation (one dedicated endpoint,
- **Bulk item actions** in a card's ⋮ menu — **Uncheck all items** and **Delete
checked items** — each a single offline-safe operation (one dedicated endpoint,
one outbox op) rather than a per-item fan-out, so they work on the whole card
even when only a preview is loaded and reach collaborators through the normal
delta feed. Deleting ticked items asks for confirmation.
delta feed. Deleting checked items asks for confirmation.
- **Markdown card notes.** The card description (the "notes" field) now renders
as Markdown on the board preview, in the open card when you are not editing, on
the public share page, and for view-only collaborators. Inside an open card the
Expand Down Expand Up @@ -118,7 +123,7 @@ and the app is installable as a PWA.
never reached it. It now renders from the same components the open card does, and
gains everything that had drifted: the **"separate checked items" layout** with
its collapsible checked section (the reported symptom: a visitor could neither
see checked items grouped nor untick them), **Markdown-rendered item text**,
see checked items grouped nor uncheck them), **Markdown-rendered item text**,
the card's **colour theme**, and, on an `edit` link, **drag-reordering items**
and editing the card's **title and notes**. Whether the checked section is
collapsed is stored on the card, not per visitor, so a `check`-or-better link
Expand Down Expand Up @@ -153,7 +158,7 @@ and the app is installable as a PWA.

### Changed

- **Local-first is on by default.** Self-hosters who want the pre-2.0 online-only
- **Local-first is on by default.** Self-hosters who want the old online-only
behaviour can opt out per-deploy with `NUXT_PUBLIC_LOCAL_FIRST=false`. A
`?localFirst=0` query param / localStorage override also works for one-off
debugging.
Expand All @@ -162,9 +167,22 @@ and the app is installable as a PWA.
- **The in-app notification feed is pruned.** Notifications that have been read
are removed after `NOTIFY_FEED_RETENTION_DAYS` (default 180; set it to 0 to
keep everything). Unread notifications are never pruned.
- **The card editor on phones is full screen with a back button.** Below tablet
width the open card fills the screen with a header bar: a back arrow on the
left, the pin and sync state on the right, the title underneath. It stays
usable while typing: the editor ends at the on-screen keyboard instead of
sliding under it, so the back arrow and the title stay reachable. Item
suggestions ("Uncheck ...") no longer hide behind the keyboard; the list
scrolls into view as it appears. A very long title is capped at four lines
and scrolls inside itself. The desktop editor is unchanged.

### Fixed

- **No more "also edited elsewhere" message for your own edits.** Your own save
comes back to your device through the sync feed. If you kept typing in the
meantime, the returning value no longer matched what was on screen, and the app
reported it as someone else's edit. The client now remembers the values it
sent and warns only about values it did not send.
- **Editing a card's notes no longer drops the title you just typed.** The open
card sent both fields through one shared 500ms timer, so touching the notes
within half a second of the title replaced the title's pending write and it
Expand Down Expand Up @@ -230,11 +248,11 @@ and the app is installable as a PWA.

### Upgrade notes

See [`docs/UPGRADING.md`](docs/UPGRADING.md). In short: there are **no production
instances yet**, so 2.0 ships a squashed migration baseline — **recreate any
pre-2.0 development database** (the schema is built with `create_all`, which does
not alter existing tables). From 2.0 on, schema changes ship as real Alembic
revisions.
See [`docs/UPGRADING.md`](docs/UPGRADING.md). In short: the early migration
history was squashed into baseline `0010`, so a **development database from
before that baseline must be recreated once** (the schema is built with
`create_all`, which does not alter existing tables). Every schema change after
it ships as a real Alembic revision and is applied at startup.

Email, the public-link invitation and webhooks are all **off by default**, so an
existing deployment behaves exactly as before until an operator turns them on.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ async def update_public_checklist(
The route guard is ``check`` because that is the lowest level allowed to write
anything here: ``checked_items_collapsed`` is a display flag stored on the card
(not per-user), and a check link already writes to the card every time somebody
ticks a box. ``name`` and ``text`` are content, so they need ``edit``, checked
checks a box. ``name`` and ``text`` are content, so they need ``edit``, checked
below (*before* the update runs, so a check link can never partially apply).
"""
fields = body.model_fields_set
Expand Down
2 changes: 1 addition & 1 deletion CheckCheck/backend/checkcheckserver/db/checklist_item.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ async def get(
return obj

async def delete_checked_items(self, checklist_id: uuid.UUID) -> int:
"""Bulk "delete ticked": soft-delete (tombstone) every checked, live item
"""Bulk "delete checked": soft-delete (tombstone) every checked, live item
of this checklist in a single transaction.

MUST mutate ORM objects in a loop (not a Core ``DELETE`` / bulk
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ async def list_from_checklist_and_create_missing_states(
return all_states

async def uncheck_all_items(self, checklist_id: uuid.UUID) -> int:
"""Bulk "untick all": flip every checked, live item of this checklist to
"""Bulk "uncheck all": flip every checked, live item of this checklist to
unchecked in a single transaction.

MUST mutate ORM objects in a loop (not a Core ``UPDATE ... SET``): the
Expand Down
4 changes: 2 additions & 2 deletions CheckCheck/backend/checkcheckserver/notify/invitation.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,9 @@ def permission_sentence(permission: SharePermission | str) -> str:
"""
value = permission.value if isinstance(permission, SharePermission) else str(permission)
if value == SharePermission.edit.value:
return "add, change and tick off items on this list, without signing in"
return "add, change and check off items on this list, without signing in"
if value == SharePermission.check.value:
return "tick items off this list, without signing in"
return "check items off this list, without signing in"
return "read this list, without signing in"


Expand Down
4 changes: 2 additions & 2 deletions CheckCheck/backend/tests/tests_bulk_item_ops.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ def test_uncheck_all_requires_check_permission():
cl_id = req("api/checklist", "post", b={"name": "uncheck perms"})["id"]
_new_item(cl_id, "a", checked=True)

# A view collaborator cannot untick all.
# A view collaborator cannot uncheck all.
_share(cl_id, viewer_id, "view")
req(
f"api/checklist/{cl_id}/items/uncheck-all",
Expand All @@ -204,7 +204,7 @@ def test_delete_checked_requires_edit_permission():
cl_id = req("api/checklist", "post", b={"name": "delete perms"})["id"]
_new_item(cl_id, "a", checked=True)

# A check collaborator cannot delete ticked items (needs edit).
# A check collaborator cannot delete checked items (needs edit).
_share(cl_id, collab_id, "check")
req(
f"api/checklist/{cl_id}/items/delete-checked",
Expand Down
2 changes: 1 addition & 1 deletion CheckCheck/backend/tests/tests_public_link_email.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ def test_the_message_names_the_card_the_sender_and_the_personal_note(
assert sender.name in mail[0].text_body
assert "the one we talked about on Friday" in mail[0].text_body
# The level is stated in words the recipient can act on, not as an enum.
assert "tick items off this list, without signing in" in mail[0].text_body
assert "check items off this list, without signing in" in mail[0].text_body
# It is an invitation, not a subscription: nothing to unsubscribe from.
assert "List-Unsubscribe" not in mail[0].headers

Expand Down
2 changes: 1 addition & 1 deletion CheckCheck/frontend/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ These are things that exist in the UI but don't work yet.
Core missing feature — sharing is entirely absent. No UI, no flows, the Share button in the footer is a stub that does nothing.

- **Share a board** – Generate a shareable link for a checklist (read-only or edit access).
- **Access levels** – At minimum: view-only vs. full-edit. Ideally also a "check-off only" mode for guests who should only be able to tick items, not edit text.
- **Access levels** – At minimum: view-only vs. full-edit. Ideally also a "check-off only" mode for guests who should only be able to check items, not edit text.
- **Manage collaborators** – List who has access, change their permission level, revoke access.
- **Accept an invitation** – Flow for a recipient: open a share link → sign in or continue as guest → see the shared board.
- **Shared boards in the sidebar** – Distinguish boards you own from boards shared with you or board you share. Lets have a filter similar to the labels.
Expand Down
4 changes: 3 additions & 1 deletion CheckCheck/frontend/components/CardParts/ItemRow.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
<template>
<div>
<div class="scroll-mb-16">
<!-- scroll-mb-16: a focused row (plus its suggestion list) keeps some air
above the on-screen keyboard when it is scrolled into view. -->
<div class="checklist-item-row flex items-start gap-1.5 py-0.5" data-testid="item-row" @mouseover="hover = true" @mouseleave="hover = false">
<span
v-if="showHandle"
Expand Down
40 changes: 31 additions & 9 deletions CheckCheck/frontend/components/CheckList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,30 +4,49 @@
:style="cardStyle"
:class="[
hasColor ? '' : 'bg-elevated',
previewModeActive ? 'p-3 sm:p-4 sm:min-h-32 shadow-sm hover:shadow-md hover:ring-1 hover:ring-default cursor-pointer' : 'p-5 sm:p-6',
editModeActive ? 'max-h-[92dvh] overflow-hidden' : '',
previewModeActive ? 'p-3 sm:p-4 sm:min-h-32 shadow-sm hover:shadow-md hover:ring-1 hover:ring-default cursor-pointer' : fullscreen ? '' : 'p-5 sm:p-6',
editModeActive && !fullscreen ? 'max-h-[92dvh] overflow-hidden' : '',
fullscreen
? 'h-full overflow-hidden rounded-none border-0 px-4 pt-[max(0.25rem,env(safe-area-inset-top))] pb-[max(0.75rem,env(safe-area-inset-bottom))]'
: 'border border-default rounded-xl',
]"
class="checklist group/card relative list-drag-handle textareas-inherit-color flex flex-col gap-1 border border-default rounded-xl transition-shadow"
class="checklist group/card relative list-drag-handle textareas-inherit-color flex flex-col gap-1 transition-shadow"
>

<!-- In the editor the modal renders its own close button at top-right, so
shift the pin left of it; on board previews there is no close button. -->
<CheckListFooterButtonPin :checkListId="checkListId" :scrollIntoViewOnPin="previewModeActive" :class="['absolute top-2 z-10', editModeActive ? 'right-10' : 'right-2']" />
<!-- Offline-write indicator (WI-11): lights up while this card has queued ops. -->
<CheckListSyncIndicator :checkListId="checkListId" />
<!-- Full-screen editor on phones (mobile editor M2): a header bar with the
caller's close control on the left (slot) and pin plus sync state on
the right, all 44px targets. Nothing here is absolutely positioned. -->
<div v-if="fullscreen" data-testid="editor-header" class="flex-none flex items-center gap-2 -mx-2">
<slot name="header-start" />
<div class="flex-1" />
<CheckListSyncIndicator :checkListId="checkListId" inline />
<CheckListFooterButtonPin
:checkListId="checkListId"
class="size-11 justify-center rounded-full"
/>
</div>
<template v-else>
<!-- In the editor the modal renders its own close button at top-right, so
shift the pin left of it; on board previews there is no close button. -->
<CheckListFooterButtonPin :checkListId="checkListId" :scrollIntoViewOnPin="previewModeActive" :class="['absolute top-2 z-10', editModeActive ? 'right-10' : 'right-2']" />
<!-- Offline-write indicator (WI-11): lights up while this card has queued ops. -->
<CheckListSyncIndicator :checkListId="checkListId" />
</template>

<div v-if="!editModeActive" data-testid="card-title" class="flex-none pr-8 text-base font-semibold leading-snug break-words line-clamp-2" v-html="highlightText(checkList!.name, searchQuery)" />
<UTextarea
v-if="editModeActive"
:autofocus="autofocusTitle"
autoresize
:maxrows="4"
variant="none"
:rows="0"
:padded="false"
:disabled="!canEdit"
placeholder="Enter a checklist title..."
v-model="localName"
class="flex-none w-full pr-16 text-xl sm:text-2xl font-semibold"
:class="fullscreen ? '' : 'pr-16'"
class="flex-none w-full text-xl sm:text-2xl font-semibold"
@focus="onFieldFocus('name')"
@blur="onFieldBlur('name')"
/>
Expand Down Expand Up @@ -136,6 +155,9 @@ const props = defineProps({
},
editModeActive: { type: Boolean, default: false },
previewModeActive: { type: Boolean, default: false },
// Full-screen editor on phones (mobile editor M2): render the header bar
// (slot `header-start` plus pin and sync state) instead of the floating pin.
fullscreen: { type: Boolean, default: false },
});

// Autofocus the title only when this editor is opening a card that was JUST
Expand Down
47 changes: 41 additions & 6 deletions CheckCheck/frontend/components/CheckListEditModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,18 @@
<UModal
v-model:open="open"
title="Opened Checklist Editor"
:ui="{
content: 'max-w-2xl w-[calc(100vw-1rem)] sm:w-full max-h-[92dvh] rounded-2xl ring ring-default overflow-hidden',
}"
:fullscreen="isMobile"
:ui="{ content: isMobile ? MOBILE_CONTENT_CLASS : DESKTOP_CONTENT_CLASS }"
>
<template #content>
<div class="relative flex flex-col max-h-[92dvh] overflow-hidden overscroll-contain">
<!-- Explicit close affordance (the editor otherwise fills the modal). -->
<div
:class="isMobile ? 'h-full' : 'max-h-[92dvh]'"
class="relative flex flex-col overflow-hidden overscroll-contain"
>
<!-- Explicit close affordance (the editor otherwise fills the modal).
On phones the editor's own header bar carries a back arrow instead. -->
<UButton
v-if="!isMobile"
icon="i-lucide-x"
color="neutral"
variant="ghost"
Expand All @@ -22,7 +26,21 @@
mount), so it needs its own Suspense boundary. Keyed by id so
reopening a different card always remounts with fresh data. -->
<Suspense>
<CheckList :key="checkListId" :checkListId="checkListId" :editModeActive="true" />
<CheckList :key="checkListId" :checkListId="checkListId" :editModeActive="true" :fullscreen="isMobile">
<template #header-start>
<UButton
icon="i-lucide-arrow-left"
color="neutral"
variant="ghost"
aria-label="Close"
data-testid="editor-back"
class="size-11 justify-center rounded-full"
:ui="{ leadingIcon: 'size-6' }"
:style="{ color: 'inherit' }"
@click="open = false"
/>
</template>
</CheckList>
<template #fallback>
<div class="flex items-center justify-center min-h-48 p-10 text-muted">
<UIcon name="i-lucide-loader-circle" class="size-6 animate-spin" />
Expand All @@ -35,8 +53,25 @@
</template>

<script setup lang="ts">
import { useMediaQuery } from "@vueuse/core";

const open = defineModel<boolean>("open", { default: false });

// Mirror the visual viewport into --vv-height/--vv-top while the editor is
// open, so it can size itself against the on-screen keyboard (mobile editor
// plan, M1). The modal stays mounted after closing, hence the getter.
useVisualViewport(() => open.value);

// Below `sm` the editor is a full-screen page with a header bar (mobile editor
// plan, M2). It is pinned to the visual viewport, so with the keyboard open it
// ends at the keyboard edge instead of reaching behind it. The variables are
// removed when nothing holds them, hence the fallbacks.
const isMobile = useMediaQuery("(max-width: 639px)");
const DESKTOP_CONTENT_CLASS =
"max-w-2xl w-[calc(100vw-1rem)] sm:w-full max-h-[92dvh] rounded-2xl ring ring-default overflow-hidden";
const MOBILE_CONTENT_CLASS =
"top-[var(--vv-top,0px)] bottom-auto h-[var(--vv-height,100dvh)] w-full max-w-none rounded-none overflow-hidden";

defineProps({
checkListId: {
type: String,
Expand Down
Loading
Loading