Compare commits

..

No commits in common. "80f8031ecc40a8601751dc1756038f488da1023d" and "6eb44d2531ea55a804e232937a5caa4945e8c0ad" have entirely different histories.

2 changed files with 33 additions and 280 deletions

View file

@ -26,7 +26,7 @@ describe("today's assignments", () => {
interceptDashboardRequests() interceptDashboardRequests()
}) })
it('shows only the earliest due assignment for each schedule', () => { it('uses the browser date and links every assignment to its schedule', () => {
cy.intercept('GET', `**/api/assignments?date=${browserToday}`, (request) => { cy.intercept('GET', `**/api/assignments?date=${browserToday}`, (request) => {
expect(request.headers.accept).to.equal('application/json') expect(request.headers.accept).to.equal('application/json')
request.reply({ request.reply({
@ -60,58 +60,6 @@ describe("today's assignments", () => {
path: ['Introduction'], path: ['Introduction'],
}, },
}, },
{
id: 14,
scheduledDate: browserToday,
schedule: {
id: 73,
set: { name: 'Bible' },
},
element: {
name: 'Chapter 2',
kind: 'Chapter_sections-v2',
path: ['Genesis', 'The garden', 'Chapter 2'],
},
},
{
id: 15,
scheduledDate: browserToday,
schedule: {
id: 73,
set: { name: 'Bible' },
},
element: {
name: 'Chapter 3',
kind: 'Chapter_sections-v2',
path: ['Genesis', 'The fall', 'Chapter 3'],
},
},
{
id: 16,
scheduledDate: browserToday,
schedule: {
id: 73,
set: { name: 'Bible' },
},
element: {
name: 'Chapter 4',
kind: 'Chapter_sections-v2',
path: ['Genesis', 'Cain and Abel', 'Chapter 4'],
},
},
{
id: 17,
scheduledDate: browserToday,
schedule: {
id: 73,
set: { name: 'Bible' },
},
element: {
name: 'Chapter 5',
kind: 'Chapter_sections-v2',
path: ['Genesis', 'Generations', 'Chapter 5'],
},
},
], ],
}, },
}) })
@ -141,40 +89,6 @@ describe("today's assignments", () => {
cy.get('[data-today-assignment="13"] .today-assignment__overdue').should( cy.get('[data-today-assignment="13"] .today-assignment__overdue').should(
'not.exist', 'not.exist',
) )
cy.get('[data-today-assignment="14"]').should('not.exist')
cy.get('[data-today-assignment="15"]').should('not.exist')
cy.get('[data-today-assignment="16"]').should('not.exist')
cy.get('[data-today-assignment="17"]').should('not.exist')
cy.get('[data-today-schedule="73"]')
.should('have.attr', 'data-due-count', '5')
.and('contain.text', '5 due now')
.and('have.class', 'today-assignment-stack--queued')
.within(() => {
cy.get('[data-stack-layer]').should('have.length', 3)
})
.then(($stack) => {
const stack = $stack[0]
if (stack === undefined) {
throw new Error('today assignment stack not found')
}
const assignmentCard = stack.querySelector<HTMLElement>('.today-assignment')
const backgroundCard = stack.querySelector<HTMLElement>('[data-stack-layer]')
if (assignmentCard === null || backgroundCard === null) {
throw new Error('today assignment stack cards not found')
}
expect(backgroundCard.getBoundingClientRect().left).to.be.lessThan(
assignmentCard.getBoundingClientRect().left,
)
})
cy.get('[data-today-schedule="81"]')
.should('have.attr', 'data-due-count', '1')
.and('not.have.class', 'today-assignment-stack--queued')
.within(() => {
cy.get('.today-assignment__queue-count').should('not.exist')
cy.get('[data-stack-layer]').should('not.exist')
})
cy.contains('a', 'Genesis / Creation / Chapter 1') cy.contains('a', 'Genesis / Creation / Chapter 1')
.should('contain.text', 'Bible') .should('contain.text', 'Bible')
.and('have.attr', 'href', '/schedules/73') .and('have.attr', 'href', '/schedules/73')
@ -230,19 +144,6 @@ describe("today's assignments", () => {
path: ['Genesis', 'Chapter 1'], path: ['Genesis', 'Chapter 1'],
}, },
}, },
{
id: 13,
scheduledDate: browserToday,
schedule: {
id: 73,
set: { name: 'Bible' },
},
element: {
name: 'Chapter 2',
kind: 'chapter',
path: ['Genesis', 'Chapter 2'],
},
},
], ],
}, },
}).as('todayAssignments') }).as('todayAssignments')
@ -268,52 +169,25 @@ describe("today's assignments", () => {
}, },
}) })
}) })
cy.intercept('PATCH', '**/api/assignments/13', (request) => {
expect(request.body).to.deep.equal({ completed: true })
request.reply({
delay: 400,
statusCode: 200,
body: {
assignment: {
id: 13,
completedAt: '2026-08-15T12:35:00+00:00',
},
},
})
}).as('nextCompletion')
cy.visit('/dashboard') cy.visit('/dashboard')
cy.wait('@me') cy.wait('@me')
cy.wait('@todayAssignments') cy.wait('@todayAssignments')
cy.get('[data-today-assignment="12"]').within(() => { cy.get('[data-today-assignment="12"]').within(() => {
cy.get('[data-stack-layer]').should('have.length', 1)
cy.contains('button', 'Mark complete').click() cy.contains('button', 'Mark complete').click()
cy.contains('button', 'Completing...').should('be.disabled') cy.contains('button', 'Completing...').should('be.disabled')
}) })
cy.wait('@completion1') cy.wait('@completion1')
cy.get('[data-today-assignment="12"]') cy.get('[data-today-assignment="12"]')
.should('contain.text', "We couldn't update this assignment.") .should('contain.text', "We couldn't update this assignment.")
.and('contain.text', '2 due now')
.within(() => { .within(() => {
cy.get('[data-stack-layer]').should('have.length', 1)
cy.contains('button', 'Mark complete').click() cy.contains('button', 'Mark complete').click()
cy.contains('button', 'Completing...').should('be.disabled') cy.contains('button', 'Completing...').should('be.disabled')
}) })
cy.wait('@completion2') cy.wait('@completion2')
cy.get('[data-today-assignment="12"]').should('not.exist') cy.get('[data-today-assignment="12"]').should('not.exist')
cy.get('[data-today-assignment="13"]')
.should('be.visible')
.and('have.attr', 'data-due-count', '1')
.within(() => {
cy.get('.today-assignment__queue-count').should('not.exist')
cy.get('[data-stack-layer]').should('not.exist')
cy.contains('button', 'Mark complete').click()
})
cy.wait('@nextCompletion')
cy.get('[data-today-assignment="13"]').should('not.exist')
cy.get('.today-assignments [role="status"]').should( cy.get('.today-assignments [role="status"]').should(
'contain.text', 'contain.text',
'Nothing is due today.', 'Nothing is due today.',

View file

@ -1,40 +1,17 @@
<script setup lang="ts"> <script setup lang="ts">
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { computed, onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
import AuthenticatedHeader from '@/components/AuthenticatedHeader.vue' import AuthenticatedHeader from '@/components/AuthenticatedHeader.vue'
import { type AssignmentForDate, useSchedulesStore } from '@/stores/schedules' import { useSchedulesStore } from '@/stores/schedules'
import { useSetsStore } from '@/stores/sets' import { useSetsStore } from '@/stores/sets'
type TodayAssignmentQueue = {
assignment: AssignmentForDate
dueCount: number
}
const setsStore = useSetsStore() const setsStore = useSetsStore()
const schedulesStore = useSchedulesStore() const schedulesStore = useSchedulesStore()
const { sets, loading, error } = storeToRefs(setsStore) const { sets, loading, error } = storeToRefs(setsStore)
const { schedules, listLoading, listError } = storeToRefs(schedulesStore) const { schedules, listLoading, listError } = storeToRefs(schedulesStore)
const { assignmentsForDate, assignmentsLoading, assignmentsError } = storeToRefs(schedulesStore) const { assignmentsForDate, assignmentsLoading, assignmentsError } = storeToRefs(schedulesStore)
const todayDate = ref(browserDate(new Date())) const todayDate = ref(browserDate(new Date()))
const todayAssignmentQueues = computed<TodayAssignmentQueue[]>(() => {
const queuesBySchedule = new Map<number, TodayAssignmentQueue>()
for (const assignment of assignmentsForDate.value) {
const queue = queuesBySchedule.get(assignment.schedule.id)
if (queue !== undefined) {
queue.dueCount += 1
continue
}
queuesBySchedule.set(assignment.schedule.id, {
assignment,
dueCount: 1,
})
}
return Array.from(queuesBySchedule.values())
})
onMounted(async () => { onMounted(async () => {
await Promise.all([ await Promise.all([
@ -67,10 +44,6 @@ function overdueLabel(scheduledDate: string): string {
return `Overdue · Due ${formatDate(scheduledDate)}` return `Overdue · Due ${formatDate(scheduledDate)}`
} }
function stackLayerCount(dueCount: number): number {
return Math.min(dueCount - 1, 3)
}
async function completeAssignment(assignmentId: number): Promise<void> { async function completeAssignment(assignmentId: number): Promise<void> {
await schedulesStore.setAssignmentCompleted(assignmentId, true) await schedulesStore.setAssignmentCompleted(assignmentId, true)
} }
@ -110,65 +83,36 @@ async function completeAssignment(assignmentId: number): Promise<void> {
</button> </button>
</div> </div>
<p v-else-if="todayAssignmentQueues.length === 0" class="today-state" role="status"> <p v-else-if="assignmentsForDate.length === 0" class="today-state" role="status">
Nothing is due today. Nothing is due today.
</p> </p>
<ul v-else class="today-assignment-list" aria-label="Today's assignments"> <ul v-else class="today-assignment-list" aria-label="Today's assignments">
<li <li
v-for="queue in todayAssignmentQueues" v-for="assignment in assignmentsForDate"
:key="queue.assignment.schedule.id" :key="assignment.id"
class="today-assignment-stack" :data-today-assignment="assignment.id"
:class="[
`today-assignment-stack--layers-${stackLayerCount(queue.dueCount)}`,
{ 'today-assignment-stack--queued': queue.dueCount > 1 },
]"
:data-due-count="queue.dueCount"
:data-today-assignment="queue.assignment.id"
:data-today-schedule="queue.assignment.schedule.id"
> >
<span
v-for="layerIndex in stackLayerCount(queue.dueCount)"
:key="layerIndex"
aria-hidden="true"
class="today-assignment-stack__layer"
:class="`today-assignment-stack__layer--${layerIndex}`"
data-stack-layer
></span>
<article class="today-assignment"> <article class="today-assignment">
<RouterLink <RouterLink
class="today-assignment-link" class="today-assignment-link"
:to="{ :to="{
name: 'schedule-detail', name: 'schedule-detail',
params: { scheduleId: queue.assignment.schedule.id }, params: { scheduleId: assignment.schedule.id },
}" }"
> >
<div class="today-assignment__heading"> <div class="today-assignment__heading">
<p>{{ queue.assignment.schedule.set.name }}</p> <p>{{ assignment.schedule.set.name }}</p>
<span class="today-assignment__kind"> <span class="today-assignment__kind">{{ assignment.element.kind }}</span>
{{ queue.assignment.element.kind }}
</span>
</div> </div>
<div
v-if="isOverdue(queue.assignment.scheduledDate) || queue.dueCount > 1"
class="today-assignment__status"
>
<time <time
v-if="isOverdue(queue.assignment.scheduledDate)" v-if="isOverdue(assignment.scheduledDate)"
class="today-assignment__overdue" class="today-assignment__overdue"
:datetime="queue.assignment.scheduledDate" :datetime="assignment.scheduledDate"
v-text="overdueLabel(queue.assignment.scheduledDate)" v-text="overdueLabel(assignment.scheduledDate)"
></time> ></time>
<span
v-if="queue.dueCount > 1"
class="today-assignment__queue-count"
:aria-label="`${queue.dueCount} assignments from this schedule are due now. Complete this assignment first.`"
>
{{ queue.dueCount }} due now
</span>
</div>
<p class="today-assignment__path"> <p class="today-assignment__path">
{{ queue.assignment.element.path.join(' / ') }} {{ assignment.element.path.join(' / ') }}
</p> </p>
<span class="today-assignment__action"> <span class="today-assignment__action">
Open schedule Open schedule
@ -177,21 +121,21 @@ async function completeAssignment(assignmentId: number): Promise<void> {
</RouterLink> </RouterLink>
<div class="today-assignment__completion"> <div class="today-assignment__completion">
<p <p
v-if="schedulesStore.assignmentCompletionError(queue.assignment.id) !== null" v-if="schedulesStore.assignmentCompletionError(assignment.id) !== null"
class="assignment-completion-error" class="assignment-completion-error"
role="alert" role="alert"
> >
{{ schedulesStore.assignmentCompletionError(queue.assignment.id) }} {{ schedulesStore.assignmentCompletionError(assignment.id) }}
</p> </p>
<button <button
type="button" type="button"
class="assignment-completion-button" class="assignment-completion-button"
:aria-label="`Mark ${queue.assignment.element.path.join(' / ')} complete`" :aria-label="`Mark ${assignment.element.path.join(' / ')} complete`"
:disabled="schedulesStore.isAssignmentCompletionPending(queue.assignment.id)" :disabled="schedulesStore.isAssignmentCompletionPending(assignment.id)"
@click="completeAssignment(queue.assignment.id)" @click="completeAssignment(assignment.id)"
> >
{{ {{
schedulesStore.isAssignmentCompletionPending(queue.assignment.id) schedulesStore.isAssignmentCompletionPending(assignment.id)
? 'Completing...' ? 'Completing...'
: 'Mark complete' : 'Mark complete'
}} }}
@ -411,52 +355,6 @@ async function completeAssignment(assignmentId: number): Promise<void> {
list-style: none; list-style: none;
} }
.today-assignment-stack {
position: relative;
display: flex;
min-width: 0;
}
.today-assignment-stack--queued {
padding-left: 0.18rem;
}
.today-assignment-stack--layers-2 {
padding-left: 0.36rem;
}
.today-assignment-stack--layers-3 {
padding-left: 0.54rem;
}
.today-assignment-stack__layer {
position: absolute;
top: 0.3rem;
right: 0;
bottom: 0.3rem;
border: 1px solid rgb(24 48 41 / 9%);
border-radius: 0.85rem;
background: #eee9dc;
pointer-events: none;
}
.today-assignment-stack__layer--1 {
z-index: 1;
left: 0;
}
.today-assignment-stack__layer--2 {
z-index: 2;
left: 0.18rem;
background: #e9e3d5;
}
.today-assignment-stack__layer--3 {
z-index: 3;
left: 0.36rem;
background: #e4ddce;
}
.today-assignment-link { .today-assignment-link {
display: block; display: block;
color: inherit; color: inherit;
@ -464,13 +362,10 @@ async function completeAssignment(assignmentId: number): Promise<void> {
} }
.today-assignment { .today-assignment {
position: relative;
z-index: 4;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
width: 100%;
height: 100%; height: 100%;
padding: 1.15rem; padding: 1.4rem;
border: 1px solid rgb(24 48 41 / 12%); border: 1px solid rgb(24 48 41 / 12%);
border-radius: 1rem; border-radius: 1rem;
background: linear-gradient(135deg, rgb(255 253 247 / 98%), rgb(244 238 225 / 86%)); background: linear-gradient(135deg, rgb(255 253 247 / 98%), rgb(244 238 225 / 86%));
@ -509,37 +404,21 @@ async function completeAssignment(assignmentId: number): Promise<void> {
text-transform: none; text-transform: none;
} }
.today-assignment__status { .today-assignment__overdue {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
margin-top: 0.65rem;
}
.today-assignment__overdue,
.today-assignment__queue-count {
display: inline-flex; display: inline-flex;
width: fit-content; width: fit-content;
margin-top: 0.85rem;
padding: 0.3rem 0.55rem; padding: 0.3rem 0.55rem;
border-radius: 999px; border-radius: 999px;
color: #923f31;
background: #f7e5dc;
font-size: 0.68rem; font-size: 0.68rem;
font-weight: 800; font-weight: 800;
letter-spacing: 0.025em; letter-spacing: 0.025em;
} }
.today-assignment__overdue {
color: #923f31;
background: #f7e5dc;
}
.today-assignment__queue-count {
color: #285c4e;
background: #e2ebe4;
}
.today-assignment__path { .today-assignment__path {
margin: 1.15rem 0 0; margin: 1.75rem 0 0;
color: #183029; color: #183029;
font-family: Georgia, 'Times New Roman', serif; font-family: Georgia, 'Times New Roman', serif;
font-size: 1.45rem; font-size: 1.45rem;
@ -550,7 +429,7 @@ async function completeAssignment(assignmentId: number): Promise<void> {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 0.35rem; gap: 0.35rem;
margin-top: 0.85rem; margin-top: 1.25rem;
color: #567064; color: #567064;
font-size: 0.76rem; font-size: 0.76rem;
font-weight: 750; font-weight: 750;
@ -561,13 +440,13 @@ async function completeAssignment(assignmentId: number): Promise<void> {
justify-items: start; justify-items: start;
gap: 0.65rem; gap: 0.65rem;
margin-top: auto; margin-top: auto;
padding-top: 0.85rem; padding-top: 1.15rem;
border-top: 1px solid rgb(24 48 41 / 10%); border-top: 1px solid rgb(24 48 41 / 10%);
} }
.assignment-completion-button { .assignment-completion-button {
min-height: 2.35rem; min-height: 2.5rem;
padding: 0.5rem 0.8rem; padding: 0.6rem 0.9rem;
border: 1px solid rgb(24 58 49 / 28%); border: 1px solid rgb(24 58 49 / 28%);
border-radius: 0.7rem; border-radius: 0.7rem;
color: #fffdf7; color: #fffdf7;