The user is asking two good questions:

1. Separate folder - confirmed, easy answer.

2. The spreadsheet question is more nuanced. Let me think through this carefully:

**Scale considerations:**
- Waukee APEX has multiple cohorts across many courses
- Students can take multiple APEX courses over their career (multiple years)
- 17 courses across 5 sectors
- A student in DCS might also take BSU, DWT, etc.

**The portfolio question:**
The user raises a real design question: does a student-associate have ONE portfolio across their entire APEX career, or a separate portfolio per course?

I'm weighing the tradeoffs here. One unified portfolio makes sense from a professional identity angle—the standards like Servant Leadership apply universally, and it tells a more cohesive growth story across multiple courses. But Standard 4 (Technical Skills) is inherently course-specific, so a DCS student's artifacts look nothing like an Auto student's, which pushes toward separate portfolios per course.

The solution I'm landing on is a hybrid: one portfolio per student-associate with course-scoped Technical Skills and context tags on artifacts. This mirrors real professional portfolios where you have one collection but can organize items by role or experience. The precedent already exists in v1 with separate Standard 4 variants for BSU and DCS.

Now I'm thinking through the actual scale—Waukee APEX probably has 100-200 student-associates across all cohorts at any given time, with each student generating 20-50 artifacts.

Looking at the numbers: 500 students × 50 artifacts gives 25,000 artifact rows, plus 500 students × 100 check-ins (roughly 2-3 per week over 36 weeks) adds another 50,000 rows. Google Sheets can technically handle this—50,000 rows × 10 columns is only 500,000 cells out of a 10 million limit. But the real bottleneck is query performance. Apps Script gets sluggish reading large datasets, especially when an instructor dashboard needs to aggregate across all student data every time it loads.

I'm considering splitting data by school year to keep individual sheets manageable, which would improve query speed significantly. One permanent Spreadsheet organized by school year with proper indexing columns (school_year, course_id, user_id) makes sense, and I should keep aggregated stats in a separate sheet to avoid expensive full-table scans on the instructor dashboard. For Waukee APEX's scale of a few hundred students, this approach will work fine even as the data grows.

The real design question is whether each student gets one career-spanning portfolio or separate portfolios per course. A single portfolio per student-associate better captures the "aspiring professional" identity and lets standards 1-3, 5 accumulate across all their courses, while standard 4 stays course-specific with a course_id tag on the artifacts.

Option C—one portfolio per student with course-scoped instructor views—feels like the right balance. It preserves the career narrative a real professional portfolio needs while keeping instructor dashboards clean and filtered to just their cohort. Standard 4 can have separate sections per course like it does now, and artifacts get tagged with their course context.

At Waukee APEX's scale—roughly 15,000 artifact rows and 30,000 check-in rows—Google Sheets handles this indefinitely; the real bottleneck is scanning all rows to build instructor dashboards, which I'd solve with a separate pre-aggregated stats sheet rather than switching databases. I'd add a school year column for filtering and a course_id column on artifacts so instructors see only their students' work.

But before building, I need to nail down whether we're designing a career-spanning portfolio that follows students across years or separate portfolios per course enrollment—that's the foundational decision that shapes everything else, and the hybrid approach of keeping one permanent spreadsheet with year-based filtering probably handles both.


Good instincts on both. Here's the honest assessment:

---

**On spreadsheet scale** — at Waukee APEX's size, one permanent Spreadsheet will be fine for a very long time. Even at 500 student-associates × 50 artifacts × 4 years each, you're looking at maybe 100,000 rows total. Google Sheets handles that without breaking a sweat. The real performance risk isn't row count — it's the instructor dashboard doing full-table scans every time it loads. The solution to that is a separate `stats` sheet that stores pre-aggregated data and updates whenever an artifact is saved, so the dashboard reads 50 rows instead of 50,000. Manageable, and we'd build it that way from the start.

---

**The bigger question your #2 actually raises:**

Does a student-associate have **one portfolio across their entire APEX career**, or **a separate portfolio per course**?

This is the real design decision before we build anything. Here's how I see the options:

**Option A — One portfolio, career-spanning**
Standards 1, 2, 3, and 5 accumulate across every course they take. A student-associate in DCS who later takes BSU builds on the same servant leadership and professional skills evidence — because that's how a real portfolio works. Standard 4 (technical skills) is tagged to the specific course since those artifacts are genuinely different per pathway.

**Option B — One portfolio per enrollment**
Clean instructor boundaries. Simple data model. But a student-associate who takes three APEX courses has three disconnected portfolios and no career narrative. Breaks the "aspiring professional" identity.

**Option C — One portfolio, course-scoped instructor view**
Same as Option A, but the instructor dashboard filters to their course and cohort. They see only their student-associates' work. Standard 4 works like it does in v1 — separate sections per pathway. This is probably the right answer.

---

My recommendation is **Option C**. A portfolio is a career document, not a course document. The "aspiring professional" framing only makes sense if the portfolio grows with them across their time in APEX — not if it resets every semester.

That said, it does add some complexity: when a student-associate is in both your DCS class and Tyler's Auto class simultaneously, both instructors need scoped views of the same portfolio. That's solvable — it just needs to be decided before we design the data model.

**Confirm Option C and we're ready to start Phase 1.**


I can confirm option C.


The user has confirmed Option C. Now I should start planning the actual build. But wait - the user said "Let's plan it out first" and I gave them a plan. They've confirmed the architecture decisions. Now they're confirming Option C which was the last piece needed.

So now we have:
- ✅ Separate v2/ folder
- ✅ One permanent Spreadsheet
- ✅ Option C: one portfolio, course-scoped instructor views
- ✅ Google Sheets backend
- ✅ No notifications
- ✅ Auth stub pattern
- ✅ Drag-and-drop (with URL fallback before SSO)

- ✅ Drive folder creation (SSO-dependent)

We've finished the planning phase, so I should confirm they're ready to start building and ask what they want to tackle first in Phase 1, or just lay out the concrete next steps.

Given that it's the end of the school year with two weeks left, I should acknowledge they're busy and ask whether they want to start now or wait until after things wrap up. I'll also save this v2 plan to memory since it's a significant project decision.


Good. Everything is locked:

- **v2/ subfolder** — v1 stays untouched
- **One permanent Spreadsheet** — `school_year` and `course_id` columns on all rows, pre-aggregated stats sheet for instructor dashboard performance
- **Option C** — one career-spanning portfolio per student-associate, course-scoped instructor views, Standard 4 stays pathway-specific

---

Phase 1 concretely means: create the Spreadsheet, build the Apps Script API, write the auth stub, and update one page end-to-end as the reference implementation — probably the Portfolio home or Standard 1 — so we have a proven pattern before touching everything else.

---

One honest question before we start: you have two weeks of school left. Do you want to start building now, or pick this up once the year ends and you have mental space for it? This is a focused multi-session build and there's no deadline pressure — v2 doesn't need to be ready until August.

Either way, I'll save the architecture decisions to memory now so we don't lose them.


Write

---
name: project-portfolio-v2
description: Architecture decisions and build plan for APEX Portfolio v2.0 — SSO-ready, Google Sheets backend, instructor dashboard
metadata:
  type: project
---

v2.0 of the APEX Portfolio system. Building with SSO in mind using an auth stub, so when SSO is approved only one function needs to change.

**Why:** Move from localStorage to a real backend, enable instructor dashboards, heat maps, artifact repository, cross-device sync, and Google Drive folder creation on first login.

**Target launch:** Start of 2026–27 school year (August 2026).

## Key Architecture Decisions

**Auth stub pattern:** Single `getCurrentUser()` function returns hardcoded test identity now. Swap for real Google OAuth when SSO approved. Supports `localStorage` override for testing both roles (student-associate / instructor).

**Backend:** One Google Spreadsheet (permanent, not per-year). `school_year` and `course_id` columns on all rows. Pre-aggregated `stats` sheet for instructor dashboard so it doesn't scan full tables. Apps Script web app as API layer — same pattern as BPRM and beta test form.

**Portfolio identity (Option C):** One portfolio per student-associate, career-spanning. Standards 1, 2, 3, 5 accumulate across all courses. Standard 4 is pathway-specific (tagged to course). Instructor dashboard filters to their course/cohort. A student in multiple APEX courses simultaneously shows in all relevant instructors' views.

**Folder structure:** v2 lives in a separate `v2/` subfolder during development. When ready, replaces v1 files entirely. v1 stays untouched until launch.

**Roster timing:** Rosters tab in Spreadsheet exists from day one but stays empty until August. Instructor dashboard shows graceful empty states. Manual CSV import when rosters are available; auto-sync when SSO lands.

## Spreadsheet Schema

Sheet tabs:
- `users` — id, name, email, role, strand, instructor_id, drive_folder_id, created_at
- `artifacts` — id, user_id, standard, artifact_id, title, link, reflection, done, opted_into_repo, course_id, school_year, updated_at
- `checkins` — id, user_id, date, project, worked, next, blockers, artifact_std, artifact_id, course_id, school_year, created_at
- `playbook_progress` — id, user_id, playbook, project_name, state_json, course_id, school_year, updated_at
- `rosters` — instructor_id, student_id, course_id, school_year
- `repository` — moderated opt-in anonymized artifact submissions
- `stats` — pre-aggregated instructor dashboard data (updated on every artifact save)

## Features by Phase

**Phase 1 — Foundation**
- Auth stub + getCurrentUser()
- Apps Script backend + all Sheets tabs
- Data sync layer (Sheets primary, localStorage as offline cache)
- All existing pages updated to use backend
- Reference implementation: Portfolio home + Standard 1

**Phase 2 — Enhanced Portfolio**
- Drag-and-drop UI on artifact cards (URL paste fallback; actual upload needs SSO)
- Shareable read-only portfolio link
- Artifact opt-in toggle for repository

**Phase 3 — Instructor Dashboard**
- Roster management UI (manual entry for now)
- Heat map per Standard (artifact cards colored by completion % across class)
- Individual student-associate progress view

**Phase 4 — Artifact Repository**
- Browse by Standard + artifact type
- Moderation queue (Russ approves before anything goes public)
- Submission flow from artifact cards

**Phase 5 — SSO Activation (when approved)**
- Swap getCurrentUser() for Google OAuth
- Enable Drive folder creation on first login
- Enable actual file upload on drag-and-drop
- Roster auto-sync if Google Workspace allows

## Features Deferred

- Notifications (v2.X, not v2.0)
- Gamification: must be minimal and opt-IN if ever added — see [[feedback-gamification]]

## Drive Folder Structure (SSO-dependent)

Created on first login if drive_folder_id is null in users sheet:
```
My Drive/
└── APEX Portfolio/
    ├── S1 — Servant Leadership/
    │   └── (one subfolder per artifact card)
    ├── S2 — Beyond GPA/
    ├── S3 — Career Exploration/
    ├── S4 — Technical Skills/
    └── S5 — Professional Skills/
```
Root folder ID saved to users sheet. Drag-and-drop uploads to matching artifact subfolder.

**How to apply:** When building any v2 feature, check this schema first. All data reads/writes go through the Apps Script API. Keep getCurrentUser() calls consistent — never hardcode user identity elsewhere.


Check memory persistence after extension restart


File created successfully at: /Users/russgoerend/.claude/projects/-Users-russgoerend-Desktop-Portfolio-HTMLs/memory/project_portfolio_v2.md (file state is current in your context — no need to Read it back)

---
name: project-portfolio-v2
description: "Architecture decisions and build plan for APEX Portfolio v2.0 — SSO-ready, Google Sheets backend, instructor dashboard"
metadata:
  node_type: memory
  type: project
  originSessionId: 79d3087b-93bd-42dd-8f8b-a4ea5fbdfd1c
---

v2.0 of the APEX Portfolio system. Building with SSO in mind using an auth stub, so when SSO is approved only one function needs to change.

**Why:** Move from localStorage to a real backend, enable instructor dashboards, heat maps, artifact repository, cross-device sync, and Google Drive folder creation on first login.

**Target launch:** Start of 2026–27 school year (August 2026).

## Key Architecture Decisions

**Auth stub pattern:** Single `getCurrentUser()` function returns hardcoded test identity now. Swap for real Google OAuth when SSO approved. Supports `localStorage` override for testing both roles (student-associate / instructor).

**Backend:** One Google Spreadsheet (permanent, not per-year). `school_year` and `course_id` columns on all rows. Pre-aggregated `stats` sheet for instructor dashboard so it doesn't scan full tables. Apps Script web app as API layer — same pattern as BPRM and beta test form.

**Portfolio identity (Option C):** One portfolio per student-associate, career-spanning. Standards 1, 2, 3, 5 accumulate across all courses. Standard 4 is pathway-specific (tagged to course). Instructor dashboard filters to their course/cohort. A student in multiple APEX courses simultaneously shows in all relevant instructors' views.

**Folder structure:** v2 lives in a separate `v2/` subfolder during development. When ready, replaces v1 files entirely. v1 stays untouched until launch.

**Roster timing:** Rosters tab in Spreadsheet exists from day one but stays empty until August. Instructor dashboard shows graceful empty states. Manual CSV import when rosters are available; auto-sync when SSO lands.

## Spreadsheet Schema

Sheet tabs:
- `users` — id, name, email, role, strand, instructor_id, drive_folder_id, created_at
- `artifacts` — id, user_id, standard, artifact_id, title, link, reflection, done, opted_into_repo, course_id, school_year, updated_at
- `checkins` — id, user_id, date, project, worked, next, blockers, artifact_std, artifact_id, course_id, school_year, created_at
- `playbook_progress` — id, user_id, playbook, project_name, state_json, course_id, school_year, updated_at
- `rosters` — instructor_id, student_id, course_id, school_year
- `repository` — moderated opt-in anonymized artifact submissions
- `stats` — pre-aggregated instructor dashboard data (updated on every artifact save)

## Features by Phase

**Phase 1 — Foundation**
- Auth stub + getCurrentUser()
- Apps Script backend + all Sheets tabs
- Data sync layer (Sheets primary, localStorage as offline cache)
- All existing pages updated to use backend
- Reference implementation: Portfolio home + Standard 1

**Phase 2 — Enhanced Portfolio**
- Drag-and-drop UI on artifact cards (URL paste fallback; actual upload needs SSO)
- Shareable read-only portfolio link
- Artifact opt-in toggle for repository

**Phase 3 — Instructor Dashboard**
- Roster management UI (manual entry for now)
- Heat map per Standard (artifact cards colored by completion % across class)
- Individual student-associate progress view

**Phase 4 — Artifact Repository**
- Browse by Standard + artifact type
- Moderation queue (Russ approves before anything goes public)
- Submission flow from artifact cards

**Phase 5 — SSO Activation (when approved)**
- Swap getCurrentUser() for Google OAuth
- Enable Drive folder creation on first login
- Enable actual file upload on drag-and-drop
- Roster auto-sync if Google Workspace allows

## Features Deferred

- Notifications (v2.X, not v2.0)
- Gamification: must be minimal and opt-IN if ever added — see [[feedback-gamification]]

## Drive Folder Structure (SSO-dependent)

Created on first login if drive_folder_id is null in users sheet:
```
My Drive/
└── APEX Portfolio/
    ├── S1 — Servant Leadership/
    │   └── (one subfolder per artifact card)
    ├── S2 — Beyond GPA/
    ├── S3 — Career Exploration/
    ├── S4 — Technical Skills/
    └── S5 — Professional Skills/
```
Root folder ID saved to users sheet. Drag-and-drop uploads to matching artifact subfolder.

**How to apply:** When building any v2 feature, check this schema first. All data reads/writes go through the Apps Script API. Keep getCurrentUser() calls consistent — never hardcode user identity elsewhere.


Edit

## project-portfolio-v2
**File:** `project_portfolio_v2.md`
**Description:** Full architecture and build plan for APEX Portfolio v2.0 — SSO-ready, Google Sheets backend, instructor dashboard, artifact repository
**Type:** project
**Summary:** Auth stub pattern (swap one function for SSO). One permanent Spreadsheet, Option C portfolio identity (career-spanning, course-scoped instructor views). 5 build phases. Drive folder creation + drag-and-drop upload are SSO-dependent. Target: August 2026 launch.

---

## feedback-gamification


The file /Users/russgoerend/.claude/projects/-Users-russgoerend-Desktop-Portfolio-HTMLs/memory/MEMORY.md has been updated successfully. (file state is current in your context — no need to Read it back)

## project-portfolio-v2
**File:** `project_portfolio_v2.md`
**Description:** Full architecture and build plan for APEX Portfolio v2.0 — SSO-ready, Google Sheets backend, instructor dashboard, artifact repository
**Type:** project
**Summary:** Auth stub pattern (swap one function for SSO). One permanent Spreadsheet, Option C portfolio identity (career-spanning, course-scoped instructor views). 5 build phases. Drive folder creation + drag-and-drop upload are SSO-dependent. Target: August 2026 launch.

---

## feedback-gamification

# Project Memory

This file indexes all saved memories for the Portfolio HTMLs project.

---

## project-apex-portfolio
**File:** `project_apex_portfolio.md`
**Description:** Overview of the Waukee APEX portfolio system — what files exist, how they connect, and the design system in use
**Type:** project
**Summary:** Standalone offline HTML/CSS/JS system for Waukee APEX student-associates. Key files listed. Design system: purple `#500778` + lime `#c4d600`, Inter font, fixed top nav, white cards. Built for Russ Goerend's DCS class. No git repo, no build tools.

---

## project-playbook-pattern
**File:** `project_playbook_pattern.md`
**Description:** The playbook sidebar UI pattern used across Video, Social Media, and Marketing Analytics playbooks
**Type:** project
**Summary:** Shared sidebar UI pattern (sticky sidebar, phase groups, step-btn/step-check, progress bar). Project switcher stores multiple projects in localStorage. Save-before-switch bug fixed. Callout CSS classes documented. Reference implementations: Marketing Analytics and Social Media playbooks.

---

## project-foundational-content
**File:** `project_foundational_content.md`
**Description:** Foundational "Before You Start" orientation pattern added to Marketing Analytics and Social Media playbooks
**Type:** project
**Summary:** Phase 0 pattern for zero-knowledge students: plain-English intro, glossary (8–10 terms), tool overview, access-limitation callout, finished-project description. Marketing Analytics expanded Metrics Plan + Analysis steps. Social Media expanded Content Creation + Publishing + added Performance Review checkpoint. Fixed missing `.callout-yellow`/`.callout-blue` CSS. Video Production Playbook predates this pattern.

---

## apex-program
**File:** `project_apex_program.md`
**Description:** Core facts about Waukee APEX — mission, history, WILC location, tools, professional expectations
**Type:** project
**Summary:** 10+ year program at WILC (295 SE Ashworth Rd, Waukee). Tools: Canvas, Slack, Gmail, Google Calendar. Business casual required. 5 & 10 Rule. Performance reviews (not tests). Instructors go by first names. Real clients, real deliverables. Dual college credit.

---

## apex-elevated-language
**File:** `user_apex_elevated_language.md`
**Description:** Required APEX terminology — always use these instead of generic school language
**Type:** user
**Summary:** Student→Student-associate, Teacher→Instructor, Classroom→Studio/Lab/Workshop, Class (group)→Team, Class (course)→Course, Field trip→Tour. Must use in ALL written content for this project.

---

## apex-dcs
**File:** `project_apex_dcs.md`
**Description:** How Russ's DCS course works — client project structure, self-selection, check-ins, micro-deliverables
**Type:** project
**Summary:** Business partners pitch projects; student-associates self-select into teams; semester spent on real deliverables. Monday/Friday check-ins. Start with micro-deliverable. Portfolio captures evidence of this real work.

---

## apex-staff
**File:** `project_apex_staff.md`
**Description:** WILC staff list with roles, studio numbers, and emails (2024–2025 manual — may be outdated)
**Type:** project
**Summary:** Kathleen Sender = WILC Program Director (Russ's boss). Michelle Hill = Director of Work-Based Learning. Russ in Studio The Hue #111. Key colleagues: Jesse Hunt (Finance), Tyler Wright (Engineering), Ashley Prieksat (DWT). Russ noted list needs updating.

---

## apex-alumni
**File:** `project_apex_alumni.md`
**Description:** Notable APEX alumni and outcomes from the 2025 Alumni LookBook
**Type:** project
**Summary:** Alumni span all pathways: Sheldon Fox (PIMCO/Capital Group NYC), Ella Titus (fashion designer LA), Vivian Le (NBC/TODAY photographer — also named APEX), Jack Sieleman (NASA), Meaghan Greteman (med school). Real client projects documented.

---

## bprm-technical
**File:** `project_bprm_technical.md`
**Description:** BPRM tech stack, data model, Slack commands, and controlled vocabulary
**Type:** project
**Summary:** Sheets ID `15G8kZ64p...`, Slack `#bprm-log`, Make.com, Claude API `claude-sonnet-4-20250514`. Partner IDs: lowercase slugs. Project IDs: `dcs_` or `bs_`. Vibe values: Thriving/Steady/Cooling/Needs Attention. Sentiment: Positive/Neutral/Negative/Uncertain.

---

## feedback-gamification
**File:** `feedback_gamification.md`
**Description:** Russ's position on gamification — minimal and opt-in only, aspiring professional identity must be preserved
**Type:** feedback
**Summary:** No default gamification. Any streaks/badges must be opt-in. Engagement should come from the tool being genuinely useful, not reward loops.

---

## apex-courses
**File:** `project_apex_courses.md`
**Description:** All 17 APEX courses with abbreviations and sectors — for BPRM Lookup tab, system prompts, anywhere courses are referenced
**Type:** project
**Summary:** 17 courses, 5 sectors. BizTech: BSU, DCS, DWT. Financial: FBI, FIAS. Engineering: CL, ETR, A&E, NGE, Auto. Human Services: CNA, EHSM, ESS/SPORT, RES, EMT. Bioscience: AVS, GFS. Russ teaches BSU + DCS.

---

+**Description:** Full architecture and build plan for APEX Portfolio v2.0 — SSO-ready, Google Sheets backend, instructor dashboard, artifact repository

+**Summary:** Auth stub pattern (swap one function for SSO). One permanent Spreadsheet, Option C portfolio identity (career-spanning, course-scoped instructor views). 5 build phases. Drive folder creation + drag-and-drop upload are SSO-dependent. Target: August 2026 launch.


Architecture is saved. Whenever you're ready to build — now or after the school year ends — we pick up at Phase 1 and the plan is right here waiting.


I'm fine starting now. I realistically probably have more time over the next few weeks vs when I'm trying to unwind a bit during summer.


The user wants to start building now. Let me get into Phase 1.

Phase 1 involves:
1. Create the Google Spreadsheet with all tabs
2. Build the Apps Script web app (API layer)
3. Write the auth stub (`getCurrentUser()`)
4. Create the v2/ folder structure
5. Build a reference implementation (Portfolio home + Standard 1) using the backend

Let me start by:
1. Creating the Google Spreadsheet via Drive MCP
2. Then I'll write the Apps Script code
3. Then set up the v2/ folder
4. Then build the reference implementation

Let me create the Spreadsheet first.

I'll create it via Drive MCP with one default sheet, then write an Apps Script `setup()` function that generates all the tabs with their headers. Russ can run that once from the editor before deploying as a web app—it's clean and self-documenting.


Let's go. First thing is creating the Spreadsheet, then the Apps Script, then the v2/ folder and auth stub. Starting now.


APEX Portfolio — Backend (v2)


APEX Portfolio — Backend (v2)

APEX Portfolio — Backend (v2)

APEX Portfolio — Backend (v2)


The Spreadsheet was created with ID `1_pDD2Bc0EjeKpyeg6cjPhdDI0um3rZ0lrYIRbMuIFGg`.

Now I need to:
1. Write the Apps Script code that will set up all the tabs and serve as the API
2. Create the v2/ folder structure
3. Write the auth stub
4. Build the reference implementation

Let me write the Apps Script code. This is the most complex part. I'll write a comprehensive Apps Script that:

- Sets up a `setup()` function to initialize all tabs with headers
- Handles read operations through `doGet(e)` and write operations through `doPost(e)`
- Manages all CRUD operations across users, artifacts, checkins, playbook_progress, rosters, repository, and stats tables

Now I'm designing the API structure with GET endpoints for querying users, artifacts, checkins, playbook progress, and instructor data, along with corresponding POST endpoints for creating and updating records.

Building the Apps Script implementation with sheet helper utilities... I'm setting up the database schema by defining all the sheets and their column structures for the APEX Portfolio system, including users, artifacts, check-ins, playbook progress, rosters, repository entries, and statistics tables.

Now I'm initializing the spreadsheet by renaming the first sheet to 'users' and setting up headers, then creating the remaining sheets with their respective column definitions and formatting the header rows with bold text and background styling.

Setting up the API routing... Completing the POST handler with cases for saving user data, artifacts, check-ins, playbook progress, roster information, and repository submissions, then returning the response as JSON through a helper function.

Now I'm setting up sheet utility functions to retrieve sheets by name, convert sheet data into objects using headers as keys, and search for specific values within a column to find their row index. Writing user management functions...

Now I'm setting up the artifacts section, filtering by user, course, and school year, then checking for duplicates based on a combination of user ID, standard, artifact ID, and course ID before saving. I'm finishing up the artifact save function by updating stats and returning the row ID, then moving into the checkins section where I'm setting up functions to retrieve checkins filtered by user and sorted by date, plus a function to create new checkin records with fields like date, project, and progress notes.

Now I'm building out the playbook progress tracking, starting with a function that queries the playbook_progress sheet to find matching records based on user, playbook type, and project ID. Writing the save logic...

Now I'm moving on to handle roster queries—I need to fetch instructor rosters filtered by course and school year, then enrich the roster data by joining with user information. Now I'm building the heatmap data function that filters the roster by instructor and course, then pulls matching artifacts for those students, checking against the standard parameter.

Aggregating artifacts by ID...

Still writing the stats update function... Writing repository functions...

Now I'm planning the v2 folder structure with auth and API wrapper modules, then setting up the HTML files for each standard and the professional dashboards.

I think the best approach is to start with just the core files—auth.js, api.js, and a reference implementation in Standard1.html—then hand off the Apps Script deployment instructions to Russ so we can iterate on the rest of the pages afterward. I'm realizing that since the site is already deployed to a web server, I can use external script files for v2 instead of duplicating code inline like v1 did—this is actually the cleaner architecture for a deployed setup.

Now I'm organizing the core modules into separate files and setting up the folder structure with the Apps Script reference, the v2 directory with shared JavaScript utilities, and building out Standard1.html as the template that demonstrates how everything connects together.

Building the core modules...


TodoWrite

Create v2/ folder structure and core shared JS files (auth.js, api.js, config.js)

Write Apps Script and give deploy instructions

Build Standard1.html as the Phase 1 reference implementation

Building Standard1.html reference implementation


Todos have been modified successfully. Ensure that you continue to use the todo list to track your progress. Please proceed with the current tasks if applicable

Create v2/ folder structure and core shared JS files (auth.js, api.js, config.js)

Write Apps Script and give deploy instructions

Build Standard1.html as the Phase 1 reference implementation

Building Standard1.html reference implementation


Bash

mkdir -p "/Users/russgoerend/Desktop/Portfolio HTMLs/v2/js" && mkdir -p "/Users/russgoerend/Desktop/Portfolio HTMLs/v2/instructor" && echo "Folders created"

Create v2/ folder structure


Folders created


Write

// APEX Portfolio v2 — Shared Configuration
// Update SCRIPT_URL after deploying the Apps Script web app

const APEX_CONFIG = {
    SCRIPT_URL: 'PASTE_YOUR_APPS_SCRIPT_URL_HERE',
    CURRENT_YEAR: '2026-27',
    SPREADSHEET_ID: '1_pDD2Bc0EjeKpyeg6cjPhdDI0um3rZ0lrYIRbMuIFGg'
};


File created successfully at: /Users/russgoerend/Desktop/Portfolio HTMLs/v2/js/config.js (file state is current in your context — no need to Read it back)

// APEX Portfolio v2 — Shared Configuration
// Update SCRIPT_URL after deploying the Apps Script web app

const APEX_CONFIG = {
    SCRIPT_URL: 'PASTE_YOUR_APPS_SCRIPT_URL_HERE',
    CURRENT_YEAR: '2026-27',
    SPREADSHEET_ID: '1_pDD2Bc0EjeKpyeg6cjPhdDI0um3rZ0lrYIRbMuIFGg'
};


// APEX Portfolio v2 — Auth Layer
//
// STUB MODE: Returns a hardcoded test identity.
// When Google SSO is approved, replace the body of getCurrentUser()
// with the real Google OAuth token exchange. Nothing else changes.
//
// To test instructor views during development:
//   localStorage.setItem('apex_dev_user', JSON.stringify({ role: 'instructor', id: 'dev-inst-001', name: 'Russ Goerend', instructorId: 'dev-inst-001' }))
// To reset:
//   localStorage.removeItem('apex_dev_user')

function getCurrentUser() {
    // ── SWAP THIS FUNCTION WHEN SSO IS APPROVED ──────────────────────────────
    const devOverride = (() => {
        try { return JSON.parse(localStorage.getItem('apex_dev_user') || 'null'); }
        catch(e) { return null; }
    })();

    if (devOverride) return devOverride;

    return {
        id:           'dev-sa-001',
        name:         'Alex Chen',
        email:        'achen@waukeeschools.org',
        role:         'student-associate',  // 'student-associate' | 'instructor'
        strand:       'Digital Communications',
        courseId:     'dcs',
        instructorId: 'dev-inst-001',
        driveFolderId: null
    };
    // ── END SWAP ZONE ────────────────────────────────────────────────────────
}

function isInstructor() {
    return getCurrentUser().role === 'instructor';
}

function requireRole(role) {
    const user = getCurrentUser();
    if (user.role !== role) {
        document.body.innerHTML = `

Access restricted

This page requires the ${role} role.

            ← Back to Dashboard

`;
        throw new Error('Unauthorized');
    }
    return user;
}


File created successfully at: /Users/russgoerend/Desktop/Portfolio HTMLs/v2/js/auth.js (file state is current in your context — no need to Read it back)

// APEX Portfolio v2 — Auth Layer
//
// STUB MODE: Returns a hardcoded test identity.
// When Google SSO is approved, replace the body of getCurrentUser()
// with the real Google OAuth token exchange. Nothing else changes.
//
// To test instructor views during development:
//   localStorage.setItem('apex_dev_user', JSON.stringify({ role: 'instructor', id: 'dev-inst-001', name: 'Russ Goerend', instructorId: 'dev-inst-001' }))
// To reset:
//   localStorage.removeItem('apex_dev_user')

function getCurrentUser() {
    // ── SWAP THIS FUNCTION WHEN SSO IS APPROVED ──────────────────────────────
    const devOverride = (() => {
        try { return JSON.parse(localStorage.getItem('apex_dev_user') || 'null'); }
        catch(e) { return null; }
    })();

    if (devOverride) return devOverride;

    return {
        id:           'dev-sa-001',
        name:         'Alex Chen',
        email:        'achen@waukeeschools.org',
        role:         'student-associate',  // 'student-associate' | 'instructor'
        strand:       'Digital Communications',
        courseId:     'dcs',
        instructorId: 'dev-inst-001',
        driveFolderId: null
    };
    // ── END SWAP ZONE ────────────────────────────────────────────────────────
}

function isInstructor() {
    return getCurrentUser().role === 'instructor';
}

function requireRole(role) {
    const user = getCurrentUser();
    if (user.role !== role) {
        document.body.innerHTML = `

Access restricted

This page requires the ${role} role.

            ← Back to Dashboard

`;
        throw new Error('Unauthorized');
    }
    return user;
}


// APEX Portfolio v2 — API Client
// All communication with the Apps Script backend goes through this file.
// Falls back to localStorage when the script URL isn't configured yet.

const ApexAPI = (() => {

    function isConfigured() {
        return APEX_CONFIG.SCRIPT_URL && APEX_CONFIG.SCRIPT_URL !== 'PASTE_YOUR_APPS_SCRIPT_URL_HERE';
    }

    async function get(params) {
        if (!isConfigured()) return null;
        const url = new URL(APEX_CONFIG.SCRIPT_URL);
        Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v));
        try {
            const res = await fetch(url.toString());
            return await res.json();
        } catch(e) {
            console.warn('API GET failed:', e);
            return null;
        }
    }

    async function post(payload) {
        if (!isConfigured()) return null;
        try {
            await fetch(APEX_CONFIG.SCRIPT_URL, {
                method: 'POST',
                mode: 'no-cors',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify(payload)
            });
            return { status: 'ok' };
        } catch(e) {
            console.warn('API POST failed:', e);
            return null;
        }
    }

    // ── USERS ─────────────────────────────────────────────────────────────────

    async function ensureUser(user) {
        await post({ action: 'saveUser', ...user });
    }

    // ── ARTIFACTS ─────────────────────────────────────────────────────────────

    async function getArtifacts(userId, courseId) {
        return await get({ action: 'getArtifacts', userId, courseId }) || [];
    }

    async function saveArtifact(artifact) {
        const user = getCurrentUser();
        return await post({
            action: 'saveArtifact',
            ...artifact,
            user_id: user.id,
            course_id: artifact.course_id || user.courseId || '',
            school_year: APEX_CONFIG.CURRENT_YEAR
        });
    }

    // ── CHECK-INS ─────────────────────────────────────────────────────────────

    async function getCheckins(userId, limit) {
        return await get({ action: 'getCheckins', userId, limit: limit || 10 }) || [];
    }

    async function saveCheckin(checkin) {
        const user = getCurrentUser();
        return await post({
            action: 'saveCheckin',
            ...checkin,
            user_id: user.id,
            course_id: checkin.course_id || user.courseId || '',
            school_year: APEX_CONFIG.CURRENT_YEAR
        });
    }

    // ── PLAYBOOK PROGRESS ─────────────────────────────────────────────────────

    async function getPlaybookProgress(playbook, projectId) {
        const user = getCurrentUser();
        return await get({ action: 'getPlaybookProgress', userId: user.id, playbook, projectId });
    }

    async function getPlaybookProjects(playbook) {
        const user = getCurrentUser();
        return await get({ action: 'getPlaybookProjects', userId: user.id, playbook }) || [];
    }

    async function savePlaybookProgress(playbook, projectId, projectName, stateJson) {
        const user = getCurrentUser();
        return await post({
            action: 'savePlaybookProgress',
            user_id: user.id,
            playbook,
            project_id: projectId,
            project_name: projectName,
            state_json: typeof stateJson === 'string' ? stateJson : JSON.stringify(stateJson),
            course_id: user.courseId || '',
            school_year: APEX_CONFIG.CURRENT_YEAR
        });
    }

    // ── INSTRUCTOR ────────────────────────────────────────────────────────────

    async function getInstructorRoster(courseId, schoolYear) {
        const user = getCurrentUser();
        return await get({
            action: 'getInstructorRoster',
            instructorId: user.id,
            courseId,
            schoolYear: schoolYear || APEX_CONFIG.CURRENT_YEAR
        }) || [];
    }

    async function getHeatmapData(courseId, standard, schoolYear) {
        const user = getCurrentUser();
        return await get({
            action: 'getHeatmapData',
            instructorId: user.id,
            courseId,
            standard,
            schoolYear: schoolYear || APEX_CONFIG.CURRENT_YEAR
        });
    }

    // ── REPOSITORY ────────────────────────────────────────────────────────────

    async function getRepository(standard, artifactId) {
        return await get({ action: 'getRepository', standard, artifactId }) || [];
    }

    async function submitToRepository(artifact) {
        const user = getCurrentUser();
        return await post({
            action: 'submitToRepository',
            ...artifact,
            strand: user.strand
        });
    }

    return {
        isConfigured,
        ensureUser,
        getArtifacts,
        saveArtifact,
        getCheckins,
        saveCheckin,
        getPlaybookProgress,
        getPlaybookProjects,
        savePlaybookProgress,
        getInstructorRoster,
        getHeatmapData,
        getRepository,
        submitToRepository
    };

})();


File created successfully at: /Users/russgoerend/Desktop/Portfolio HTMLs/v2/js/api.js (file state is current in your context — no need to Read it back)

// APEX Portfolio v2 — API Client
// All communication with the Apps Script backend goes through this file.
// Falls back to localStorage when the script URL isn't configured yet.

const ApexAPI = (() => {

    function isConfigured() {
        return APEX_CONFIG.SCRIPT_URL && APEX_CONFIG.SCRIPT_URL !== 'PASTE_YOUR_APPS_SCRIPT_URL_HERE';
    }

    async function get(params) {
        if (!isConfigured()) return null;
        const url = new URL(APEX_CONFIG.SCRIPT_URL);
        Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v));
        try {
            const res = await fetch(url.toString());
            return await res.json();
        } catch(e) {
            console.warn('API GET failed:', e);
            return null;
        }
    }

    async function post(payload) {
        if (!isConfigured()) return null;
        try {
            await fetch(APEX_CONFIG.SCRIPT_URL, {
                method: 'POST',
                mode: 'no-cors',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify(payload)
            });
            return { status: 'ok' };
        } catch(e) {
            console.warn('API POST failed:', e);
            return null;
        }
    }

    // ── USERS ─────────────────────────────────────────────────────────────────

    async function ensureUser(user) {
        await post({ action: 'saveUser', ...user });
    }

    // ── ARTIFACTS ─────────────────────────────────────────────────────────────

    async function getArtifacts(userId, courseId) {
        return await get({ action: 'getArtifacts', userId, courseId }) || [];
    }

    async function saveArtifact(artifact) {
        const user = getCurrentUser();
        return await post({
            action: 'saveArtifact',
            ...artifact,
            user_id: user.id,
            course_id: artifact.course_id || user.courseId || '',
            school_year: APEX_CONFIG.CURRENT_YEAR
        });
    }

    // ── CHECK-INS ─────────────────────────────────────────────────────────────

    async function getCheckins(userId, limit) {
        return await get({ action: 'getCheckins', userId, limit: limit || 10 }) || [];
    }

    async function saveCheckin(checkin) {
        const user = getCurrentUser();
        return await post({
            action: 'saveCheckin',
            ...checkin,
            user_id: user.id,
            course_id: checkin.course_id || user.courseId || '',
            school_year: APEX_CONFIG.CURRENT_YEAR
        });
    }

    // ── PLAYBOOK PROGRESS ─────────────────────────────────────────────────────

    async function getPlaybookProgress(playbook, projectId) {
        const user = getCurrentUser();
        return await get({ action: 'getPlaybookProgress', userId: user.id, playbook, projectId });
    }

    async function getPlaybookProjects(playbook) {
        const user = getCurrentUser();
        return await get({ action: 'getPlaybookProjects', userId: user.id, playbook }) || [];
    }

    async function savePlaybookProgress(playbook, projectId, projectName, stateJson) {
        const user = getCurrentUser();
        return await post({
            action: 'savePlaybookProgress',
            user_id: user.id,
            playbook,
            project_id: projectId,
            project_name: projectName,
            state_json: typeof stateJson === 'string' ? stateJson : JSON.stringify(stateJson),
            course_id: user.courseId || '',
            school_year: APEX_CONFIG.CURRENT_YEAR
        });
    }

    // ── INSTRUCTOR ────────────────────────────────────────────────────────────

    async function getInstructorRoster(courseId, schoolYear) {
        const user = getCurrentUser();
        return await get({
            action: 'getInstructorRoster',
            instructorId: user.id,
            courseId,
            schoolYear: schoolYear || APEX_CONFIG.CURRENT_YEAR
        }) || [];
    }

    async function getHeatmapData(courseId, standard, schoolYear) {
        const user = getCurrentUser();
        return await get({
            action: 'getHeatmapData',
            instructorId: user.id,
            courseId,
            standard,
            schoolYear: schoolYear || APEX_CONFIG.CURRENT_YEAR
        });
    }

    // ── REPOSITORY ────────────────────────────────────────────────────────────

    async function getRepository(standard, artifactId) {
        return await get({ action: 'getRepository', standard, artifactId }) || [];
    }

    async function submitToRepository(artifact) {
        const user = getCurrentUser();
        return await post({
            action: 'submitToRepository',
            ...artifact,
            strand: user.strand
        });
    }

    return {
        isConfigured,
        ensureUser,
        getArtifacts,
        saveArtifact,
        getCheckins,
        saveCheckin,
        getPlaybookProgress,
        getPlaybookProjects,
        savePlaybookProgress,
        getInstructorRoster,
        getHeatmapData,
        getRepository,
        submitToRepository
    };

})();


Now write the Apps Script to a local reference file:


// APEX Portfolio v2 — Apps Script Backend
// Spreadsheet ID: 1_pDD2Bc0EjeKpyeg6cjPhdDI0um3rZ0lrYIRbMuIFGg
//
// DEPLOY INSTRUCTIONS:
//   1. Open the spreadsheet → Extensions → Apps Script
//   2. Paste this entire file, replacing any existing code
//   3. Click Save, then run setup() once from the editor
//   4. Deploy → New deployment → Web app
//      - Execute as: Me
//      - Who has access: Anyone
//   5. Copy the Web app URL into v2/js/config.js → SCRIPT_URL

const SS_ID = '1_pDD2Bc0EjeKpyeg6cjPhdDI0um3rZ0lrYIRbMuIFGg';
const CURRENT_YEAR = '2026-27';

// ── SETUP ─────────────────────────────────────────────────────────────────────
// Run this ONCE from the Apps Script editor after pasting the code.

function setup() {
    const ss = SpreadsheetApp.openById(SS_ID);

    const SHEETS = {
        users:            ['id','name','email','role','strand','instructor_id','drive_folder_id','created_at'],
        artifacts:        ['id','user_id','standard','artifact_id','title','link','reflection','done','opted_into_repo','course_id','school_year','updated_at'],
        checkins:         ['id','user_id','date','project','worked','next','blockers','artifact_std','artifact_id','course_id','school_year','created_at'],
        playbook_progress:['id','user_id','playbook','project_id','project_name','state_json','course_id','school_year','updated_at'],
        rosters:          ['instructor_id','student_id','course_id','school_year'],
        repository:       ['id','artifact_id','standard','title','link','reflection','strand','approved','submitted_at'],
        stats:            ['user_id','standard','total_cards','done_count','course_id','school_year','updated_at']
    };

    const existingNames = ss.getSheets().map(s => s.getName());

    // Rename Sheet1 → users
    if (existingNames.includes('Sheet1')) {
        ss.getSheetByName('Sheet1').setName('users');
    }

    Object.entries(SHEETS).forEach(([name, headers]) => {
        let sheet = ss.getSheetByName(name);
        if (!sheet) sheet = ss.insertSheet(name);
        if (sheet.getLastRow() === 0) {
            sheet.appendRow(headers);
            sheet.getRange(1, 1, 1, headers.length)
                .setFontWeight('bold')
                .setBackground('#500778')
                .setFontColor('#ffffff');
            sheet.setFrozenRows(1);
        }
    });

    Logger.log('Setup complete. All sheets initialized.');
}

// ── ROUTING ───────────────────────────────────────────────────────────────────

function doGet(e) {
    const p = e.parameter;
    try {
        switch(p.action) {
            case 'getUser':             return ok(getUser(p.userId));
            case 'getArtifacts':        return ok(getArtifacts(p.userId, p.courseId, p.schoolYear));
            case 'getCheckins':         return ok(getCheckins(p.userId, p.limit));
            case 'getPlaybookProgress': return ok(getPlaybookProgress(p.userId, p.playbook, p.projectId));
            case 'getPlaybookProjects': return ok(getPlaybookProjects(p.userId, p.playbook));
            case 'getInstructorRoster': return ok(getInstructorRoster(p.instructorId, p.courseId, p.schoolYear));
            case 'getHeatmapData':      return ok(getHeatmapData(p.instructorId, p.courseId, p.standard, p.schoolYear));
            case 'getRepository':       return ok(getRepository(p.standard, p.artifactId));
            default:                    return err('Unknown action: ' + p.action);
        }
    } catch(e) { return err(e.message); }
}

function doPost(e) {
    try {
        const data = JSON.parse(e.postData.contents);
        switch(data.action) {
            case 'saveUser':             return ok(saveUser(data));
            case 'saveArtifact':         return ok(saveArtifact(data));
            case 'saveCheckin':          return ok(saveCheckin(data));
            case 'savePlaybookProgress': return ok(savePlaybookProgress(data));
            case 'saveRoster':           return ok(saveRoster(data));
            case 'submitToRepository':   return ok(submitToRepository(data));
            default:                     return err('Unknown action: ' + data.action);
        }
    } catch(e) { return err(e.message); }
}

function ok(data)  { return ContentService.createTextOutput(JSON.stringify({ status:'ok', data })).setMimeType(ContentService.MimeType.JSON); }
function err(msg)  { return ContentService.createTextOutput(JSON.stringify({ status:'error', message: msg })).setMimeType(ContentService.MimeType.JSON); }

// ── SHEET HELPERS ─────────────────────────────────────────────────────────────

function sheet(name) { return SpreadsheetApp.openById(SS_ID).getSheetByName(name); }

function rows(name) {
    const s = sheet(name);
    const data = s.getDataRange().getValues();
    if (data.length < 2) return [];
    const headers = data[0];
    return data.slice(1).map(row => {
        const obj = {};
        headers.forEach((h, i) => { obj[h] = row[i]; });
        return obj;
    });
}

function headers(name) {
    const s = sheet(name);
    return s.getRange(1, 1, 1, s.getLastColumn()).getValues()[0];
}

function appendRow(name, obj) {
    const h = headers(name);
    sheet(name).appendRow(h.map(k => obj[k] !== undefined ? obj[k] : ''));
}

function updateRow(name, rowIndex, obj) {
    const h = headers(name);
    sheet(name).getRange(rowIndex, 1, 1, h.length).setValues([h.map(k => obj[k] !== undefined ? obj[k] : '')]);
}

function uid() { return Utilities.getUuid(); }
function now() { return new Date().toISOString(); }

// ── USERS ─────────────────────────────────────────────────────────────────────

function getUser(userId) {
    return rows('users').find(r => r.id === userId) || null;
}

function saveUser(data) {
    const all = rows('users');
    const idx = all.findIndex(r => r.id === data.id);
    const row = {
        id:             data.id || uid(),
        name:           data.name || '',
        email:          data.email || '',
        role:           data.role || 'student-associate',
        strand:         data.strand || '',
        instructor_id:  data.instructor_id || '',
        drive_folder_id:data.drive_folder_id || '',
        created_at:     data.created_at || now()
    };
    if (idx === -1) appendRow('users', row);
    else updateRow('users', idx + 2, row);
    return { id: row.id };
}

// ── ARTIFACTS ─────────────────────────────────────────────────────────────────

function getArtifacts(userId, courseId, schoolYear) {
    let r = rows('artifacts').filter(r => r.user_id === userId);
    if (courseId)   r = r.filter(a => a.course_id === courseId);
    if (schoolYear) r = r.filter(a => a.school_year === schoolYear);
    return r;
}

function saveArtifact(data) {
    const all = rows('artifacts');
    const idx = all.findIndex(r =>
        r.user_id    === data.user_id    &&
        r.standard   === data.standard   &&
        r.artifact_id=== data.artifact_id &&
        r.course_id  === (data.course_id || '')
    );
    const row = {
        id:             idx === -1 ? uid() : all[idx].id,
        user_id:        data.user_id,
        standard:       data.standard,
        artifact_id:    data.artifact_id,
        title:          data.title || '',
        link:           data.link || '',
        reflection:     data.reflection || '',
        done:           data.done ? 'true' : 'false',
        opted_into_repo:data.opted_into_repo ? 'true' : 'false',
        course_id:      data.course_id || '',
        school_year:    data.school_year || CURRENT_YEAR,
        updated_at:     now()
    };
    if (idx === -1) appendRow('artifacts', row);
    else updateRow('artifacts', idx + 2, row);
    updateStats(data.user_id, data.standard, data.course_id, data.school_year);
    return { id: row.id };
}

// ── CHECK-INS ─────────────────────────────────────────────────────────────────

function getCheckins(userId, limit) {
    let r = rows('checkins')
        .filter(r => r.user_id === userId)
        .sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
    return limit ? r.slice(0, parseInt(limit)) : r;
}

function saveCheckin(data) {
    const row = {
        id:          uid(),
        user_id:     data.user_id,
        date:        data.date || '',
        project:     data.project || '',
        worked:      data.worked || '',
        next:        data.next || '',
        blockers:    data.blockers || '',
        artifact_std:data.artifact_std || '',
        artifact_id: data.artifact_id || '',
        course_id:   data.course_id || '',
        school_year: data.school_year || CURRENT_YEAR,
        created_at:  now()
    };
    appendRow('checkins', row);
    return { id: row.id };
}

// ── PLAYBOOK PROGRESS ─────────────────────────────────────────────────────────

function getPlaybookProgress(userId, playbook, projectId) {
    return rows('playbook_progress').find(r =>
        r.user_id === userId && r.playbook === playbook && r.project_id === projectId
    ) || null;
}

function getPlaybookProjects(userId, playbook) {
    return rows('playbook_progress')
        .filter(r => r.user_id === userId && r.playbook === playbook)
        .map(r => ({ project_id: r.project_id, project_name: r.project_name }));
}

function savePlaybookProgress(data) {
    const all = rows('playbook_progress');
    const idx = all.findIndex(r =>
        r.user_id === data.user_id && r.playbook === data.playbook && r.project_id === data.project_id
    );
    const projectId = data.project_id || uid();
    const row = {
        id:           idx === -1 ? uid() : all[idx].id,
        user_id:      data.user_id,
        playbook:     data.playbook,
        project_id:   projectId,
        project_name: data.project_name || 'Untitled Project',
        state_json:   typeof data.state_json === 'string' ? data.state_json : JSON.stringify(data.state_json),
        course_id:    data.course_id || '',
        school_year:  data.school_year || CURRENT_YEAR,
        updated_at:   now()
    };
    if (idx === -1) appendRow('playbook_progress', row);
    else updateRow('playbook_progress', idx + 2, row);
    return { id: row.id, project_id: projectId };
}

// ── ROSTERS ───────────────────────────────────────────────────────────────────

function getInstructorRoster(instructorId, courseId, schoolYear) {
    const allUsers = rows('users');
    let roster = rows('rosters').filter(r => r.instructor_id === instructorId);
    if (courseId)   roster = roster.filter(r => r.course_id === courseId);
    if (schoolYear) roster = roster.filter(r => r.school_year === schoolYear);
    return roster.map(r => {
        const user = allUsers.find(u => u.id === r.student_id) || {};
        return { ...r, name: user.name || '', strand: user.strand || '', email: user.email || '' };
    });
}

function saveRoster(data) {
    const exists = rows('rosters').some(r =>
        r.instructor_id === data.instructor_id &&
        r.student_id    === data.student_id    &&
        r.course_id     === data.course_id     &&
        r.school_year   === (data.school_year || CURRENT_YEAR)
    );
    if (!exists) {
        sheet('rosters').appendRow([data.instructor_id, data.student_id, data.course_id, data.school_year || CURRENT_YEAR]);
    }
    return { status: 'ok' };
}

// ── HEAT MAP ──────────────────────────────────────────────────────────────────

function getHeatmapData(instructorId, courseId, standard, schoolYear) {
    let roster = rows('rosters').filter(r => r.instructor_id === instructorId && r.course_id === courseId);
    if (schoolYear) roster = roster.filter(r => r.school_year === schoolYear);
    const studentIds = roster.map(r => r.student_id);
    if (!studentIds.length) return { studentCount: 0, artifacts: [] };

    const artifacts = rows('artifacts').filter(r =>
        studentIds.includes(r.user_id) && r.standard === standard
    );

    const map = {};
    artifacts.forEach(a => {
        if (!map[a.artifact_id]) map[a.artifact_id] = { artifact_id: a.artifact_id, title: a.title, done: 0, started: 0, total: studentIds.length };
        if (a.done === 'true') map[a.artifact_id].done++;
        else if (a.link || a.reflection) map[a.artifact_id].started++;
    });

    return { studentCount: studentIds.length, artifacts: Object.values(map) };
}

function updateStats(userId, standard, courseId, schoolYear) {
    const all = rows('stats');
    const userArtifacts = rows('artifacts').filter(r =>
        r.user_id === userId && r.standard === standard && r.course_id === (courseId || '')
    );
    const stat = {
        user_id:    userId,
        standard:   standard,
        total_cards:userArtifacts.length,
        done_count: userArtifacts.filter(a => a.done === 'true').length,
        course_id:  courseId || '',
        school_year:schoolYear || CURRENT_YEAR,
        updated_at: now()
    };
    const idx = all.findIndex(r =>
        r.user_id === userId && r.standard === standard && r.course_id === (courseId || '')
    );
    if (idx === -1) appendRow('stats', stat);
    else updateRow('stats', idx + 2, stat);
}

// ── REPOSITORY ────────────────────────────────────────────────────────────────

function getRepository(standard, artifactId) {
    let r = rows('repository').filter(r => r.approved === 'true');
    if (standard)   r = r.filter(a => a.standard === standard);
    if (artifactId) r = r.filter(a => a.artifact_id === artifactId);
    return r;
}

function submitToRepository(data) {
    appendRow('repository', {
        id:          uid(),
        artifact_id: data.artifact_id || '',
        standard:    data.standard || '',
        title:       data.title || '',
        link:        data.link || '',
        reflection:  data.reflection || '',
        strand:      data.strand || '',
        approved:    'false',
        submitted_at:now()
    });
    return { status: 'ok' };
}


File created successfully at: /Users/russgoerend/Desktop/Portfolio HTMLs/v2/APPS_SCRIPT.js (file state is current in your context — no need to Read it back)

// APEX Portfolio v2 — Apps Script Backend
// Spreadsheet ID: 1_pDD2Bc0EjeKpyeg6cjPhdDI0um3rZ0lrYIRbMuIFGg
//
// DEPLOY INSTRUCTIONS:
//   1. Open the spreadsheet → Extensions → Apps Script
//   2. Paste this entire file, replacing any existing code
//   3. Click Save, then run setup() once from the editor
//   4. Deploy → New deployment → Web app
//      - Execute as: Me
//      - Who has access: Anyone
//   5. Copy the Web app URL into v2/js/config.js → SCRIPT_URL

const SS_ID = '1_pDD2Bc0EjeKpyeg6cjPhdDI0um3rZ0lrYIRbMuIFGg';
const CURRENT_YEAR = '2026-27';

// ── SETUP ─────────────────────────────────────────────────────────────────────
// Run this ONCE from the Apps Script editor after pasting the code.

function setup() {
    const ss = SpreadsheetApp.openById(SS_ID);

    const SHEETS = {
        users:            ['id','name','email','role','strand','instructor_id','drive_folder_id','created_at'],
        artifacts:        ['id','user_id','standard','artifact_id','title','link','reflection','done','opted_into_repo','course_id','school_year','updated_at'],
        checkins:         ['id','user_id','date','project','worked','next','blockers','artifact_std','artifact_id','course_id','school_year','created_at'],
        playbook_progress:['id','user_id','playbook','project_id','project_name','state_json','course_id','school_year','updated_at'],
        rosters:          ['instructor_id','student_id','course_id','school_year'],
        repository:       ['id','artifact_id','standard','title','link','reflection','strand','approved','submitted_at'],
        stats:            ['user_id','standard','total_cards','done_count','course_id','school_year','updated_at']
    };

    const existingNames = ss.getSheets().map(s => s.getName());

    // Rename Sheet1 → users
    if (existingNames.includes('Sheet1')) {
        ss.getSheetByName('Sheet1').setName('users');
    }

    Object.entries(SHEETS).forEach(([name, headers]) => {
        let sheet = ss.getSheetByName(name);
        if (!sheet) sheet = ss.insertSheet(name);
        if (sheet.getLastRow() === 0) {
            sheet.appendRow(headers);
            sheet.getRange(1, 1, 1, headers.length)
                .setFontWeight('bold')
                .setBackground('#500778')
                .setFontColor('#ffffff');
            sheet.setFrozenRows(1);
        }
    });

    Logger.log('Setup complete. All sheets initialized.');
}

// ── ROUTING ───────────────────────────────────────────────────────────────────

function doGet(e) {
    const p = e.parameter;
    try {
        switch(p.action) {
            case 'getUser':             return ok(getUser(p.userId));
            case 'getArtifacts':        return ok(getArtifacts(p.userId, p.courseId, p.schoolYear));
            case 'getCheckins':         return ok(getCheckins(p.userId, p.limit));
            case 'getPlaybookProgress': return ok(getPlaybookProgress(p.userId, p.playbook, p.projectId));
            case 'getPlaybookProjects': return ok(getPlaybookProjects(p.userId, p.playbook));
            case 'getInstructorRoster': return ok(getInstructorRoster(p.instructorId, p.courseId, p.schoolYear));
            case 'getHeatmapData':      return ok(getHeatmapData(p.instructorId, p.courseId, p.standard, p.schoolYear));
            case 'getRepository':       return ok(getRepository(p.standard, p.artifactId));
            default:                    return err('Unknown action: ' + p.action);
        }
    } catch(e) { return err(e.message); }
}

function doPost(e) {
    try {
        const data = JSON.parse(e.postData.contents);
        switch(data.action) {
            case 'saveUser':             return ok(saveUser(data));
            case 'saveArtifact':         return ok(saveArtifact(data));
            case 'saveCheckin':          return ok(saveCheckin(data));
            case 'savePlaybookProgress': return ok(savePlaybookProgress(data));
            case 'saveRoster':           return ok(saveRoster(data));
            case 'submitToRepository':   return ok(submitToRepository(data));
            default:                     return err('Unknown action: ' + data.action);
        }
    } catch(e) { return err(e.message); }
}

function ok(data)  { return ContentService.createTextOutput(JSON.stringify({ status:'ok', data })).setMimeType(ContentService.MimeType.JSON); }
function err(msg)  { return ContentService.createTextOutput(JSON.stringify({ status:'error', message: msg })).setMimeType(ContentService.MimeType.JSON); }

// ── SHEET HELPERS ─────────────────────────────────────────────────────────────

function sheet(name) { return SpreadsheetApp.openById(SS_ID).getSheetByName(name); }

function rows(name) {
    const s = sheet(name);
    const data = s.getDataRange().getValues();
    if (data.length < 2) return [];
    const headers = data[0];
    return data.slice(1).map(row => {
        const obj = {};
        headers.forEach((h, i) => { obj[h] = row[i]; });
        return obj;
    });
}

function headers(name) {
    const s = sheet(name);
    return s.getRange(1, 1, 1, s.getLastColumn()).getValues()[0];
}

function appendRow(name, obj) {
    const h = headers(name);
    sheet(name).appendRow(h.map(k => obj[k] !== undefined ? obj[k] : ''));
}

function updateRow(name, rowIndex, obj) {
    const h = headers(name);
    sheet(name).getRange(rowIndex, 1, 1, h.length).setValues([h.map(k => obj[k] !== undefined ? obj[k] : '')]);
}

function uid() { return Utilities.getUuid(); }
function now() { return new Date().toISOString(); }

// ── USERS ─────────────────────────────────────────────────────────────────────

function getUser(userId) {
    return rows('users').find(r => r.id === userId) || null;
}

function saveUser(data) {
    const all = rows('users');
    const idx = all.findIndex(r => r.id === data.id);
    const row = {
        id:             data.id || uid(),
        name:           data.name || '',
        email:          data.email || '',
        role:           data.role || 'student-associate',
        strand:         data.strand || '',
        instructor_id:  data.instructor_id || '',
        drive_folder_id:data.drive_folder_id || '',
        created_at:     data.created_at || now()
    };
    if (idx === -1) appendRow('users', row);
    else updateRow('users', idx + 2, row);
    return { id: row.id };
}

// ── ARTIFACTS ─────────────────────────────────────────────────────────────────

function getArtifacts(userId, courseId, schoolYear) {
    let r = rows('artifacts').filter(r => r.user_id === userId);
    if (courseId)   r = r.filter(a => a.course_id === courseId);
    if (schoolYear) r = r.filter(a => a.school_year === schoolYear);
    return r;
}

function saveArtifact(data) {
    const all = rows('artifacts');
    const idx = all.findIndex(r =>
        r.user_id    === data.user_id    &&
        r.standard   === data.standard   &&
        r.artifact_id=== data.artifact_id &&
        r.course_id  === (data.course_id || '')
    );
    const row = {
        id:             idx === -1 ? uid() : all[idx].id,
        user_id:        data.user_id,
        standard:       data.standard,
        artifact_id:    data.artifact_id,
        title:          data.title || '',
        link:           data.link || '',
        reflection:     data.reflection || '',
        done:           data.done ? 'true' : 'false',
        opted_into_repo:data.opted_into_repo ? 'true' : 'false',
        course_id:      data.course_id || '',
        school_year:    data.school_year || CURRENT_YEAR,
        updated_at:     now()
    };
    if (idx === -1) appendRow('artifacts', row);
    else updateRow('artifacts', idx + 2, row);
    updateStats(data.user_id, data.standard, data.course_id, data.school_year);
    return { id: row.id };
}

// ── CHECK-INS ─────────────────────────────────────────────────────────────────

function getCheckins(userId, limit) {
    let r = rows('checkins')
        .filter(r => r.user_id === userId)
        .sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
    return limit ? r.slice(0, parseInt(limit)) : r;
}

function saveCheckin(data) {
    const row = {
        id:          uid(),
        user_id:     data.user_id,
        date:        data.date || '',
        project:     data.project || '',
        worked:      data.worked || '',
        next:        data.next || '',
        blockers:    data.blockers || '',
        artifact_std:data.artifact_std || '',
        artifact_id: data.artifact_id || '',
        course_id:   data.course_id || '',
        school_year: data.school_year || CURRENT_YEAR,
        created_at:  now()
    };
    appendRow('checkins', row);
    return { id: row.id };
}

// ── PLAYBOOK PROGRESS ─────────────────────────────────────────────────────────

function getPlaybookProgress(userId, playbook, projectId) {
    return rows('playbook_progress').find(r =>
        r.user_id === userId && r.playbook === playbook && r.project_id === projectId
    ) || null;
}

function getPlaybookProjects(userId, playbook) {
    return rows('playbook_progress')
        .filter(r => r.user_id === userId && r.playbook === playbook)
        .map(r => ({ project_id: r.project_id, project_name: r.project_name }));
}

function savePlaybookProgress(data) {
    const all = rows('playbook_progress');
    const idx = all.findIndex(r =>
        r.user_id === data.user_id && r.playbook === data.playbook && r.project_id === data.project_id
    );
    const projectId = data.project_id || uid();
    const row = {
        id:           idx === -1 ? uid() : all[idx].id,
        user_id:      data.user_id,
        playbook:     data.playbook,
        project_id:   projectId,
        project_name: data.project_name || 'Untitled Project',
        state_json:   typeof data.state_json === 'string' ? data.state_json : JSON.stringify(data.state_json),
        course_id:    data.course_id || '',
        school_year:  data.school_year || CURRENT_YEAR,
        updated_at:   now()
    };
    if (idx === -1) appendRow('playbook_progress', row);
    else updateRow('playbook_progress', idx + 2, row);
    return { id: row.id, project_id: projectId };
}

// ── ROSTERS ───────────────────────────────────────────────────────────────────

function getInstructorRoster(instructorId, courseId, schoolYear) {
    const allUsers = rows('users');
    let roster = rows('rosters').filter(r => r.instructor_id === instructorId);
    if (courseId)   roster = roster.filter(r => r.course_id === courseId);
    if (schoolYear) roster = roster.filter(r => r.school_year === schoolYear);
    return roster.map(r => {
        const user = allUsers.find(u => u.id === r.student_id) || {};
        return { ...r, name: user.name || '', strand: user.strand || '', email: user.email || '' };
    });
}

function saveRoster(data) {
    const exists = rows('rosters').some(r =>
        r.instructor_id === data.instructor_id &&
        r.student_id    === data.student_id    &&
        r.course_id     === data.course_id     &&
        r.school_year   === (data.school_year || CURRENT_YEAR)
    );
    if (!exists) {
        sheet('rosters').appendRow([data.instructor_id, data.student_id, data.course_id, data.school_year || CURRENT_YEAR]);
    }
    return { status: 'ok' };
}

// ── HEAT MAP ──────────────────────────────────────────────────────────────────

function getHeatmapData(instructorId, courseId, standard, schoolYear) {
    let roster = rows('rosters').filter(r => r.instructor_id === instructorId && r.course_id === courseId);
    if (schoolYear) roster = roster.filter(r => r.school_year === schoolYear);
    const studentIds = roster.map(r => r.student_id);
    if (!studentIds.length) return { studentCount: 0, artifacts: [] };

    const artifacts = rows('artifacts').filter(r =>
        studentIds.includes(r.user_id) && r.standard === standard
    );

    const map = {};
    artifacts.forEach(a => {
        if (!map[a.artifact_id]) map[a.artifact_id] = { artifact_id: a.artifact_id, title: a.title, done: 0, started: 0, total: studentIds.length };
        if (a.done === 'true') map[a.artifact_id].done++;
        else if (a.link || a.reflection) map[a.artifact_id].started++;
    });

    return { studentCount: studentIds.length, artifacts: Object.values(map) };
}

function updateStats(userId, standard, courseId, schoolYear) {
    const all = rows('stats');
    const userArtifacts = rows('artifacts').filter(r =>
        r.user_id === userId && r.standard === standard && r.course_id === (courseId || '')
    );
    const stat = {
        user_id:    userId,
        standard:   standard,
        total_cards:userArtifacts.length,
        done_count: userArtifacts.filter(a => a.done === 'true').length,
        course_id:  courseId || '',
        school_year:schoolYear || CURRENT_YEAR,
        updated_at: now()
    };
    const idx = all.findIndex(r =>
        r.user_id === userId && r.standard === standard && r.course_id === (courseId || '')
    );
    if (idx === -1) appendRow('stats', stat);
    else updateRow('stats', idx + 2, stat);
}

// ── REPOSITORY ────────────────────────────────────────────────────────────────

function getRepository(standard, artifactId) {
    let r = rows('repository').filter(r => r.approved === 'true');
    if (standard)   r = r.filter(a => a.standard === standard);
    if (artifactId) r = r.filter(a => a.artifact_id === artifactId);
    return r;
}

function submitToRepository(data) {
    appendRow('repository', {
        id:          uid(),
        artifact_id: data.artifact_id || '',
        standard:    data.standard || '',
        title:       data.title || '',
        link:        data.link || '',
        reflection:  data.reflection || '',
        strand:      data.strand || '',
        approved:    'false',
        submitted_at:now()
    });
    return { status: 'ok' };
}


TodoWrite

Create v2/ folder structure and core shared JS files (auth.js, api.js, config.js)

Write Apps Script and give deploy instructions

Build Standard1.html as the Phase 1 reference implementation

Building Standard1.html reference implementation


Foundation is built. Here's where we are and what you need to do right now before we write a single page:

---

**Deploy the Apps Script — same 3 steps as before:**

1. Open [APEX Portfolio — Backend (v2)](https://docs.google.com/spreadsheets/d/1_pDD2Bc0EjeKpyeg6cjPhdDI0um3rZ0lrYIRbMuIFGg) → **Extensions → Apps Script** → paste the entire contents of [v2/APPS_SCRIPT.js](v2/APPS_SCRIPT.js) → Save

2. Run `setup()` once from the editor (this creates all 7 sheet tabs with headers)

3. **Deploy → New deployment → Web app** — Execute as: Me, Who has access: Anyone → copy the URL → paste it into [v2/js/config.js](v2/js/config.js) replacing `PASTE_YOUR_APPS_SCRIPT_URL_HERE`

Once you've done that and sent me the URL, I'll update config.js and we'll start building Standard 1 as the reference implementation — the page that proves the whole pattern works end-to-end before we touch anything else.


<ide_opened_file>The user opened the file /Users/russgoerend/Desktop/Portfolio HTMLs/v2/js/config.js in the IDE. This may or may not be related to the current task.</ide_opened_file>

Done. I even updated config.js on my own thank you very much.


1	// APEX Portfolio v2 — Shared Configuration
2	// Update SCRIPT_URL after deploying the Apps Script web app
3
4	const APEX_CONFIG = {
5	    SCRIPT_URL: 'https://script.google.com/macros/s/AKfycbzAkt9EroCbDxqkXvy7Hk9ceETGKZmitpG1EU3srEyJdWq7lmN69NOzXUS1J4-F_qrF/exec',
6	    CURRENT_YEAR: '2026-27',
7	    SPREADSHEET_ID: '1_pDD2Bc0EjeKpyeg6cjPhdDI0um3rZ0lrYIRbMuIFGg'
8	};


The user has deployed the Apps Script and updated config.js with the URL themselves. Now I should build Standard1.html as the reference implementation for v2.

Let me think about what this involves:
1. Copy the structure from v1/Standard1.html but update it to use the backend
2. Include the three shared JS files (config.js, auth.js, api.js)
3. Replace the localStorage read/write with API calls
4. Keep the UI identical to v1 from the user's perspective
5. The key change is: on load, fetch artifacts from the API; on save, write to the API (and keep localStorage as cache)

6. I need to adapt the existing kanban structure to call `getCurrentUser()` on page load, fetch artifacts from the API for the current user and standard, render the cards, and handle saves by writing to the API while maintaining localStorage as a fallback for offline scenarios. I need to figure out how to store the column information in the backend since each artifact row needs to know which column it belongs to—whether it's servant, client, team, or brand.

I could add a `column` field directly to the artifacts schema, or encode it into the artifact_id itself, but storing the entire kanban state as JSON would be cleaner and consistent with how playbook_progress works. The tradeoff is that per-artifact rows are necessary for the heat map feature to work properly, so I'll go with adding a `column` field to the schema.

Looking at my current artifacts schema, I don't have that field yet, and the Apps Script is already deployed. But actually, the column for default cards is already determined by the config—each card has a fixed position. I only need to track the column for custom cards, so I could either encode it in the title or handle it separately when saving artifacts.

The real issue is that modifying the schema requires redeploying the script, since the headers array controls how data gets written to the spreadsheet. For v1, I need to figure out if I can work around this limitation or if redeployment is unavoidable.

Actually, thinking about this more practically: for DEFAULT cards, the column is always recoverable from the config itself, so I don't need to store it. For CUSTOM cards, I could encode the column directly into the artifact_id (like `servant:custom-1234567890`) and parse it back out when needed. But the cleaner approach is just to have Russ redeploy the script with `column` added to the schema—it's early enough that this won't be a big deal.

Or I could sidestep the per-artifact storage entirely and store the whole kanban state as a single JSON record per standard per user, similar to how playbook_progress works. That would be simpler to implement right now without needing schema changes.

The tradeoff is that we'd lose per-artifact analytics for the heat map feature, but we can add that as a separate sync step later when we build the instructor dashboard in Phase 3. For Phase 1, getting everything synced to the backend cleanly is the priority.

But wait—if I go that route, I'd need a new sheet like `portfolio_state` or repurpose `playbook_progress` differently. Let me think about this again.

Actually, the right move is to just add the `column` field to the artifacts sheet. I'll update the Apps Script schema and have Russ redeploy it.

The setup function won't overwrite existing headers since it checks if the sheet is empty first, but the artifacts sheet already has headers, so the new column won't get added automatically. I could either ask Russ to manually add the header, write a migration function, or encode the column information directly into the artifact ID itself using a format like `{column}:{original_id}`.

But encoding it that way breaks backward compatibility with v1 data, and asking Russ to manually update the Apps Script and redeploy creates too much friction. I think the cleanest solution is to store the entire kanban state as JSON in a new column using the same pattern we use for playbook progress, rather than trying to track individual columns.

For Phase 1, I'll store standard state in `playbook_progress` where the `playbook` field is something like `standard-s1`, and the `state_json` holds the full kanban state. This works with the existing schema, loads efficiently, and gives us the flexibility to extract per-artifact data later when we build the heat maps. The tradeoff is that Phase 1 uses this JSON approach while Phase 3 adds proper per-artifact tracking as a separate sync.

Now I'm realizing `playbook_progress` already has a `project_id` field, so I need to figure out how standards map to that.

For the reference implementation in Standard1.html, I'll load the three shared JS files, initialize by fetching the current user, then try to pull state from the API using `playbook_progress` with the standard identifier, falling back to localStorage if needed. The save flow will write to both localStorage and trigger an async API call, with a merge strategy that favors newer API data when reconciling local and remote state.

Setting up the HTML file with the shared scripts and initialization config... I need to examine the full `apex-kanban.js` file to understand its structure before adapting it for v2 with API sync capabilities.


Look at you. Let me read the full kanban script before building Standard 1, so I know exactly what I'm extending.