Compare commits
6 commits
6eb44d2531
...
80f8031ecc
| Author | SHA1 | Date | |
|---|---|---|---|
| 80f8031ecc | |||
| c91a7b2793 | |||
| 0ea1f7a21c | |||
| 934965025e | |||
| fea6c3f7a8 | |||
| ba6e4908da |
2 changed files with 280 additions and 33 deletions
|
|
@ -26,7 +26,7 @@ describe("today's assignments", () => {
|
|||
interceptDashboardRequests()
|
||||
})
|
||||
|
||||
it('uses the browser date and links every assignment to its schedule', () => {
|
||||
it('shows only the earliest due assignment for each schedule', () => {
|
||||
cy.intercept('GET', `**/api/assignments?date=${browserToday}`, (request) => {
|
||||
expect(request.headers.accept).to.equal('application/json')
|
||||
request.reply({
|
||||
|
|
@ -60,6 +60,58 @@ describe("today's assignments", () => {
|
|||
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'],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
|
@ -89,6 +141,40 @@ describe("today's assignments", () => {
|
|||
cy.get('[data-today-assignment="13"] .today-assignment__overdue').should(
|
||||
'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')
|
||||
.should('contain.text', 'Bible')
|
||||
.and('have.attr', 'href', '/schedules/73')
|
||||
|
|
@ -144,6 +230,19 @@ describe("today's assignments", () => {
|
|||
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')
|
||||
|
|
@ -169,25 +268,52 @@ 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.wait('@me')
|
||||
cy.wait('@todayAssignments')
|
||||
|
||||
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', 'Completing...').should('be.disabled')
|
||||
})
|
||||
cy.wait('@completion1')
|
||||
cy.get('[data-today-assignment="12"]')
|
||||
.should('contain.text', "We couldn't update this assignment.")
|
||||
.and('contain.text', '2 due now')
|
||||
.within(() => {
|
||||
cy.get('[data-stack-layer]').should('have.length', 1)
|
||||
cy.contains('button', 'Mark complete').click()
|
||||
cy.contains('button', 'Completing...').should('be.disabled')
|
||||
})
|
||||
cy.wait('@completion2')
|
||||
|
||||
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(
|
||||
'contain.text',
|
||||
'Nothing is due today.',
|
||||
|
|
|
|||
|
|
@ -1,17 +1,40 @@
|
|||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
import AuthenticatedHeader from '@/components/AuthenticatedHeader.vue'
|
||||
import { useSchedulesStore } from '@/stores/schedules'
|
||||
import { type AssignmentForDate, useSchedulesStore } from '@/stores/schedules'
|
||||
import { useSetsStore } from '@/stores/sets'
|
||||
|
||||
type TodayAssignmentQueue = {
|
||||
assignment: AssignmentForDate
|
||||
dueCount: number
|
||||
}
|
||||
|
||||
const setsStore = useSetsStore()
|
||||
const schedulesStore = useSchedulesStore()
|
||||
const { sets, loading, error } = storeToRefs(setsStore)
|
||||
const { schedules, listLoading, listError } = storeToRefs(schedulesStore)
|
||||
const { assignmentsForDate, assignmentsLoading, assignmentsError } = storeToRefs(schedulesStore)
|
||||
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 () => {
|
||||
await Promise.all([
|
||||
|
|
@ -44,6 +67,10 @@ function overdueLabel(scheduledDate: string): string {
|
|||
return `Overdue · Due ${formatDate(scheduledDate)}`
|
||||
}
|
||||
|
||||
function stackLayerCount(dueCount: number): number {
|
||||
return Math.min(dueCount - 1, 3)
|
||||
}
|
||||
|
||||
async function completeAssignment(assignmentId: number): Promise<void> {
|
||||
await schedulesStore.setAssignmentCompleted(assignmentId, true)
|
||||
}
|
||||
|
|
@ -83,36 +110,65 @@ async function completeAssignment(assignmentId: number): Promise<void> {
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<p v-else-if="assignmentsForDate.length === 0" class="today-state" role="status">
|
||||
<p v-else-if="todayAssignmentQueues.length === 0" class="today-state" role="status">
|
||||
Nothing is due today.
|
||||
</p>
|
||||
|
||||
<ul v-else class="today-assignment-list" aria-label="Today's assignments">
|
||||
<li
|
||||
v-for="assignment in assignmentsForDate"
|
||||
:key="assignment.id"
|
||||
:data-today-assignment="assignment.id"
|
||||
v-for="queue in todayAssignmentQueues"
|
||||
:key="queue.assignment.schedule.id"
|
||||
class="today-assignment-stack"
|
||||
: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">
|
||||
<RouterLink
|
||||
class="today-assignment-link"
|
||||
:to="{
|
||||
name: 'schedule-detail',
|
||||
params: { scheduleId: assignment.schedule.id },
|
||||
params: { scheduleId: queue.assignment.schedule.id },
|
||||
}"
|
||||
>
|
||||
<div class="today-assignment__heading">
|
||||
<p>{{ assignment.schedule.set.name }}</p>
|
||||
<span class="today-assignment__kind">{{ assignment.element.kind }}</span>
|
||||
<p>{{ queue.assignment.schedule.set.name }}</p>
|
||||
<span class="today-assignment__kind">
|
||||
{{ queue.assignment.element.kind }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="isOverdue(queue.assignment.scheduledDate) || queue.dueCount > 1"
|
||||
class="today-assignment__status"
|
||||
>
|
||||
<time
|
||||
v-if="isOverdue(assignment.scheduledDate)"
|
||||
v-if="isOverdue(queue.assignment.scheduledDate)"
|
||||
class="today-assignment__overdue"
|
||||
:datetime="assignment.scheduledDate"
|
||||
v-text="overdueLabel(assignment.scheduledDate)"
|
||||
:datetime="queue.assignment.scheduledDate"
|
||||
v-text="overdueLabel(queue.assignment.scheduledDate)"
|
||||
></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">
|
||||
{{ assignment.element.path.join(' / ') }}
|
||||
{{ queue.assignment.element.path.join(' / ') }}
|
||||
</p>
|
||||
<span class="today-assignment__action">
|
||||
Open schedule
|
||||
|
|
@ -121,21 +177,21 @@ async function completeAssignment(assignmentId: number): Promise<void> {
|
|||
</RouterLink>
|
||||
<div class="today-assignment__completion">
|
||||
<p
|
||||
v-if="schedulesStore.assignmentCompletionError(assignment.id) !== null"
|
||||
v-if="schedulesStore.assignmentCompletionError(queue.assignment.id) !== null"
|
||||
class="assignment-completion-error"
|
||||
role="alert"
|
||||
>
|
||||
{{ schedulesStore.assignmentCompletionError(assignment.id) }}
|
||||
{{ schedulesStore.assignmentCompletionError(queue.assignment.id) }}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="assignment-completion-button"
|
||||
:aria-label="`Mark ${assignment.element.path.join(' / ')} complete`"
|
||||
:disabled="schedulesStore.isAssignmentCompletionPending(assignment.id)"
|
||||
@click="completeAssignment(assignment.id)"
|
||||
:aria-label="`Mark ${queue.assignment.element.path.join(' / ')} complete`"
|
||||
:disabled="schedulesStore.isAssignmentCompletionPending(queue.assignment.id)"
|
||||
@click="completeAssignment(queue.assignment.id)"
|
||||
>
|
||||
{{
|
||||
schedulesStore.isAssignmentCompletionPending(assignment.id)
|
||||
schedulesStore.isAssignmentCompletionPending(queue.assignment.id)
|
||||
? 'Completing...'
|
||||
: 'Mark complete'
|
||||
}}
|
||||
|
|
@ -355,6 +411,52 @@ async function completeAssignment(assignmentId: number): Promise<void> {
|
|||
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 {
|
||||
display: block;
|
||||
color: inherit;
|
||||
|
|
@ -362,10 +464,13 @@ async function completeAssignment(assignmentId: number): Promise<void> {
|
|||
}
|
||||
|
||||
.today-assignment {
|
||||
position: relative;
|
||||
z-index: 4;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 1.4rem;
|
||||
padding: 1.15rem;
|
||||
border: 1px solid rgb(24 48 41 / 12%);
|
||||
border-radius: 1rem;
|
||||
background: linear-gradient(135deg, rgb(255 253 247 / 98%), rgb(244 238 225 / 86%));
|
||||
|
|
@ -404,21 +509,37 @@ async function completeAssignment(assignmentId: number): Promise<void> {
|
|||
text-transform: none;
|
||||
}
|
||||
|
||||
.today-assignment__overdue {
|
||||
.today-assignment__status {
|
||||
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;
|
||||
width: fit-content;
|
||||
margin-top: 0.85rem;
|
||||
padding: 0.3rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
color: #923f31;
|
||||
background: #f7e5dc;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.025em;
|
||||
}
|
||||
|
||||
.today-assignment__overdue {
|
||||
color: #923f31;
|
||||
background: #f7e5dc;
|
||||
}
|
||||
|
||||
.today-assignment__queue-count {
|
||||
color: #285c4e;
|
||||
background: #e2ebe4;
|
||||
}
|
||||
|
||||
.today-assignment__path {
|
||||
margin: 1.75rem 0 0;
|
||||
margin: 1.15rem 0 0;
|
||||
color: #183029;
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
font-size: 1.45rem;
|
||||
|
|
@ -429,7 +550,7 @@ async function completeAssignment(assignmentId: number): Promise<void> {
|
|||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
margin-top: 1.25rem;
|
||||
margin-top: 0.85rem;
|
||||
color: #567064;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 750;
|
||||
|
|
@ -440,13 +561,13 @@ async function completeAssignment(assignmentId: number): Promise<void> {
|
|||
justify-items: start;
|
||||
gap: 0.65rem;
|
||||
margin-top: auto;
|
||||
padding-top: 1.15rem;
|
||||
padding-top: 0.85rem;
|
||||
border-top: 1px solid rgb(24 48 41 / 10%);
|
||||
}
|
||||
|
||||
.assignment-completion-button {
|
||||
min-height: 2.5rem;
|
||||
padding: 0.6rem 0.9rem;
|
||||
min-height: 2.35rem;
|
||||
padding: 0.5rem 0.8rem;
|
||||
border: 1px solid rgb(24 58 49 / 28%);
|
||||
border-radius: 0.7rem;
|
||||
color: #fffdf7;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue