diff --git a/docs/concepts/plan.md b/docs/concepts/plan.md
new file mode 100644
index 0000000..98df6b7
--- /dev/null
+++ b/docs/concepts/plan.md
@@ -0,0 +1,69 @@
+# MVP Plan: Doodle Addon for Plone
+
+A minimal Doodle-like addon for Plone with folderish and date content types, permissions, and a Bootstrap-styled doodle view with participant selection toggle.
+
+---
+
+## Completed MVP (All Tests Passing ✅)
+
+### Content Types
+- ✅ `experimental.doodle.folder` (folderish container type)
+- ✅ `experimental.doodle.date` (date item with participants set field)
+
+### Browser View & Interaction
+- ✅ Doodle view renders sorted dates in a responsive table
+- ✅ Participants displayed as Bootstrap badge pills
+- ✅ Toggle button for current user participation (POST-based with CSRF protection)
+- ✅ Participation redirect back to referrer or doodle view
+
+### UI/Styling
+- ✅ Bootstrap-compatible markup (card, table-striped, badge, btn-primary)
+- ✅ Responsive layout with table-responsive wrapper
+- ✅ Minimal custom CSS (badge flex-wrap, button nowrap)
+- ✅ Bundle registration via csscompilation field (Plone 6.x pattern)
+
+### Permissions & Access Control
+- ✅ Custom add permissions for both types
+- ✅ Browser layer registration active
+- ✅ CSRF token validation on participation toggle
+- ✅ Anonymous user rejection on toggle attempt
+
+### Tests (15 Passed)
+- ✅ 10 setup tests (install, uninstall, browser layer, permissions, type registration)
+- ✅ 1 testing profile test
+- ✅ 1 doodle view test (sorted dates rendering)
+- ✅ 1 participation toggle test
+- ✅ Code lint & quality (ruff, pyroma 10/10, zpretty, python-versions)
+
+---
+
+## Key Implementation Details
+
+**Files:**
+- `src/experimental/doodle/browser/doodle.pt` — Template with Bootstrap markup + tal:attributes for dynamic classes
+- `src/experimental/doodle/browser/doodle.py` — View with rows() and date_label() methods
+- `src/experimental/doodle/browser/participation.py` — ToggleParticipationView with CSRF check
+- `src/experimental/doodle/browser/static/doodle.css` — Minimal responsive utilities
+- `src/experimental/doodle/profiles/default/registry/main.xml` — Bundle registration
+
+**Key Decisions:**
+- Two separate types for structural clarity
+- `participants` field as set of user IDs (not explicit selection field)
+- POST-based toggle pattern for atomic updates
+- Bootstrap-first styling aligned with Plone 6.x defaults
+- explicit tal:attributes for class binding (not string interpolation)
+
+---
+
+## Next Atomic Steps (Post-MVP)
+
+1. **Results/Summary View** — Display vote counts per date (new view method, optional aggregation UI)
+2. **Control Panel** — Registry settings for participation notifications/preferences
+3. **Catalog Indexing** — Add doodle-specific indexes for efficient queries (if scaling)
+4. **Edge Case Tests** — Validate CSRF token reuse, anonymous rejection, data persistence
+5. **Advanced Features** — Date filtering, search, results export
+
+---
+
+## Session Status
+Session completed with all quality gates green. Ready for code review or feature expansion.
diff --git a/news/+d9b46371.feature.md b/news/+d9b46371.feature.md
new file mode 100644
index 0000000..68d3d07
--- /dev/null
+++ b/news/+d9b46371.feature.md
@@ -0,0 +1 @@
+Initial implementation
diff --git a/src/experimental/doodle/browser/configure.zcml b/src/experimental/doodle/browser/configure.zcml
index 6bf678a..58242fc 100644
--- a/src/experimental/doodle/browser/configure.zcml
+++ b/src/experimental/doodle/browser/configure.zcml
@@ -22,4 +22,22 @@
type="plone"
/>
+
+
+
+
+
diff --git a/src/experimental/doodle/browser/doodle.py b/src/experimental/doodle/browser/doodle.py
new file mode 100644
index 0000000..b33c84b
--- /dev/null
+++ b/src/experimental/doodle/browser/doodle.py
@@ -0,0 +1,47 @@
+from datetime import date
+
+import plone.api
+from Products.Five.browser import BrowserView
+
+
+class DoodleView(BrowserView):
+ """Render date options in a simple doodle-style table."""
+
+ def rows(self):
+ current = plone.api.user.get_current()
+ userid = current.getId() if current else None
+ items = [
+ obj
+ for obj in self.context.objectValues()
+ if obj.portal_type == "experimental.doodle.date"
+ ]
+
+ def sort_key(item):
+ value = getattr(item, "date", None)
+ if isinstance(value, date):
+ return value
+ return date.max
+
+ rows = []
+ for item in sorted(items, key=sort_key):
+ when = getattr(item, "date", None)
+ values = getattr(item, "participants", None)
+ if values is None:
+ values = getattr(item, "participants", set())
+ participants = sorted(values or set())
+ rows.append(
+ {
+ "title": item.Title(),
+ "date": when,
+ "participants": participants,
+ "selected": bool(userid and userid in participants),
+ "toggle_url": f"{item.absolute_url()}/@@toggle-participation",
+ "can_toggle": bool(userid),
+ }
+ )
+ return rows
+
+ def date_label(self, value):
+ if not isinstance(value, date):
+ return "-"
+ return value.strftime("%Y-%m-%d")
diff --git a/src/experimental/doodle/browser/participation.py b/src/experimental/doodle/browser/participation.py
new file mode 100644
index 0000000..9bee2bf
--- /dev/null
+++ b/src/experimental/doodle/browser/participation.py
@@ -0,0 +1,36 @@
+import plone.api
+
+from AccessControl import Unauthorized
+from Products.Five.browser import BrowserView
+from plone.protect.authenticator import check
+
+
+class ToggleParticipationView(BrowserView):
+ """Toggle the current user in and out of date participants."""
+
+ def __call__(self):
+ if self.request.get("REQUEST_METHOD", "GET") != "POST":
+ raise Unauthorized("Participation changes require POST")
+
+ check(self.request)
+
+ user = plone.api.user.get_current()
+ userid = user.getId() if user else None
+ if not userid:
+ raise Unauthorized("Login required")
+
+ values = getattr(self.context, "participants", None)
+ if values is None:
+ values = getattr(self.context, "participants", set())
+ values = set(values or set())
+ if userid in values:
+ values.remove(userid)
+ else:
+ values.add(userid)
+ self.context.participants = values
+
+ target = self.request.get("HTTP_REFERER")
+ if not target:
+ target = f"{self.context.aq_parent.absolute_url()}/@@doodle"
+ self.request.response.redirect(target)
+ return ""
diff --git a/src/experimental/doodle/browser/static/doodle.css b/src/experimental/doodle/browser/static/doodle.css
new file mode 100644
index 0000000..5f07ce6
--- /dev/null
+++ b/src/experimental/doodle/browser/static/doodle.css
@@ -0,0 +1,16 @@
+.doodle-date {
+ font-weight: 700;
+ white-space: nowrap;
+}
+
+.doodle-view {
+ border-radius: 0.5rem;
+}
+
+.doodle-view td .badge {
+ margin-right: 0.25rem;
+}
+
+.doodle-view button.btn {
+ white-space: nowrap;
+}
diff --git a/src/experimental/doodle/browser/templates/doodle.pt b/src/experimental/doodle/browser/templates/doodle.pt
new file mode 100644
index 0000000..bae46dd
--- /dev/null
+++ b/src/experimental/doodle/browser/templates/doodle.pt
@@ -0,0 +1,70 @@
+
+
+
+
+
+
Doodle
+
Pick the dates that work for you and compare availability at a glance.
+
+
+
+
+ | Date |
+ Option |
+ Participants |
+ Action |
+
+
+
+
+ | 2026-01-01 |
+ Date option |
+
+ No participants yet
+ member
+ |
+
+
+ |
+
+
+
+
+
No date options yet.
+
+
+
+
+
diff --git a/src/experimental/doodle/content/date.py b/src/experimental/doodle/content/date.py
new file mode 100644
index 0000000..f379772
--- /dev/null
+++ b/src/experimental/doodle/content/date.py
@@ -0,0 +1,9 @@
+from plone.dexterity.content import Item
+
+
+
+class ExperimentalDoodleDate(Item):
+ """Single date option for a doodle poll (MVP)."""
+
+ # Marker class for the experimental.doodle.date type
+ pass
diff --git a/src/experimental/doodle/content/folder.py b/src/experimental/doodle/content/folder.py
new file mode 100644
index 0000000..39e7c08
--- /dev/null
+++ b/src/experimental/doodle/content/folder.py
@@ -0,0 +1,6 @@
+from plone.dexterity.content import Container
+
+class ExperimentalDoodleFolder(Container):
+ """Folderish Doodle container (MVP)"""
+ # Marker for the folderish doodle type
+ pass
diff --git a/src/experimental/doodle/interfaces.py b/src/experimental/doodle/interfaces.py
index 7e2f288..716035c 100644
--- a/src/experimental/doodle/interfaces.py
+++ b/src/experimental/doodle/interfaces.py
@@ -1,7 +1,43 @@
"""Module where all interfaces, events and exceptions live."""
from zope.publisher.interfaces.browser import IDefaultBrowserLayer
+from plone.autoform import directives as form
+from plone.supermodel import model
+from zope import schema
class IBrowserLayer(IDefaultBrowserLayer):
"""Marker interface that defines a browser layer."""
+
+
+# MVP: Folderish Doodle container schema interface
+class IExperimentalDoodleFolder(model.Schema):
+ """Folderish Doodle container (MVP)"""
+ # Dublin Core fields (title, description) are included by default
+ form.order_after(description='title')
+ description = schema.Text(
+ title="Description",
+ required=False,
+ )
+ # Optionally, add a text field for body/content
+ body = schema.Text(
+ title="Body",
+ required=False,
+ )
+
+
+class IExperimentalDoodleDate(model.Schema):
+ """Single date option inside a Doodle folder (MVP)"""
+
+ date = schema.Date(
+ title="Date",
+ required=True,
+ )
+
+ form.write_permission(participants="zope2.View")
+ participants = schema.Set(
+ title="Participants",
+ description="User ids who selected this date.",
+ value_type=schema.TextLine(),
+ required=False,
+ )
diff --git a/src/experimental/doodle/permissions.zcml b/src/experimental/doodle/permissions.zcml
index e2bdd95..1366aa3 100644
--- a/src/experimental/doodle/permissions.zcml
+++ b/src/experimental/doodle/permissions.zcml
@@ -1,5 +1,13 @@
-
+
+
+
diff --git a/src/experimental/doodle/profiles.zcml b/src/experimental/doodle/profiles.zcml
index 465f9e7..2cef98b 100644
--- a/src/experimental/doodle/profiles.zcml
+++ b/src/experimental/doodle/profiles.zcml
@@ -20,6 +20,14 @@
directory="profiles/uninstall"
/>
+
+
-
+
+ ++plone++experimental.doodle/doodle.css
+
+
+
+ True
+ ++plone++experimental.doodle/doodle.css
+ False
+ False
+ False
+ plone
+
diff --git a/src/experimental/doodle/profiles/default/rolemap.xml b/src/experimental/doodle/profiles/default/rolemap.xml
index a803517..371ac52 100644
--- a/src/experimental/doodle/profiles/default/rolemap.xml
+++ b/src/experimental/doodle/profiles/default/rolemap.xml
@@ -1,6 +1,21 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/experimental/doodle/profiles/default/types.xml b/src/experimental/doodle/profiles/default/types.xml
index bed2b0d..af27ac3 100644
--- a/src/experimental/doodle/profiles/default/types.xml
+++ b/src/experimental/doodle/profiles/default/types.xml
@@ -2,9 +2,10 @@
diff --git a/src/experimental/doodle/profiles/default/types/experimental.doodle.date.xml b/src/experimental/doodle/profiles/default/types/experimental.doodle.date.xml
new file mode 100644
index 0000000..7807348
--- /dev/null
+++ b/src/experimental/doodle/profiles/default/types/experimental.doodle.date.xml
@@ -0,0 +1,66 @@
+
+
diff --git a/src/experimental/doodle/profiles/default/types/experimental.doodle.folder.xml b/src/experimental/doodle/profiles/default/types/experimental.doodle.folder.xml
new file mode 100644
index 0000000..13a74f0
--- /dev/null
+++ b/src/experimental/doodle/profiles/default/types/experimental.doodle.folder.xml
@@ -0,0 +1,70 @@
+
+
diff --git a/src/experimental/doodle/profiles/testing/experimental.doodle.testingdata.txt b/src/experimental/doodle/profiles/testing/experimental.doodle.testingdata.txt
new file mode 100644
index 0000000..665e9e0
--- /dev/null
+++ b/src/experimental/doodle/profiles/testing/experimental.doodle.testingdata.txt
@@ -0,0 +1 @@
+experimental.doodle testing data marker
diff --git a/src/experimental/doodle/profiles/testing/import_steps.xml b/src/experimental/doodle/profiles/testing/import_steps.xml
new file mode 100644
index 0000000..ecbc19f
--- /dev/null
+++ b/src/experimental/doodle/profiles/testing/import_steps.xml
@@ -0,0 +1,9 @@
+
+
+
+
diff --git a/src/experimental/doodle/profiles/testing/metadata.xml b/src/experimental/doodle/profiles/testing/metadata.xml
new file mode 100644
index 0000000..6072b36
--- /dev/null
+++ b/src/experimental/doodle/profiles/testing/metadata.xml
@@ -0,0 +1,7 @@
+
+
+ 1000
+
+ profile-experimental.doodle:default
+
+
diff --git a/src/experimental/doodle/setuphandlers/testing.py b/src/experimental/doodle/setuphandlers/testing.py
new file mode 100644
index 0000000..9056b61
--- /dev/null
+++ b/src/experimental/doodle/setuphandlers/testing.py
@@ -0,0 +1,67 @@
+from datetime import date
+from datetime import timedelta
+
+import plone.api
+
+
+TEST_USERNAMES = (
+ "alice",
+ "bob",
+ "carol",
+ "dave",
+)
+
+
+def _ensure_test_users(password="5upersecret!"):
+ for username in TEST_USERNAMES:
+ if plone.api.user.get(username=username):
+ continue
+ plone.api.user.create(
+ username=username,
+ email=f"{username}@example.com",
+ password=password,
+ roles=["Member"],
+ )
+
+
+def _seed_doodle_folders(portal, count=10):
+ base = date.today()
+ for idx in range(1, count + 1):
+ folder_id = f"test-{idx:02d}"
+ folder = getattr(portal, folder_id, None)
+ if folder is None:
+ folder = plone.api.content.create(
+ container=portal,
+ type="experimental.doodle.folder",
+ id=folder_id,
+ title=f"Test Doodle {idx:02d}",
+ description="Generated testing content",
+ body="Generated by testing GenericSetup profile.",
+ )
+
+ for option in range(1, 4):
+ option_id = f"date-{option}"
+ if getattr(folder, option_id, None) is not None:
+ continue
+ offset = (idx - 1) * 3 + option
+ participants = {
+ TEST_USERNAMES[(idx + option) % len(TEST_USERNAMES)],
+ }
+ plone.api.content.create(
+ container=folder,
+ type="experimental.doodle.date",
+ id=option_id,
+ title=f"Option {option}",
+ date=base + timedelta(days=offset),
+ participants=participants,
+ )
+
+
+def populate_testing_data(context):
+ if context.readDataFile("experimental.doodle.testingdata.txt") is None:
+ return
+
+ portal = context.getSite()
+ with plone.api.env.adopt_roles(["Manager"]):
+ _ensure_test_users(password="5upersecret!")
+ _seed_doodle_folders(portal, count=10)
diff --git a/tests/setup/test_setup_install.py b/tests/setup/test_setup_install.py
index 53b02c0..0e66c1b 100644
--- a/tests/setup/test_setup_install.py
+++ b/tests/setup/test_setup_install.py
@@ -15,3 +15,38 @@ def test_browserlayer(self, browser_layers):
def test_latest_version(self, profile_last_version):
"""Test latest version of default profile."""
assert profile_last_version(f"{PACKAGE_NAME}:default") == "1000"
+
+ def test_folder_type_installed(self, portal):
+ """Test folder content type is registered."""
+ assert "experimental.doodle.folder" in portal.portal_types
+
+ def test_folder_type_add_permission(self, portal):
+ """Test folder content type uses custom add permission."""
+ fti = portal.portal_types["experimental.doodle.folder"]
+ assert fti.add_permission == "experimental.doodle.AddDoodleFolder"
+
+ def test_date_type_installed(self, portal):
+ """Test date content type is registered."""
+ assert "experimental.doodle.date" in portal.portal_types
+
+ def test_date_type_add_permission(self, portal):
+ """Test date content type uses custom add permission."""
+ fti = portal.portal_types["experimental.doodle.date"]
+ assert fti.add_permission == "experimental.doodle.AddDoodleDate"
+
+ def test_folder_allows_only_date_type(self, portal):
+ """Test folder is configured to accept only date items."""
+ fti = portal.portal_types["experimental.doodle.folder"]
+ assert fti.filter_content_types is True
+ assert fti.allowed_content_types == ("experimental.doodle.date",)
+
+ def test_folder_default_view_is_doodle(self, portal):
+ """Test folder type defaults to the doodle layout."""
+ fti = portal.portal_types["experimental.doodle.folder"]
+ assert fti.default_view == "doodle"
+ assert fti.immediate_view == "doodle"
+
+ def test_date_type_not_global(self, portal):
+ """Test date type is not globally addable."""
+ fti = portal.portal_types["experimental.doodle.date"]
+ assert fti.global_allow is False
diff --git a/tests/setup/test_setup_testing_profile.py b/tests/setup/test_setup_testing_profile.py
new file mode 100644
index 0000000..c6b6542
--- /dev/null
+++ b/tests/setup/test_setup_testing_profile.py
@@ -0,0 +1,27 @@
+from plone.app.testing import applyProfile
+
+import plone.api
+
+
+class TestTestingProfile:
+ def test_testing_profile_creates_users_and_doodles(self, portal):
+ applyProfile(portal, "experimental.doodle:testing")
+
+ for username in ("alice", "bob", "carol", "dave"):
+ assert plone.api.user.get(username=username) is not None
+
+ doodles = [
+ obj
+ for obj in portal.objectValues()
+ if obj.portal_type == "experimental.doodle.folder" and obj.getId().startswith("test-")
+ ]
+ assert len(doodles) == 10
+
+ first = portal.get("test-01")
+ assert first is not None
+ options = [
+ obj
+ for obj in first.objectValues()
+ if obj.portal_type == "experimental.doodle.date"
+ ]
+ assert len(options) == 3
diff --git a/tests/test_doodle_view.py b/tests/test_doodle_view.py
new file mode 100644
index 0000000..c971a71
--- /dev/null
+++ b/tests/test_doodle_view.py
@@ -0,0 +1,40 @@
+from datetime import date
+
+import plone.api
+
+
+class TestDoodleView:
+ def test_doodle_view_lists_sorted_dates(self, portal, request):
+ with plone.api.env.adopt_roles(["Manager"]):
+ folder = plone.api.content.create(
+ container=portal,
+ type="experimental.doodle.folder",
+ id="poll",
+ title="Poll",
+ )
+
+ plone.api.content.create(
+ container=folder,
+ type="experimental.doodle.date",
+ id="late",
+ title="Late option",
+ date=date(2026, 6, 1),
+ participants={"bob"},
+ )
+ plone.api.content.create(
+ container=folder,
+ type="experimental.doodle.date",
+ id="early",
+ title="Early option",
+ date=date(2026, 5, 1),
+ participants={"alice", "bob"},
+ )
+
+ view = folder.restrictedTraverse("@@doodle")
+ html = view()
+
+ assert "Early option" in html
+ assert "Late option" in html
+ assert html.index("Early option") < html.index("Late option")
+ assert "alice" in html
+ assert "bob" in html
diff --git a/tests/test_participation.py b/tests/test_participation.py
new file mode 100644
index 0000000..f39db56
--- /dev/null
+++ b/tests/test_participation.py
@@ -0,0 +1,40 @@
+from datetime import date
+
+import plone.api
+from plone.protect.authenticator import createToken
+
+
+class TestParticipation:
+ def test_member_can_toggle_own_participation(self, portal):
+ with plone.api.env.adopt_roles(["Manager"]):
+ plone.api.user.create(
+ username="alice",
+ email="alice@example.com",
+ roles=["Member"],
+ )
+ folder = plone.api.content.create(
+ container=portal,
+ type="experimental.doodle.folder",
+ id="poll-toggle",
+ title="Toggle Poll",
+ )
+ option = plone.api.content.create(
+ container=folder,
+ type="experimental.doodle.date",
+ id="date1",
+ title="Date 1",
+ date=date(2026, 9, 1),
+ participants=set(),
+ )
+ option.manage_setLocalRoles("alice", ["Owner"])
+ option.reindexObjectSecurity()
+
+ with plone.api.env.adopt_user(username="alice"):
+ option.REQUEST.environ["REQUEST_METHOD"] = "POST"
+ option.REQUEST.form["_authenticator"] = createToken()
+ option.restrictedTraverse("@@toggle-participation")()
+ assert "alice" in option.participants
+
+ option.REQUEST.form["_authenticator"] = createToken()
+ option.restrictedTraverse("@@toggle-participation")()
+ assert "alice" not in option.participants